@volter/editor-game 0.5.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,1554 @@
1
+ /**
2
+ * PixiAuthoringAdapter — THE editor {@link AuthoringAdapter} for the CANVAS
3
+ * surface, whatever authored the display tree it is handed.
4
+ *
5
+ * It is keyed on the surface, never on provenance (ARCHITECTURE-CORE §Rules):
6
+ * a second adapter class keyed on where the graph came from is the forbidden
7
+ * shape, so a first-party `@pixi/react` world and an ingested game's captured
8
+ * stage are the SAME class here, differing only along two declared axes:
9
+ *
10
+ * - **identity** — {@link CanvasIdentity}, the engine-side collaborator the
11
+ * tree walk takes. `createOidCanvasIdentity(worldId)` keys rows on the
12
+ * `data-oid` a first-party TSX world's containers carry (stable across a
13
+ * remount, so selection survives one); `STRUCTURAL_CANVAS_IDENTITY` keys
14
+ * them on the tree's shape, which is all a graph with no authored address
15
+ * can honestly offer.
16
+ * - **persistence** — {@link CanvasWriteTarget}, this module's collaborator.
17
+ * `createSourceCanvasWriteTarget` writes literal JSX props back into the
18
+ * project's `.tsx` through the existing `ui-source` writer;
19
+ * `createLiveCanvasWriteTarget` has nowhere to write and says so
20
+ * (`provenance.detail`), journaling its edits for the session only.
21
+ *
22
+ * Everything else — the tree projection, selection, viewport picking, the 2D
23
+ * transform vocabulary, late-structural-commit re-indexing — is shared, because
24
+ * none of it depends on who wrote the tree. Selection ink is the shared 2D
25
+ * overlay (`rects`); this adapter never writes filters onto live objects.
26
+ *
27
+ * It wraps the engine's backend-neutral {@link AuthoringAdapter2D} (the walk,
28
+ * the ids, the 2D transform primitives) and translates the engine's 2D
29
+ * transform (x, y, scalar rotation) to/from the editor's neutral
30
+ * {@link Transform} through `pixi-transform-channels.ts`.
31
+ */
32
+
33
+ import {
34
+ AuthoringAdapter2D,
35
+ type CanvasIdentity,
36
+ type Transform2DValue,
37
+ } from '@volter/game-runtime/pixi/authoring';
38
+ import type {
39
+ AssetDropContext,
40
+ AssetDropProvider,
41
+ AuthoringAdapter,
42
+ AuthoringCapabilities,
43
+ AuthoringProvenance,
44
+ AuthoringTruth,
45
+ BoxEditProvider,
46
+ ComponentInstancesProvider,
47
+ DOMRectLike,
48
+ EditorNode,
49
+ HierarchyProvider,
50
+ InspectorProvider,
51
+ PersistenceProvider,
52
+ PickProvider,
53
+ PropertyDescriptor,
54
+ RectProvider,
55
+ RelatedSubjectsProvider,
56
+ SelectionProvider,
57
+ SpatialHandlesProvider,
58
+ SpatialPoint3,
59
+ StoriesProvider,
60
+ StructuralIdWrite,
61
+ StructuralWriteOutcome,
62
+ StructureProvider,
63
+ Transform,
64
+ TransformChannel,
65
+ TransformEditability,
66
+ TransformProvider,
67
+ TruthProvider,
68
+ WriteAck,
69
+ } from '@volter/editor-project/adapter';
70
+ import type { Container, Graphics, Matrix, PointData, Sprite, Text, Texture } from 'pixi.js';
71
+ import * as shellPixi from 'pixi.js';
72
+ import type { CanvasPixiNamespace } from '../canvas-entry-runtime';
73
+ import { componentStatesProvider } from '@volter/editor-core/component-states-registry';
74
+ import { editorConsole } from '@volter/editor-core/editor-console';
75
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
76
+ import type { JournalSubject } from '../history/json-history-resource';
77
+ import { PixiProjector } from '../projection/pixi';
78
+ import { creationSiteRelated } from './creation-site-related';
79
+ import {
80
+ isCanvasComponentInstanceRoot,
81
+ readContainerAuthoringComponent,
82
+ readContainerAuthoringLabel,
83
+ } from './pixi-source-identity';
84
+ import { CanvasStructureHistory } from './pixi-structure-history';
85
+ import { fromNeutralTransform, toNeutralTransform } from './pixi-transform-channels';
86
+ import { resolvesLiveOnly, runWritePipe } from '@volter/editor-core/authoring/write-pipe';
87
+
88
+ /**
89
+ * What a write target is handed at construction — the live view it edits
90
+ * through, and the store it journals/notifies against. A target never walks
91
+ * the tree itself; the adapter owns that.
92
+ */
93
+ export interface CanvasWriteContext {
94
+ readonly a2d: AuthoringAdapter2D;
95
+ readonly store: EditorShellStore;
96
+ /** Whose journal a live-only edit through this target belongs to — passed
97
+ * through from {@link PixiAuthoringOptions.journal}. */
98
+ readonly journal: JournalSubject;
99
+ /** Tell the adapter's subscribers something changed. */
100
+ notify(): void;
101
+ }
102
+
103
+ /**
104
+ * THE PERSISTENCE AXIS. One implementation writes the project's own TSX
105
+ * source; the other has no source to write and reports that honestly.
106
+ *
107
+ * Deliberately a boundary seam with two genuine implementers — the one
108
+ * exception the no-wrappers rule records — rather than an `if (source)` branch
109
+ * threaded through the adapter, because the two halves disagree about every
110
+ * question a write raises: what a channel's editability is, what an inspector
111
+ * row means, and what happens when an edit ends.
112
+ */
113
+ export interface CanvasWriteTarget {
114
+ /** Declared ONCE per adapter and rendered at the seam — never per row. */
115
+ readonly provenance: AuthoringProvenance;
116
+ /** A target with nowhere to write reports the ephemeral provider; a source
117
+ * target reports the auto-save destination and project-history failures even
118
+ * though there is no pending whole-document flush. */
119
+ readonly persistence?: PersistenceProvider;
120
+ /** Source-backed structural edits when the target can rewrite its native
121
+ * document. Absent targets receive the adapter's live Pixi operations. */
122
+ readonly structure?: StructureProvider;
123
+ /** Source-native file/component placement when this target can persist it. */
124
+ readonly assetDrop?: AssetDropProvider;
125
+ /**
126
+ * The source address of one node, for the inspector's READ surface.
127
+ *
128
+ * ABSENT ⇒ this target's substrate has no creation-site index to answer from,
129
+ * and the adapter reports no `truth` provider at all rather than a
130
+ * fabricated "unknown". Present ⇒ every id gets an answer, anchored or
131
+ * reasoned. First-party TSX uses the same OID index as its guarded writes.
132
+ */
133
+ truth?(id: string, property: string): AuthoringTruth;
134
+ /**
135
+ * The COMPONENT-INSTANCE seam over this target's own truth: on the canvas
136
+ * surface the CREATION SITE is the component, its literals are the defaults,
137
+ * and the live object is the instance that may differ from them.
138
+ *
139
+ * ABSENT ⇒ this target has no source to diff a live value against, and the
140
+ * adapter reports no `instances` provider at all rather than one whose
141
+ * "defaults" it invented — the same conditional shape as `creationSite`.
142
+ */
143
+ instances?(): ComponentInstancesProvider;
144
+ /** Portable-CSF association for a native component instance root. */
145
+ componentIdentity?(id: string): { name: string; sourcePath?: string } | null;
146
+ /**
147
+ * Put a node's transform ORIGIN — a container's `pivot`, a sprite's normalized
148
+ * `anchor` — on the live object, mid-gesture.
149
+ *
150
+ * BRACKETED BY `beginTransformEdit`/`endTransformEdit`, exactly as
151
+ * {@link writeTransform} is, because moving an origin is one gesture that
152
+ * moves TWO values: the origin itself and the `position` that compensates for
153
+ * it so the rendered content stays where the author put it. One bracket is
154
+ * what makes the pair one history transaction.
155
+ *
156
+ * ABSENT ⇒ this target cannot write an origin, and the adapter's spatial
157
+ * handle reports itself non-writable rather than moving something that will
158
+ * snap back.
159
+ */
160
+ writeOrigin?(id: string, kind: 'pivot' | 'anchor', value: readonly [number, number]): void;
161
+ /** Called once, before any other method. */
162
+ bind(context: CanvasWriteContext): void;
163
+ /** The live tree was re-walked — ids may have moved. */
164
+ onReindex(): void;
165
+ transformEditability(id: string, channel: TransformChannel): TransformEditability;
166
+ beginTransformEdit(id: string): void;
167
+ /** Mid-gesture: put `next` on the live object (and anything that owns it). */
168
+ writeTransform(id: string, next: Transform2DValue): void;
169
+ /** Close the gesture and ANSWER for whatever write it triggered — the
170
+ * persistence pipe's per-edit ack (`write-pipe.ts`), or nothing when this
171
+ * target performed no write. */
172
+ endTransformEdit(id: string): void | Promise<WriteAck>;
173
+ /** Drop the native JSX prop(s) that author one transform channel. Absent
174
+ * when this target has no source-level notion of byte-absence. */
175
+ removeTransform?(id: string, channel: TransformChannel): void | Promise<WriteAck>;
176
+ properties(id: string): PropertyDescriptor[];
177
+ get(id: string, path: string): unknown;
178
+ /** Same ack contract as {@link endTransformEdit}. */
179
+ set(id: string, path: string, value: unknown): void | Promise<WriteAck>;
180
+ /** Remove one authored override and let the component default take over —
181
+ * the only door that restores byte-ABSENCE, since {@link set} can only write
182
+ * a value. Same ack contract as {@link set}. */
183
+ remove?(id: string, path: string): void | Promise<WriteAck>;
184
+ /**
185
+ * Say — in THIS target's own voice — that a completed structural op lives
186
+ * only in the session.
187
+ *
188
+ * A structural op has no `transformEditability` sentence to read before the
189
+ * gesture (the {@link StructureProvider} contract has no such member), so
190
+ * this report is the ONLY place its persistence story can be told, and a
191
+ * silent live-only structural edit is exactly the two-truths divergence the
192
+ * lane forbids elsewhere. It is a hook rather than a fixed sentence because
193
+ * the two shipped targets already speak about a refused write in different
194
+ * channels — the creation-site one through the ingest refusal report, the TSX
195
+ * one through its `[canvas-source <entry>]` console line — and inventing a
196
+ * third voice here is how a lane acquires two sentences for one act.
197
+ *
198
+ * ABSENT ⇒ the adapter says it itself, generically, through the editor
199
+ * console. Never nothing.
200
+ */
201
+ reportStructureLiveOnly?(label: string, reason: string): void;
202
+ dispose(): void;
203
+ }
204
+
205
+ export interface PixiAuthoringOptions {
206
+ /** The persistence axis — required, because there is no default answer to
207
+ * "where does an edit go". */
208
+ readonly target: CanvasWriteTarget;
209
+ /**
210
+ * THE `pixi.js` OF THE GRAPH THIS TREE CAME FROM
211
+ * (`resolveCanvasPixiForEditor()`), and the only namespace this adapter
212
+ * constructs or class-checks with.
213
+ *
214
+ * ABSENT ⇒ this bundle's own copy, which is the right answer on every
215
+ * runtime with ONE module graph (dev, hosted, and every headless
216
+ * suite that builds its tree with a direct `pixi.js` import). Under the
217
+ * PACKAGED runtime the tree is the project's and this must be too: the
218
+ * duplicate gate is `object.constructor === pixi.Container`, which is an
219
+ * exact identity test on purpose — a game's own `class Bubble extends
220
+ * Container` must refuse — and an exact test against the wrong instance
221
+ * refuses everything. See `../../vite-plugin-module-doorways.ts` for the
222
+ * measured list.
223
+ */
224
+ readonly pixi?: CanvasPixiNamespace | undefined;
225
+ /**
226
+ * The UNDO axis — whose journal this adapter's live edits belong to. Required
227
+ * for the same reason `target` is: there is no default answer to "does this
228
+ * stack survive my teardown", and the answer differs per lane
229
+ * (`authoringJournal` for a held/design world whose subject outlives every
230
+ * remount, `playJournal` for a run that ends on ■). See the ownership block
231
+ * in `history/json-history-resource.ts`.
232
+ */
233
+ readonly journal: JournalSubject;
234
+ /** The identity axis. Omitted ⇒ structural paths (the engine's default). */
235
+ readonly identity?: CanvasIdentity | undefined;
236
+ /**
237
+ * The element whose client rect maps viewport pixels into this stage's own
238
+ * coordinate space — the canvas analogue of `viewport-pick-context.ts` for
239
+ * three (a `pickable` needs a screen↔world mapping the adapter cannot own,
240
+ * because it is handed only a `Container`).
241
+ *
242
+ * A resolver, not an element, because a play/ingest surface is torn down and
243
+ * rebuilt under a longer-lived adapter; `null` at pick time is an honest
244
+ * degrade (no pick), never a throw. ABSENT ⇒ this adapter reports no
245
+ * `pickable` at all — a stage with no mapped surface cannot answer where a
246
+ * client point landed, and guessing one would fabricate a hit.
247
+ *
248
+ * When the element carries `data-vgai-stage-width`/`-height`, those are the
249
+ * stage's LOGICAL size and the mapping divides the measured rect by them —
250
+ * which is what lets a design surface presented through the shared pan/zoom
251
+ * camera (a CSS scale) pick correctly. Without them the mapping is 1:1,
252
+ * which is every runtime surface (`create-runtime.ts` sizes the renderer to
253
+ * its container, so stage units are that element's client pixels).
254
+ */
255
+ readonly surface?: (() => HTMLElement | null) | undefined;
256
+ /**
257
+ * The DESIGN-TIME STATES axis: whatever can put this world into one of its
258
+ * own named states, in the editor's ordinary stories vocabulary.
259
+ *
260
+ * A collaborator rather than something this class builds, for the same reason
261
+ * `target` and `identity` are: the answer differs per lane and this adapter is
262
+ * keyed on the SURFACE, not on provenance. An ingested game's states are the
263
+ * scenes it declares through the game contract
264
+ * (`contract-scenes-stories.ts`), which is a WORLD-level provider.
265
+ *
266
+ * ABSENT ⇒ the adapter falls back to the CSF-component lane whenever the
267
+ * target names the component a node came from — see the `stories` field on
268
+ * the class for why that provider is unconditional and where its honesty
269
+ * lives instead.
270
+ */
271
+ readonly stories?: StoriesProvider | undefined;
272
+ /** Optional editor-camera mapping for a native Canvas Scene. The stage
273
+ * remains in authored world coordinates while its viewport renderer applies
274
+ * a presentation-only matrix; this inverse maps a client pointer back into
275
+ * those same world coordinates. Runtime/artboard surfaces omit it and keep
276
+ * the size-based mapping above. */
277
+ readonly pointFromClient?:
278
+ | ((clientX: number, clientY: number, surfaceRect: DOMRect) => PointData | null)
279
+ | undefined;
280
+ /**
281
+ * How an asset path becomes a texture for {@link AssetDropProvider}.
282
+ * Defaults to Pixi's own `Assets.load`, which is what every mounted surface
283
+ * uses; injectable so a headless test can drive the drop without a network
284
+ * or a renderer (the same seam, and the same reason, as the creation-site
285
+ * backend's injectable `writer`).
286
+ */
287
+ readonly loadTexture?: ((assetPath: string) => Promise<Texture>) | undefined;
288
+ /** Photograph one native display subtree on an independent preview
289
+ * Application. The live surface renderer is never a render target. */
290
+ readonly capturePreview?:
291
+ | ((
292
+ object: Container,
293
+ size: { readonly width: number; readonly height: number },
294
+ ) => Promise<string | null>)
295
+ | undefined;
296
+ }
297
+
298
+ /**
299
+ * WHAT THIS SURFACE CAN CONSTRUCT — the creation palette's whole contents.
300
+ *
301
+ * Four kinds, each of which is visible the moment it is created (a sprite gets
302
+ * `Texture.WHITE` and a size; a graphics gets a drawn rect): a node the author
303
+ * cannot see is indistinguishable from a create that silently failed.
304
+ */
305
+ const CANVAS_CREATABLE_KINDS: readonly { kind: string; label: string }[] = [
306
+ { kind: 'container', label: 'Container' },
307
+ { kind: 'sprite', label: 'Sprite' },
308
+ { kind: 'text', label: 'Text' },
309
+ { kind: 'graphics', label: 'Graphics' },
310
+ ];
311
+
312
+ /** What a canvas world can take from the asset browser: an image becomes a
313
+ * Sprite. A model or an audio file has no display object to become here. */
314
+ const IMAGE_ASSET_RE = /\.(png|jpe?g|webp|gif|svg)(\?.*)?$/i;
315
+
316
+ /** Why every structural op on this surface is live-only, in one sentence — the
317
+ * same reason for all of them, because they all come down to the same fact:
318
+ * the game's source contains statements that CONSTRUCT objects, and this lane
319
+ * plans property writes, never statements. */
320
+ const STRUCTURE_LIVE_ONLY_REASON =
321
+ 'a canvas structural edit would have to add, delete or move a construction statement in the ' +
322
+ "world's own source, and this lane plans property writes only — an editor-created node has no " +
323
+ 'construction site there at all';
324
+
325
+ /** The one draggable point a canvas node offers (see `spatialHandles`). Stable
326
+ * only within its owning node, per the {@link SpatialDragHandle} contract. */
327
+ const ORIGIN_HANDLE_ID = 'origin';
328
+
329
+ /** The origin handle's ink. Deliberately the canvas-guide language's own
330
+ * measure pink rather than the selection accent, so an origin never reads as
331
+ * selection chrome. */
332
+ const ORIGIN_HANDLE_COLOR = '#ff2d92';
333
+
334
+ /** What the adapter says when the target has no origin write at all — the
335
+ * generic voice the target's own sentence replaces whenever it has one. */
336
+ const ORIGIN_UNWRITABLE_REASON =
337
+ "this world's write target has no origin write, so the pivot/anchor can be seen here but not " +
338
+ 'dragged';
339
+
340
+ /**
341
+ * The anchor-origin view of a node: its `anchor` and `texture`, or `null` when
342
+ * this node's origin is a plain container pivot.
343
+ *
344
+ * STRUCTURAL, not `instanceof`, and that is load-bearing under the packaged
345
+ * runtime: a canvas world is mounted with the PROJECT's own `pixi.js` (see
346
+ * `../../vite-plugin-module-doorways.ts`), which is a different module instance
347
+ * from the one this prebuilt shell bundles — so a real `Sprite` from the world
348
+ * fails `instanceof Sprite` here and every selected sprite would silently read
349
+ * as a pivot node. The engine's own 2D authoring walk already refuses class
350
+ * identity for the same reason (`@volter/game-runtime/pixi/authoring`'s `kindOf` keys on the
351
+ * constructor NAME); asking for the two fields the anchor question is actually
352
+ * about is the same move without depending on a name a minifier may mangle.
353
+ *
354
+ * It also answers correctly for the sprite-shaped classes that are NOT `Sprite`
355
+ * subclasses — `TilingSprite`, `NineSliceSprite` — which carry a real `anchor`
356
+ * over a real `texture` and were previously mis-read as pivot nodes.
357
+ */
358
+ function anchoredSprite(object: Container): { anchor: PointData; texture: Texture } | null {
359
+ const candidate = object as unknown as Partial<Pick<Sprite, 'anchor' | 'texture'>>;
360
+ const anchor = candidate.anchor;
361
+ const texture = candidate.texture;
362
+ return anchor && texture?.orig ? { anchor, texture } : null;
363
+ }
364
+
365
+ /**
366
+ * The frame a node's `anchor` is normalized against, in its own local units —
367
+ * `null` for anything that is not an anchored sprite.
368
+ *
369
+ * This is the node's OWN view bounds, not `texture.orig`, and the two are only
370
+ * the same for `Sprite`. Pixi normalizes an anchor against whatever its
371
+ * `updateBounds` measures, and `TilingSprite`/`NineSliceSprite` measure their
372
+ * authored `_width`/`_height` — the tiled/stretched region — while their
373
+ * texture stays the little source tile. Dividing a drag delta by the texture
374
+ * instead moved a 512-wide banner's anchor by the width of its 32px tile, so
375
+ * the compensating position write no longer cancelled the shift and the
376
+ * content jumped under the pointer.
377
+ *
378
+ * `bounds` is `ViewContainer`'s own accessor (Sprite, TilingSprite,
379
+ * NineSliceSprite all extend it) and reports the view's box WITHOUT children,
380
+ * which is exactly the anchored frame. Read structurally for the same reason
381
+ * {@link anchoredSprite} is: under the packaged runtime this object comes from
382
+ * the project's `pixi.js`, not the shell's.
383
+ */
384
+ function anchorFrameSize(object: Container): PointData | null {
385
+ const view = anchoredSprite(object);
386
+ if (!view) return null;
387
+ const bounds = (object as unknown as { bounds?: { width?: unknown; height?: unknown } }).bounds;
388
+ if (typeof bounds?.width === 'number' && typeof bounds.height === 'number') {
389
+ return { x: bounds.width, y: bounds.height };
390
+ }
391
+ return { x: view.texture.orig.width, y: view.texture.orig.height };
392
+ }
393
+
394
+ /** The default node label per kind — a legible row in the hierarchy the moment
395
+ * it exists, rather than an empty name the panel renders as the kind. */
396
+ function defaultLabelFor(kind: string): string {
397
+ const entry = CANVAS_CREATABLE_KINDS.find((candidate) => candidate.kind === kind);
398
+ return entry?.label ?? kind;
399
+ }
400
+
401
+ /** Construct one new display object, or `null` for a kind this surface does not
402
+ * know — never a fabricated stand-in.
403
+ *
404
+ * `pixi` is THIS SURFACE's namespace ({@link PixiAuthoringOptions.pixi}): a
405
+ * node built from another graph's classes is a foreign object in the world's
406
+ * own display list, and the world's renderer is the one that has to draw it. */
407
+ function createDisplayObject(pixi: CanvasPixiNamespace, kind: string): Container | null {
408
+ switch (kind) {
409
+ case 'container':
410
+ return new pixi.Container();
411
+ case 'sprite': {
412
+ const sprite = new pixi.Sprite(pixi.Texture.WHITE);
413
+ sprite.setSize(64, 64);
414
+ return sprite;
415
+ }
416
+ case 'text':
417
+ return new pixi.Text({ text: 'Text', style: { fill: 0xffffff, fontSize: 24 } });
418
+ case 'graphics':
419
+ return new pixi.Graphics().rect(0, 0, 100, 100).fill(0xffffff);
420
+ default:
421
+ return null;
422
+ }
423
+ }
424
+
425
+ /**
426
+ * A deep copy of one display object, or a thrown refusal naming what it is.
427
+ *
428
+ * The refusal is the point: a game's own `class Bubble extends Container` holds
429
+ * state this code cannot see (timers, sim references, its own texture logic),
430
+ * and a "clone" that copied only the Container half would put a dead prop in
431
+ * the world that LOOKS like the real thing. Only the four shapes the editor
432
+ * itself can construct are cloneable, plus their children, recursively.
433
+ */
434
+ function cloneDisplayObject(pixi: CanvasPixiNamespace, object: Container): Container {
435
+ const clone = cloneShallow(pixi, object);
436
+ copyCommonProperties(object, clone);
437
+ for (const child of object.children ?? []) {
438
+ clone.addChild(cloneDisplayObject(pixi, child as Container));
439
+ }
440
+ return clone;
441
+ }
442
+
443
+ function cloneShallow(pixi: CanvasPixiNamespace, object: Container): Container {
444
+ // Constructor IDENTITY, not `instanceof`: a subclass passes an instanceof
445
+ // check and is exactly the case that must refuse.
446
+ //
447
+ // AGAINST THIS SURFACE'S OWN NAMESPACE, not a static import — and that is
448
+ // what makes the exact test survive the packaged runtime. `object` comes from
449
+ // the graph the world mounted in; the shell's `Container` is a different
450
+ // class object there, so `===` against it was false for EVERY node and a
451
+ // plain project container was refused with the sentence below, which is about
452
+ // a game's own subclass. Structural duck-typing is not the fix here the way
453
+ // it is for `anchoredSprite` above: this test's whole job is exactness.
454
+ if (object.constructor === pixi.Container) return new pixi.Container();
455
+ if (object.constructor === pixi.Sprite) {
456
+ const source = object as Sprite;
457
+ const sprite = new pixi.Sprite(source.texture);
458
+ sprite.anchor.set(source.anchor.x, source.anchor.y);
459
+ return sprite;
460
+ }
461
+ if (object.constructor === pixi.Text) {
462
+ const source = object as Text;
463
+ const text = new pixi.Text({ text: source.text, style: source.style.clone() });
464
+ text.anchor.set(source.anchor.x, source.anchor.y);
465
+ return text;
466
+ }
467
+ if (object.constructor === pixi.Graphics) return (object as Graphics).clone(true);
468
+ throw new Error(
469
+ `"${object.label ?? object.constructor?.name ?? 'this node'}" cannot be duplicated: a ` +
470
+ `${object.constructor?.name ?? 'custom'} is constructed by the game's own code, and copying ` +
471
+ 'only its display half would put a lookalike with no behaviour into the world.',
472
+ );
473
+ }
474
+
475
+ function copyCommonProperties(source: Container, target: Container): void {
476
+ target.label = source.label;
477
+ target.position.set(source.position.x, source.position.y);
478
+ target.scale.set(source.scale.x, source.scale.y);
479
+ target.pivot.set(source.pivot.x, source.pivot.y);
480
+ target.skew.set(source.skew.x, source.skew.y);
481
+ target.rotation = source.rotation;
482
+ target.alpha = source.alpha;
483
+ target.visible = source.visible;
484
+ if ('tint' in source && 'tint' in target) {
485
+ (target as Container & { tint: number }).tint = (source as Container & { tint: number }).tint;
486
+ }
487
+ }
488
+
489
+ interface CanvasBoxEditSession {
490
+ readonly id: string;
491
+ readonly transform: Transform;
492
+ readonly rect: { x: number; y: number; width: number; height: number };
493
+ }
494
+
495
+ function boxPatchChannels(patch: Record<string, number>): TransformChannel[] {
496
+ const channels = new Set<TransformChannel>();
497
+ if (
498
+ patch['x'] !== undefined ||
499
+ patch['y'] !== undefined ||
500
+ patch['originX'] !== undefined ||
501
+ patch['originY'] !== undefined
502
+ ) {
503
+ channels.add('position');
504
+ }
505
+ if (
506
+ patch['width'] !== undefined ||
507
+ patch['height'] !== undefined ||
508
+ patch['scaleXFactor'] !== undefined ||
509
+ patch['scaleYFactor'] !== undefined
510
+ ) {
511
+ channels.add('scale');
512
+ if (patch['width'] !== undefined || patch['height'] !== undefined) channels.add('position');
513
+ }
514
+ if (patch['rotate'] !== undefined) channels.add('rotation');
515
+ return [...channels];
516
+ }
517
+
518
+ function rotateTransformZ(transform: Transform, degrees: number): Transform['rotation'] {
519
+ const half = (degrees * Math.PI) / 180 / 2;
520
+ const deltaZ = Math.sin(half);
521
+ const deltaW = Math.cos(half);
522
+ const [x, y, z, w] = transform.rotation;
523
+ return [
524
+ x * deltaW + y * deltaZ,
525
+ y * deltaW - x * deltaZ,
526
+ z * deltaW + w * deltaZ,
527
+ w * deltaW - z * deltaZ,
528
+ ];
529
+ }
530
+
531
+ function transformForBoxPatch(
532
+ session: CanvasBoxEditSession,
533
+ patch: Record<string, number>,
534
+ ): Transform {
535
+ const next: Transform = {
536
+ position: [...session.transform.position],
537
+ rotation: [...session.transform.rotation],
538
+ scale: [...session.transform.scale],
539
+ };
540
+ const rotate = patch['rotate'];
541
+ if (rotate !== undefined) next.rotation = rotateTransformZ(session.transform, rotate);
542
+ const width = patch['width'];
543
+ if (width !== undefined && session.rect.width > 0) {
544
+ next.scale[0] = session.transform.scale[0] * Math.max(0.0001, width / session.rect.width);
545
+ }
546
+ const height = patch['height'];
547
+ if (height !== undefined && session.rect.height > 0) {
548
+ next.scale[1] = session.transform.scale[1] * Math.max(0.0001, height / session.rect.height);
549
+ }
550
+ const scaleStep = patch['scaleStep'];
551
+ const snapScale = (value: number): number =>
552
+ scaleStep !== undefined && scaleStep > 0 ? Math.round(value / scaleStep) * scaleStep : value;
553
+ const nonZeroScale = (value: number): number =>
554
+ Math.abs(value) >= 0.0001 ? value : value < 0 ? -0.0001 : 0.0001;
555
+ const scaleXFactor = patch['scaleXFactor'];
556
+ if (scaleXFactor !== undefined) {
557
+ next.scale[0] = nonZeroScale(snapScale(session.transform.scale[0] * scaleXFactor));
558
+ }
559
+ const scaleYFactor = patch['scaleYFactor'];
560
+ if (scaleYFactor !== undefined) {
561
+ next.scale[1] = nonZeroScale(snapScale(session.transform.scale[1] * scaleYFactor));
562
+ }
563
+ return next;
564
+ }
565
+
566
+ function requestedBoxEditDelta(
567
+ currentOrigin: { x: number; y: number } | null,
568
+ currentRect: DOMRectLike | null,
569
+ sessionRect: DOMRectLike,
570
+ patch: Record<string, number>,
571
+ ): { dx: number; dy: number } {
572
+ const usesOrigin = patch['originX'] !== undefined || patch['originY'] !== undefined;
573
+ if (usesOrigin) {
574
+ return {
575
+ dx: patch['originX'] !== undefined && currentOrigin ? patch['originX'] - currentOrigin.x : 0,
576
+ dy: patch['originY'] !== undefined && currentOrigin ? patch['originY'] - currentOrigin.y : 0,
577
+ };
578
+ }
579
+ return {
580
+ dx: currentRect ? (patch['x'] ?? sessionRect.x) - currentRect.x : 0,
581
+ dy: currentRect ? (patch['y'] ?? sessionRect.y) - currentRect.y : 0,
582
+ };
583
+ }
584
+
585
+ export class PixiAuthoringAdapter implements AuthoringAdapter {
586
+ readonly capabilities: AuthoringCapabilities;
587
+ readonly provenance: AuthoringProvenance;
588
+ readonly pickable?: PickProvider;
589
+ /**
590
+ * The selection chrome's geometry — outlines, hover, marquee — in the SAME
591
+ * authored-space frame `RootSelectionOverlay` reads for every other surface.
592
+ *
593
+ * Present exactly when {@link PixiAuthoringOptions.surface} is, and for the
594
+ * same reason it gates `pickable`: both need a mapped surface, and without
595
+ * one there is no honest frame to answer in. `hasRectCapableChild` is what
596
+ * decides whether the shared overlay renders at all, so this is also what
597
+ * makes a canvas world SELECTABLE by clicking it — the overlay's own
598
+ * interaction layer routes its picks back through `pickable`.
599
+ */
600
+ readonly rects?: RectProvider;
601
+ /** Present only when the target HAS one. A source-writing target uses this
602
+ * to disclose its auto-save destination and last project-history failure. */
603
+ readonly persistence?: PersistenceProvider;
604
+ /**
605
+ * Present exactly when the target can answer where a node came from. The
606
+ * provider is the id→object hop and nothing else — the index itself is a
607
+ * host-side `WeakMap` (`creation-site-registry.ts`), so a running game is
608
+ * never asked anything and never observes that it was indexed.
609
+ */
610
+ readonly truth?: TruthProvider;
611
+ /**
612
+ * Present exactly when {@link truth} is, and derived from the same
613
+ * index: the ONE jump a canvas subject honestly has is to the line that
614
+ * constructed it. A target with no creation-site index advertises no empty
615
+ * capability (the same conditional shape as `pickable`/`rects`), and an
616
+ * indexed node whose object the index never saw gets `[]` — an absence, not a
617
+ * fabricated link.
618
+ */
619
+ readonly related?: RelatedSubjectsProvider;
620
+ readonly structure: StructureProvider;
621
+ /**
622
+ * The handed-in {@link PixiAuthoringOptions.stories} when there is one;
623
+ * otherwise the CSF-COMPONENT provider, built here for any target that can
624
+ * name the component a node came from.
625
+ *
626
+ * That second arm is UNCONDITIONAL, and it is not the empty picker the
627
+ * conditional shape (`pickable`/`rects`) exists to avoid. Which stories a
628
+ * node has is the project's story registry's answer, and the registry is
629
+ * populated by story-module discovery long after this constructor runs — so
630
+ * there is no honest construction-time predicate to gate on, and the sibling
631
+ * CSF lanes resolve it the same way: `R3fSourceAuthoringAdapter` and
632
+ * `ReactRootAuthoringAdapter` both declare `stories` NON-optionally and build
633
+ * it unconditionally, pushing the honesty into `storiesFor(id)` — which
634
+ * answers `[]` for a node whose component has none.
635
+ *
636
+ * That is where the surface is hidden, and `inspector-stories-gating.ts`
637
+ * says so twice: gate on `storiesFor(...)` returning rows, "NEVER on
638
+ * `adapter.stories` truthiness". Presence of the provider is not an
639
+ * advertisement — a picker only appears where there is something to pick.
640
+ *
641
+ * Conditionality still belongs to whatever CAN be decided up front: a
642
+ * WORLD-level provider knows its own list, which is why
643
+ * `createContractScenesStories` answers `null` for a game that declares no
644
+ * scenes.
645
+ */
646
+ readonly stories?: StoriesProvider;
647
+ /**
648
+ * Present exactly when the target can read the game's own source
649
+ * ({@link CanvasWriteTarget.instances}). On this surface the CREATION SITE is
650
+ * the component — its literals are the defaults an instance overrides — so a
651
+ * target with no readable source has no defaults to diff against and this
652
+ * adapter advertises no instance capability rather than inventing one.
653
+ */
654
+ readonly instances?: ComponentInstancesProvider;
655
+ readonly transforms: TransformProvider;
656
+
657
+ private readonly a2d: AuthoringAdapter2D;
658
+ private readonly projector: PixiProjector;
659
+ private readonly target: CanvasWriteTarget;
660
+ /** THE namespace — see {@link PixiAuthoringOptions.pixi}. */
661
+ private readonly pixi: CanvasPixiNamespace;
662
+ private readonly structureHistory: CanvasStructureHistory;
663
+ private readonly loadTexture: (assetPath: string) => Promise<Texture>;
664
+ private readonly capturePreview:
665
+ | ((
666
+ object: Container,
667
+ size: { readonly width: number; readonly height: number },
668
+ ) => Promise<string | null>)
669
+ | undefined;
670
+ private listeners = new Set<() => void>();
671
+ private boxEditSessions = new Map<string, CanvasBoxEditSession>();
672
+ private lockedIds = new Set<string>();
673
+
674
+ constructor(
675
+ private readonly root: Container,
676
+ private readonly store: EditorShellStore,
677
+ opts: PixiAuthoringOptions,
678
+ ) {
679
+ this.target = opts.target;
680
+ this.pixi = opts.pixi ?? shellPixi;
681
+ this.transforms = {
682
+ dimensions: () => '2d',
683
+ get: (id): Transform => {
684
+ const value = this.a2d.getTransform(id);
685
+ if (!value) return { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] };
686
+ return toNeutralTransform(value);
687
+ },
688
+ editability: (id, channel) => this.target.transformEditability(id, channel),
689
+ beginEdit: (id) => this.target.beginTransformEdit(id),
690
+ apply: (id, transform) => {
691
+ this.target.writeTransform(id, fromNeutralTransform(transform));
692
+ this.notify();
693
+ },
694
+ endEdit: (id) => {
695
+ return this.target.endTransformEdit(id);
696
+ },
697
+ ...(this.target.removeTransform
698
+ ? {
699
+ remove: (id: string, channel: TransformChannel) =>
700
+ this.target.removeTransform?.(id, channel),
701
+ }
702
+ : {}),
703
+ };
704
+ // THIS SURFACE's `Assets`, so a dropped image lands in the cache the
705
+ // world's own loader reads — the shell's is a separate cache that would
706
+ // fetch and decode the same file a second time.
707
+ this.loadTexture =
708
+ opts.loadTexture ?? ((assetPath) => this.pixi.Assets.load<Texture>(assetPath));
709
+ this.capturePreview = opts.capturePreview;
710
+ this.structureHistory = new CanvasStructureHistory(root, store, opts.journal, () =>
711
+ this.afterStructuralChange(),
712
+ );
713
+ this.a2d = new AuthoringAdapter2D(root, {
714
+ ...(opts.identity ? { identity: opts.identity } : {}),
715
+ });
716
+ this.projector = new PixiProjector(root, this.a2d, {
717
+ surface: opts.surface ?? (() => null),
718
+ ...(opts.pointFromClient ? { pointFromClient: opts.pointFromClient } : {}),
719
+ });
720
+ this.provenance = this.target.provenance;
721
+ const surface = opts.surface;
722
+ if (surface) {
723
+ this.pickable = {
724
+ pick: (clientX, clientY) =>
725
+ this.projector.pick(clientX, clientY, (id) => this.isPickLocked(id)),
726
+ candidates: (clientX, clientY) =>
727
+ this.projector.candidates(clientX, clientY, (id) => this.isPickLocked(id)),
728
+ };
729
+ this.rects = {
730
+ rect: (id) => this.projector.rect(id),
731
+ contextRects: (id) => this.projector.contextRects(id),
732
+ };
733
+ }
734
+ if (opts.stories) this.stories = opts.stories;
735
+ this.watchStructure(root);
736
+ this.capabilities = {
737
+ transform: true,
738
+ inspectorFields: true,
739
+ // First-party source targets auto-save per edit but still expose the
740
+ // destination and rollback failure through PersistenceProvider. A
741
+ // foreign/live target's provider may be only an ephemeral disclosure,
742
+ // which is not a persistence capability.
743
+ persist:
744
+ this.target.provenance.source === 'source-code' && this.target.persistence !== undefined,
745
+ };
746
+ this.target.bind({ a2d: this.a2d, store, journal: opts.journal, notify: () => this.notify() });
747
+ this.structure = this.target.structure ?? this.liveStructure;
748
+ this.assetDrop =
749
+ this.target.assetDrop ??
750
+ ({
751
+ accepts: (nodeId, assetPath) =>
752
+ IMAGE_ASSET_RE.test(assetPath) && this.containerFor(nodeId || null) !== null,
753
+ drop: (nodeId, assetPath, context) => this.dropAsset(nodeId, assetPath, context),
754
+ } satisfies AssetDropProvider);
755
+ const persistence = this.target.persistence;
756
+ if (persistence) this.persistence = persistence;
757
+ const truthOf = this.target.truth?.bind(this.target);
758
+ if (truthOf) {
759
+ this.truth = { resolve: (id, property) => truthOf(id, property) };
760
+ this.related = creationSiteRelated((id) => truthOf(id, 'position').site);
761
+ }
762
+ // The CSF-component lane: a target that names the component a node came
763
+ // from gets the portable-CSF story association, unconditionally, exactly as
764
+ // the R3F and React lanes build theirs. `storiesFor(id)` is where a node
765
+ // with no story says so — see the `stories` field for why that is the only
766
+ // place the answer is knowable.
767
+ if (!opts.stories && this.target.componentIdentity) {
768
+ this.stories = componentStatesProvider(
769
+ 'canvas',
770
+ store,
771
+ (id) => this.target.componentIdentity?.(id) ?? null,
772
+ );
773
+ }
774
+ const instances = this.target.instances?.();
775
+ if (instances) {
776
+ this.instances = {
777
+ // NO GATE HERE — the TARGET answers for its own instances.
778
+ //
779
+ // This used to wrap `describe` in "the CSF provider has a story for
780
+ // this node", which is the portable-CSF lane's own sentence
781
+ // (`r3f-source-authoring-adapter.ts` says it inside the adapter that
782
+ // owns both halves). Asked as a blanket wrapper it is asked of targets
783
+ // whose instance notion is NOT CSF at all: once the live target learned
784
+ // to name a class-owned object's component, every ingested game's
785
+ // instances capability went dark for any class with no matching story —
786
+ // the exact silent capability deletion this wrapper was last edited to
787
+ // prevent. The CSF gate now lives in `pixi-source-write-target.ts`,
788
+ // beside the component identity it is a statement about; a target whose
789
+ // component IS its construction statement has no story to require.
790
+ describe: (id) => instances.describe(id),
791
+ ...(instances.openComponent
792
+ ? { openComponent: (id: string) => instances.openComponent?.(id) }
793
+ : {}),
794
+ revert: (id, paths) => instances.revert(id, paths),
795
+ applyToComponent: (id, path) => instances.applyToComponent(id, path),
796
+ };
797
+ }
798
+ }
799
+
800
+ refresh(): { count: number; sprites: number } {
801
+ return this.projector.refresh();
802
+ }
803
+
804
+ /** The selected live display subtree as pixels, or `null` when this mounted
805
+ * surface did not expose a renderer-backed capture door. */
806
+ previewImage(id: string, width: number, height: number): Promise<string | null> {
807
+ const object = this.projector.object(id);
808
+ return object && this.capturePreview
809
+ ? this.capturePreview(object, { width, height })
810
+ : Promise.resolve(null);
811
+ }
812
+
813
+ private notify(): void {
814
+ for (const listener of this.listeners) listener();
815
+ }
816
+
817
+ // ------------------------------------------------- late structural commits
818
+ //
819
+ // Re-indexing at the READ (see `hierarchy` below) is what makes a late
820
+ // subtree NAMEABLE; this is what makes the panel ask again. Pixi emits
821
+ // `childAdded`/`childRemoved` on the parent, so watching the root plus every
822
+ // indexed object covers every path a new subtree can enter through, and the
823
+ // listeners are re-attached after each notification so a freshly-committed
824
+ // subtree is itself watched. Both axes need it, for different reasons: an
825
+ // ingested port waits on its resources, and a first-party world commonly
826
+ // gates its whole content on an atlas.
827
+
828
+ private watchedForStructure: Container[] = [];
829
+ private structureNotifyQueued = false;
830
+
831
+ private readonly onStructureChanged = (): void => {
832
+ // STALENESS IS ANSWERED NOW; the walk is what waits. The projector holds
833
+ // one projection per synchronous turn, and it can only know to drop it
834
+ // when a mover tells it — its own write paths do, and this watcher is the
835
+ // other mover: the GAME committed, behind the adapter's back, which is the
836
+ // whole reason this watcher exists. A read arriving before the microtask
837
+ // below (a status/coverage pass, `hierarchy.roots()` from a panel) would
838
+ // otherwise answer from the walk taken before the commit.
839
+ this.projector.invalidate();
840
+ // One notification per commit burst: React's commit is synchronous, so a
841
+ // microtask runs after the WHOLE subtree is in the tree, however many
842
+ // `childAdded` events it fired.
843
+ if (this.structureNotifyQueued) return;
844
+ this.structureNotifyQueued = true;
845
+ queueMicrotask(() => {
846
+ this.structureNotifyQueued = false;
847
+ this.projector.reproject();
848
+ this.watchStructure(this.root);
849
+ this.target.onReindex();
850
+ this.notify();
851
+ this.store.notifyIngestEdit();
852
+ });
853
+ };
854
+
855
+ private watchStructure(root: Container): void {
856
+ for (const object of this.watchedForStructure) {
857
+ object.off('childAdded', this.onStructureChanged);
858
+ object.off('childRemoved', this.onStructureChanged);
859
+ }
860
+ this.watchedForStructure = [];
861
+ const watch = (object: Container): void => {
862
+ // A tracked node is not guaranteed to be a real PixiJS display object —
863
+ // the same reason `getTransform` tolerates one without a position. An
864
+ // emitter it does not have is a node whose structure cannot change under
865
+ // us either, so skipping it loses nothing.
866
+ if (typeof object.on === 'function' && typeof object.off === 'function') {
867
+ object.on('childAdded', this.onStructureChanged);
868
+ object.on('childRemoved', this.onStructureChanged);
869
+ this.watchedForStructure.push(object);
870
+ }
871
+ for (const child of object.children ?? []) watch(child as Container);
872
+ };
873
+ watch(root);
874
+ }
875
+
876
+ // -------------------------------------------------------------- hierarchy
877
+
878
+ readonly hierarchy: HierarchyProvider = {
879
+ // THE READ IS THE REFRESH POINT. A world whose content arrives after the
880
+ // adapter was handed its root commits real nodes into this same tree
881
+ // later, and a node the last index never saw carries no id: its parent
882
+ // reports a child it cannot name, so the hierarchy stops at the last row
883
+ // that WAS indexed while the viewport draws the whole world. The index is a
884
+ // walk of a display list, so re-doing it per read is cheaper than any
885
+ // scheme for noticing when it went stale.
886
+ roots: () => {
887
+ this.projector.project();
888
+ return this.projector
889
+ .roots()
890
+ .map((node) => this.toEditorNode(node.id))
891
+ .filter((node): node is EditorNode => node !== null);
892
+ },
893
+ node: (id) => this.toEditorNode(id),
894
+ // No `object3D`/`idForObject3D`: canvas entities are PIXI display objects,
895
+ // not THREE — the concept does not apply here (no 3D gizmo binding).
896
+ };
897
+
898
+ private toEditorNode(id: string): EditorNode | null {
899
+ const node = this.projector.node(id);
900
+ if (!node) return null;
901
+ const object = this.projector.object(id);
902
+ const componentRoot = object ? isCanvasComponentInstanceRoot(object) : false;
903
+ const component = componentRoot ? readContainerAuthoringComponent(object) : undefined;
904
+ return {
905
+ id: node.id,
906
+ label:
907
+ (componentRoot && object ? readContainerAuthoringLabel(object) : undefined) ?? node.label,
908
+ role: componentRoot ? 'component' : 'entity',
909
+ kind: node.kind,
910
+ ...(component ? { typeLabel: component } : {}),
911
+ parentId: node.parentId,
912
+ childIds: node.childIds,
913
+ };
914
+ }
915
+
916
+ readonly selection: SelectionProvider = {
917
+ get: () => [...this.store.selectedEntityIds],
918
+ set: (ids) => {
919
+ this.store.selectMultiple(ids);
920
+ this.notify();
921
+ },
922
+ };
923
+
924
+ // -------------------------------------------------------------- structure
925
+ //
926
+ // EVERY OP IS A LIVE OP ON THE CAPTURED TREE — Pixi's own
927
+ // `addChild`/`removeChild`/`addChildAt` — bracketed in ONE
928
+ // `CanvasStructureHistory` transaction, so one Ctrl+Z reverts the whole op.
929
+ // That is the same project-history path a transform gesture takes; see that
930
+ // module for why the structural state is a resource of its own.
931
+ //
932
+ // PERSISTENCE IS REPORTED, NEVER PRETENDED. A canvas structural edit would
933
+ // have to add, delete or move a CONSTRUCTION STATEMENT in the world's own
934
+ // source, and neither persistence axis plans statements: the TSX target
935
+ // writes JSX props, the creation-site target rewrites a literal at the line
936
+ // that constructed an object. An editor-created node has no such line at all.
937
+ // So the live op proceeds — a canvas world is authorable in the session the
938
+ // same way a live three world is — and the target says, in its own voice,
939
+ // that it stays there (`reportStructureLiveOnly`). Source insertion is a
940
+ // separate capability, and inventing half of it here would be the sidecar the
941
+ // ingest-authoring model forbids.
942
+ //
943
+ // Absent on purpose: `wrap`/`unwrap`/`group`/`ungroup`/`copy`/`cut`/`paste`.
944
+ // The shell hides those affordances for this adapter, which is the honest
945
+ // degrade — a half-answer would be a menu item that silently does nothing.
946
+
947
+ private readonly liveStructure: StructureProvider = {
948
+ create: (kind, parentId) => this.createStructuralNode(kind, parentId ?? null),
949
+ remove: (id) => this.removeStructuralNodes([id]),
950
+ removeMany: (ids) => this.removeStructuralNodes(ids),
951
+ duplicate: (id) => this.duplicateStructuralNode(id),
952
+ reparent: (id, newParentId) => this.reparentStructuralNode(id, newParentId),
953
+ reorder: (id, beforeSiblingId) => this.reorderStructuralNode(id, beforeSiblingId),
954
+ // The palette is the same everywhere in this tree: any container can hold
955
+ // any of these, including the stage root (`parentId === null`).
956
+ creatableKinds: () => CANVAS_CREATABLE_KINDS.map((entry) => ({ ...entry })),
957
+ };
958
+
959
+ /** The container an op's `parentId` names — `null`/`''` is the stage root,
960
+ * which is a legal parent even though it is not one of this adapter's rows. */
961
+ private containerFor(parentId: string | null): Container | null {
962
+ if (!parentId) return this.root;
963
+ const object = this.projector.object(parentId);
964
+ if (!object || typeof object.addChild !== 'function') return null;
965
+ return object;
966
+ }
967
+
968
+ private refuseStructure(reason: string): Promise<WriteAck> {
969
+ return runWritePipe({
970
+ resolve: () => resolvesLiveOnly(reason),
971
+ record: () => undefined,
972
+ report: (said) =>
973
+ editorConsole.warn(`[canvas] structural edit refused — ${said}`, 'authoring'),
974
+ });
975
+ }
976
+
977
+ /**
978
+ * Say where a completed structural op lives, in the target's own voice when it
979
+ * has one (see {@link CanvasWriteTarget.reportStructureLiveOnly}) — and ANSWER
980
+ * FOR IT through the pipe.
981
+ *
982
+ * This lane has no structural writer at all (see the block comment above), so
983
+ * its resolution is the live-only arm, which by the pipe's type has no `write`
984
+ * member: an ack claiming a destination is a compile error here rather than a
985
+ * discipline. Acking `persisted: true` for an op that only ever touched RAM is
986
+ * exactly the blanket answer the pipe replaced.
987
+ */
988
+ private reportStructureLiveOnly(label: string): Promise<WriteAck> {
989
+ return runWritePipe({
990
+ resolve: () => resolvesLiveOnly(STRUCTURE_LIVE_ONLY_REASON),
991
+ record: () => undefined,
992
+ report: (reason) => {
993
+ if (this.target.reportStructureLiveOnly) {
994
+ this.target.reportStructureLiveOnly(label, reason);
995
+ return;
996
+ }
997
+ editorConsole.log(`[canvas] ${label} is live-only — ${reason}.`, 'authoring');
998
+ },
999
+ });
1000
+ }
1001
+
1002
+ /**
1003
+ * Re-index and tell the panels, SYNCHRONOUSLY.
1004
+ *
1005
+ * The `childAdded` watcher below queues the same work in a microtask (one
1006
+ * notification per React commit burst), but an op's own caller needs the new
1007
+ * id NOW — `create` returns one, and "create then immediately select it" is
1008
+ * the ordinary shell sequence.
1009
+ */
1010
+ private afterStructuralChange(): void {
1011
+ this.projector.reproject();
1012
+ this.watchStructure(this.root);
1013
+ this.target.onReindex();
1014
+ this.notify();
1015
+ this.store.notifyIngestEdit();
1016
+ }
1017
+
1018
+ private idOf(object: Container): string {
1019
+ return (object as Container & { __authId?: string }).__authId ?? '';
1020
+ }
1021
+
1022
+ /**
1023
+ * The lane that mints a GENUINELY SYNCHRONOUS id: the object is in the running
1024
+ * tree and re-indexed before this returns, so the caller can select it in the
1025
+ * same turn. The live-only report is the other half of the answer and rides
1026
+ * back in `ack` (see `StructuralIdWrite`) instead of being fired `void`.
1027
+ */
1028
+ private createStructuralNode(kind: string, parentId: string | null): StructuralIdWrite {
1029
+ const parent = this.containerFor(parentId);
1030
+ if (!parent) {
1031
+ return {
1032
+ id: '',
1033
+ ack: this.refuseStructure(`no node with id "${parentId}" can hold children.`),
1034
+ };
1035
+ }
1036
+ const node = createDisplayObject(this.pixi, kind);
1037
+ if (!node) {
1038
+ return {
1039
+ id: '',
1040
+ ack: this.refuseStructure(`"${kind}" is not a kind this canvas surface can construct.`),
1041
+ };
1042
+ }
1043
+ node.label = defaultLabelFor(kind);
1044
+ const label = `Create ${node.label}`;
1045
+ this.structureHistory.track(parent);
1046
+ this.structureHistory.track(node);
1047
+ this.structureHistory.run(label, () => {
1048
+ parent.addChild(node);
1049
+ });
1050
+ this.afterStructuralChange();
1051
+ return { id: this.idOf(node), ack: this.reportStructureLiveOnly(label) };
1052
+ }
1053
+
1054
+ /** One transaction for the whole batch — `remove` is the single-id case of
1055
+ * the same op, so a multi-delete is one Ctrl+Z rather than N. */
1056
+ private removeStructuralNodes(ids: readonly string[]): Promise<WriteAck> {
1057
+ const doomed: Container[] = [];
1058
+ let refusal: Promise<WriteAck> | null = null;
1059
+ for (const id of ids) {
1060
+ const object = this.projector.object(id);
1061
+ if (!object?.parent) {
1062
+ refusal = this.refuseStructure(`"${id}" is not a removable node in this tree.`);
1063
+ continue;
1064
+ }
1065
+ doomed.push(object);
1066
+ }
1067
+ if (doomed.length === 0) {
1068
+ return refusal ?? this.refuseStructure('the selection held no removable node.');
1069
+ }
1070
+ const label =
1071
+ doomed.length === 1
1072
+ ? `Delete ${doomed[0]!.label ?? 'node'}`
1073
+ : `Delete ${doomed.length} nodes`;
1074
+ for (const object of doomed) {
1075
+ this.structureHistory.track(object);
1076
+ if (object.parent) this.structureHistory.track(object.parent as Container);
1077
+ }
1078
+ this.structureHistory.run(label, () => {
1079
+ for (const object of doomed) object.parent?.removeChild(object);
1080
+ });
1081
+ this.afterStructuralChange();
1082
+ return this.reportStructureLiveOnly(label);
1083
+ }
1084
+
1085
+ private duplicateStructuralNode(id: string): StructuralIdWrite {
1086
+ const object = this.projector.object(id);
1087
+ const parent = (object?.parent as Container | null) ?? null;
1088
+ if (!object || !parent) {
1089
+ // A THROW, not a silent '' — `duplicate` has no refusal channel in its
1090
+ // signature, and a caller that gets an empty id back cannot tell "refused"
1091
+ // from "the new node has no id yet".
1092
+ throw new Error(`"${id}" is not a duplicable node in this tree.`);
1093
+ }
1094
+ // Built BEFORE the transaction opens: an uncloneable node must refuse
1095
+ // without having recorded a history entry for a copy that never happened.
1096
+ const clone = cloneDisplayObject(this.pixi, object);
1097
+ clone.label = `${object.label || 'node'} copy`;
1098
+ const label = `Duplicate ${object.label ?? 'node'}`;
1099
+ const index = parent.children.indexOf(object);
1100
+ this.structureHistory.track(parent);
1101
+ this.structureHistory.track(clone);
1102
+ this.structureHistory.run(label, () => {
1103
+ parent.addChildAt(clone, index + 1);
1104
+ });
1105
+ this.afterStructuralChange();
1106
+ return { id: this.idOf(clone), ack: this.reportStructureLiveOnly(label) };
1107
+ }
1108
+
1109
+ private reparentStructuralNode(id: string, newParentId: string | null): Promise<WriteAck> {
1110
+ const object = this.projector.object(id);
1111
+ if (!object) return this.refuseStructure(`"${id}" is not a node in this tree.`);
1112
+ const parent = this.containerFor(newParentId);
1113
+ if (!parent) {
1114
+ return this.refuseStructure(`no node with id "${newParentId}" can hold children.`);
1115
+ }
1116
+ if (parent === object || this.isAncestorOf(object, parent)) {
1117
+ return this.refuseStructure('a node cannot be reparented into itself or its own descendant.');
1118
+ }
1119
+ // WORLD TRANSFORM PRESERVED, computed from the accumulated LOCAL walk —
1120
+ // deliberately not Pixi's own `reparentChild`, which reads `worldTransform`.
1121
+ // That matrix is populated by the RENDER pass (see `hitTestChildren`), so on
1122
+ // a stage no renderer has drawn yet it is identity, and `reparentChild`
1123
+ // would silently teleport the node to the new parent's origin.
1124
+ const world = this.localToRootMatrix(object);
1125
+ const parentWorld = this.localToRootMatrix(parent);
1126
+ const label = `Move ${object.label ?? 'node'}`;
1127
+ this.structureHistory.track(object);
1128
+ if (object.parent) this.structureHistory.track(object.parent as Container);
1129
+ this.structureHistory.track(parent);
1130
+ this.structureHistory.run(label, () => {
1131
+ parent.addChild(object);
1132
+ object.setFromMatrix(parentWorld.clone().invert().append(world));
1133
+ });
1134
+ this.afterStructuralChange();
1135
+ return this.reportStructureLiveOnly(label);
1136
+ }
1137
+
1138
+ private reorderStructuralNode(id: string, beforeSiblingId: string | null): Promise<WriteAck> {
1139
+ const object = this.projector.object(id);
1140
+ const parent = (object?.parent as Container | null) ?? null;
1141
+ if (!object || !parent) {
1142
+ return this.refuseStructure(`"${id}" is not a reorderable node in this tree.`);
1143
+ }
1144
+ const sibling = beforeSiblingId ? this.projector.object(beforeSiblingId) : null;
1145
+ if (beforeSiblingId && (!sibling || sibling.parent !== parent)) {
1146
+ return this.refuseStructure(`"${beforeSiblingId}" is not a sibling of "${id}".`);
1147
+ }
1148
+ const label = `Reorder ${object.label ?? 'node'}`;
1149
+ this.structureHistory.track(parent);
1150
+ this.structureHistory.track(object);
1151
+ this.structureHistory.run(label, () => {
1152
+ const current = parent.children.indexOf(object);
1153
+ // `addChildAt` on the SAME parent lifts the node out first, so every
1154
+ // index above it shifts down by one — which is why a downward move lands
1155
+ // one slot earlier than the target's current index. `null` means "to the
1156
+ // end", i.e. the last slot of the lifted-out array.
1157
+ const target = sibling ? parent.children.indexOf(sibling) : parent.children.length;
1158
+ parent.addChildAt(object, Math.max(0, current < target ? target - 1 : target));
1159
+ });
1160
+ this.afterStructuralChange();
1161
+ return this.reportStructureLiveOnly(label);
1162
+ }
1163
+
1164
+ private isAncestorOf(ancestor: Container, node: Container): boolean {
1165
+ let cursor: Container | null = node.parent as Container | null;
1166
+ while (cursor) {
1167
+ if (cursor === ancestor) return true;
1168
+ cursor = cursor.parent as Container | null;
1169
+ }
1170
+ return false;
1171
+ }
1172
+
1173
+ /**
1174
+ * One object's matrix in the stage root's frame, accumulated from each
1175
+ * ancestor's own `localTransform` — the same render-pass-independent walk
1176
+ * `hitTestChildren` does, for the same reason.
1177
+ */
1178
+ private localToRootMatrix(object: Container): Matrix {
1179
+ const chain: Container[] = [];
1180
+ let cursor: Container | null = object;
1181
+ while (cursor && cursor !== this.root) {
1182
+ chain.push(cursor);
1183
+ cursor = cursor.parent as Container | null;
1184
+ }
1185
+ const matrix = new this.pixi.Matrix();
1186
+ for (let i = chain.length - 1; i >= 0; i--) {
1187
+ const node = chain[i]!;
1188
+ node.updateLocalTransform();
1189
+ matrix.append(node.localTransform);
1190
+ }
1191
+ return matrix;
1192
+ }
1193
+
1194
+ // ------------------------------------------------------------- asset drop
1195
+ //
1196
+ // "Drag an image from the asset browser onto the world" — the canvas lane's
1197
+ // half of the core authoring loop. An image becomes a Sprite through the SAME
1198
+ // create machinery and the same one-transaction bracket as
1199
+ // `structure.create`, so it is one Ctrl+Z and it carries the same honest
1200
+ // live-only report.
1201
+ //
1202
+ // Two callers, two shapes of target (see `AssetDropProvider`): the VIEWPORT
1203
+ // passes `''` (the world itself) plus the point under the cursor, in the
1204
+ // stage's own coordinate space — the frame `rects` answers in; the HIERARCHY
1205
+ // passes the row dropped on and no position, and the sprite lands at that
1206
+ // parent's origin.
1207
+
1208
+ readonly assetDrop: AssetDropProvider;
1209
+
1210
+ private async dropAsset(
1211
+ nodeId: string,
1212
+ assetPath: string,
1213
+ context?: AssetDropContext,
1214
+ ): Promise<WriteAck> {
1215
+ if (!IMAGE_ASSET_RE.test(assetPath)) {
1216
+ // Both shell callers consult `accepts` first, so this is the direct-call
1217
+ // path — it still says why rather than dropping the gesture on the floor.
1218
+ return this.refuseStructure(
1219
+ `${assetPath} is not an image. A canvas world mounts an image as a Sprite; a model or an ` +
1220
+ 'audio file has no display object to become here.',
1221
+ );
1222
+ }
1223
+ const parent = this.containerFor(nodeId || null);
1224
+ if (!parent) {
1225
+ return this.refuseStructure(`no node with id "${nodeId}" can hold a dropped asset.`);
1226
+ }
1227
+ let texture: Texture;
1228
+ try {
1229
+ texture = await this.loadTexture(assetPath);
1230
+ } catch (error) {
1231
+ return this.refuseStructure(
1232
+ `${assetPath} could not be loaded as a texture: ${
1233
+ error instanceof Error ? error.message : String(error)
1234
+ }`,
1235
+ );
1236
+ }
1237
+ const sprite = new this.pixi.Sprite(texture);
1238
+ // Centred on the drop point, which is where the user aimed.
1239
+ sprite.anchor.set(0.5, 0.5);
1240
+ sprite.label = assetPath.split('/').pop() ?? 'Sprite';
1241
+ const point = context?.position;
1242
+ if (point) {
1243
+ const local = this.localToRootMatrix(parent).clone().invert().apply({
1244
+ x: point[0],
1245
+ y: point[1],
1246
+ });
1247
+ sprite.position.set(local.x, local.y);
1248
+ }
1249
+ const label = `Add ${sprite.label}`;
1250
+ this.structureHistory.track(parent);
1251
+ this.structureHistory.track(sprite);
1252
+ this.structureHistory.run(label, () => {
1253
+ parent.addChild(sprite);
1254
+ });
1255
+ this.afterStructuralChange();
1256
+ // A drop is a structural write like any other, so it answers with its own
1257
+ // ack (`AssetDropProvider.drop`) rather than producing one and discarding it.
1258
+ return this.reportStructureLiveOnly(label);
1259
+ }
1260
+
1261
+ // -------------------------------------------------------------- transforms
1262
+
1263
+ /**
1264
+ * Canvas-native manipulation contribution for the shared 2D selection
1265
+ * overlay. The overlay speaks absolute stage-space rectangles and rotation
1266
+ * deltas; this provider translates those gestures into the same neutral
1267
+ * transform channel the inspector uses. The Pixi target still owns source
1268
+ * persistence, physics coordination, and the one-undo-step bracket.
1269
+ */
1270
+ readonly boxEdit: BoxEditProvider = {
1271
+ begin: (id) => {
1272
+ const rect = this.projector.rect(id);
1273
+ if (!rect) {
1274
+ this.boxEditSessions.delete(id);
1275
+ return;
1276
+ }
1277
+ this.boxEditSessions.set(id, { id, rect, transform: this.transforms.get(id) });
1278
+ this.transforms.beginEdit?.(id);
1279
+ },
1280
+ apply: (id, patch) => this.applyBoxEdit(id, patch),
1281
+ end: (id) => {
1282
+ const session = this.boxEditSessions.get(id);
1283
+ this.boxEditSessions.delete(id);
1284
+ if (!session) return;
1285
+ this.transforms.endEdit?.(id);
1286
+ },
1287
+ gizmoOrigin: (id) => this.gizmoOriginOf(id),
1288
+ };
1289
+
1290
+ /** Pixi's position projected through its parent chain is the invariant
1291
+ * point around which its native pivot rotates/scales. For a Sprite, anchor
1292
+ * shifts the rendered vertices around this same point; neither case can be
1293
+ * recovered from the visual bounds' center. */
1294
+ private gizmoOriginOf(id: string): { x: number; y: number } | null {
1295
+ const object = this.projector.object(id);
1296
+ if (!object || typeof object.getGlobalPosition !== 'function') return null;
1297
+ const point = object.getGlobalPosition();
1298
+ return { x: point.x, y: point.y };
1299
+ }
1300
+
1301
+ private applyBoxEdit(id: string, patch: Record<string, number>): void {
1302
+ const session = this.boxEditSessions.get(id);
1303
+ if (!session) return;
1304
+ if (
1305
+ boxPatchChannels(patch).some(
1306
+ (channel) => this.transforms.editability?.(id, channel).writable === false,
1307
+ )
1308
+ ) {
1309
+ return;
1310
+ }
1311
+ const next = transformForBoxPatch(session, patch);
1312
+ this.transforms.apply(id, next);
1313
+ if (
1314
+ patch['x'] === undefined &&
1315
+ patch['y'] === undefined &&
1316
+ patch['originX'] === undefined &&
1317
+ patch['originY'] === undefined &&
1318
+ patch['width'] === undefined &&
1319
+ patch['height'] === undefined
1320
+ ) {
1321
+ return;
1322
+ }
1323
+ this.repositionBoxEditBounds(id, session, patch, next);
1324
+ }
1325
+
1326
+ private repositionBoxEditBounds(
1327
+ id: string,
1328
+ session: CanvasBoxEditSession,
1329
+ patch: Record<string, number>,
1330
+ next: Transform,
1331
+ ): void {
1332
+ const object = this.projector.object(id);
1333
+ if (!object) return;
1334
+ const currentOrigin = this.gizmoOriginOf(id);
1335
+ const currentRect = this.projector.rect(id);
1336
+ const { dx, dy } = requestedBoxEditDelta(currentOrigin, currentRect, session.rect, patch);
1337
+ const parent = object.parent;
1338
+ if (parent && typeof parent.toLocal === 'function') {
1339
+ const origin = parent.toLocal({ x: 0, y: 0 });
1340
+ const shifted = parent.toLocal({ x: dx, y: dy });
1341
+ next.position[0] += shifted.x - origin.x;
1342
+ next.position[1] += shifted.y - origin.y;
1343
+ } else {
1344
+ next.position[0] += dx;
1345
+ next.position[1] += dy;
1346
+ }
1347
+ this.transforms.apply(id, next);
1348
+ }
1349
+
1350
+ // --------------------------------------------------------- spatial handles
1351
+ //
1352
+ // THE ONE COMPONENT-OWNED SPATIAL VALUE A CANVAS NODE HAS that its box does
1353
+ // not already report: its transform ORIGIN — a container's `pivot`, a
1354
+ // sprite's normalized `anchor`. Everything else the three lane draws here (an
1355
+ // attenuation radius, a light cone, a collider extent) is a component this
1356
+ // surface has no analogue of; the origin is the one that is real, and it is
1357
+ // invisible in every other panel because it is not a rect and not a position.
1358
+ //
1359
+ // WHY THE ORIGIN MOVE COMPENSATES `position`. The origin's world point IS the
1360
+ // node's global position (Pixi maps the pivot to `position`, and a sprite's
1361
+ // anchor point to its local zero), so changing the origin alone can never move
1362
+ // the handle — the dot would spring back under the cursor on every drag. The
1363
+ // gesture therefore does what every editor's origin affordance does: the
1364
+ // CONTENT stays where the author put it and the ORIGIN follows the pointer,
1365
+ // which takes one write to the origin and one compensating write to
1366
+ // `position`. Both land inside ONE `beginTransformEdit`/`endTransformEdit`
1367
+ // bracket, so they are one history transaction and one Ctrl+Z.
1368
+ //
1369
+ // The category is `origin` rather than one of the shell's named helper
1370
+ // channels: `HelperVisibility` names lights, colliders, audio and their
1371
+ // siblings, none of which this is, and the contract's own rule for a category
1372
+ // the shell does not know is that it obeys the master Helpers toggle (see
1373
+ // `SpatialHandleLayer`) — which is the correct behaviour here.
1374
+
1375
+ readonly spatialHandles: SpatialHandlesProvider = {
1376
+ layers: (id) => {
1377
+ const origin = this.originOf(id);
1378
+ if (!origin) return [];
1379
+ const editability = this.target.transformEditability(id, 'position');
1380
+ const writable = !!this.target.writeOrigin && editability.writable;
1381
+ return [
1382
+ {
1383
+ id: `origin:${id}`,
1384
+ category: 'origin',
1385
+ guides: [],
1386
+ handles: [
1387
+ {
1388
+ id: ORIGIN_HANDLE_ID,
1389
+ label: origin.kind === 'anchor' ? 'Sprite anchor' : 'Pivot',
1390
+ position: [origin.world.x, origin.world.y, 0],
1391
+ color: ORIGIN_HANDLE_COLOR,
1392
+ writable,
1393
+ // The target's own sentence when it has one; when it has no
1394
+ // origin write at all, the adapter says so itself — the same
1395
+ // "ABSENT ⇒ the adapter says it generically" rule
1396
+ // `reportStructureLiveOnly` follows.
1397
+ ...(writable
1398
+ ? {}
1399
+ : {
1400
+ reason:
1401
+ this.target.writeOrigin && editability.reason
1402
+ ? editability.reason
1403
+ : ORIGIN_UNWRITABLE_REASON,
1404
+ }),
1405
+ },
1406
+ ],
1407
+ },
1408
+ ];
1409
+ },
1410
+ preview: (id, handleId, worldPosition) => {
1411
+ this.moveOrigin(id, handleId, worldPosition, 'preview');
1412
+ },
1413
+ commit: (id, handleId, worldPosition) => this.moveOrigin(id, handleId, worldPosition, 'commit'),
1414
+ };
1415
+
1416
+ /** The node whose origin gesture is currently open, so a `commit` that
1417
+ * arrives without a preview still brackets exactly one gesture. */
1418
+ private originEditId: string | null = null;
1419
+
1420
+ /**
1421
+ * One node's transform origin: which native value it is, its value in the
1422
+ * node's own units, and where it sits in the stage's frame (the SAME frame
1423
+ * `rects` answers in, so the overlay draws it with no correction).
1424
+ */
1425
+ private originOf(
1426
+ id: string,
1427
+ ): { kind: 'pivot' | 'anchor'; value: PointData; world: PointData } | null {
1428
+ const object = this.projector.object(id);
1429
+ if (!object || typeof object.updateLocalTransform !== 'function') return null;
1430
+ const sprite = anchoredSprite(object);
1431
+ const kind = sprite ? 'anchor' : 'pivot';
1432
+ const value = sprite
1433
+ ? { x: sprite.anchor.x, y: sprite.anchor.y }
1434
+ : { x: object.pivot.x, y: object.pivot.y };
1435
+ // The LOCAL point the origin occupies: a container's pivot is that point
1436
+ // outright; a sprite's anchor names a point ON THE TEXTURE, which Pixi
1437
+ // always renders at the sprite's local zero.
1438
+ const local: PointData = sprite ? { x: 0, y: 0 } : value;
1439
+ const world = this.localToRootMatrix(object).apply({ x: local.x, y: local.y });
1440
+ return { kind, value, world: { x: world.x, y: world.y } };
1441
+ }
1442
+
1443
+ /**
1444
+ * Move `id`'s origin to a stage-space point, keeping the rendered content
1445
+ * still. `phase` is the whole difference between a live preview and the end
1446
+ * of the gesture: both write the same two values, and only `commit` closes
1447
+ * the bracket that records them.
1448
+ */
1449
+ private moveOrigin(
1450
+ id: string,
1451
+ handleId: string,
1452
+ worldPosition: SpatialPoint3,
1453
+ phase: 'preview' | 'commit',
1454
+ ): StructuralWriteOutcome {
1455
+ if (handleId !== ORIGIN_HANDLE_ID) return;
1456
+ const origin = this.originOf(id);
1457
+ const object = this.projector.object(id);
1458
+ const writeOrigin = this.target.writeOrigin;
1459
+ if (!origin || !object || !writeOrigin) return;
1460
+ if (!this.target.transformEditability(id, 'position').writable) return;
1461
+ if (this.originEditId !== id) {
1462
+ this.target.beginTransformEdit(id);
1463
+ this.originEditId = id;
1464
+ }
1465
+ object.updateLocalTransform();
1466
+ // The local point that currently lands under the pointer — the point the
1467
+ // origin has to become for the content to stay where it is.
1468
+ const target = this.localToRootMatrix(object)
1469
+ .clone()
1470
+ .invert()
1471
+ .apply({ x: worldPosition[0], y: worldPosition[1] });
1472
+ const localOrigin = origin.kind === 'anchor' ? { x: 0, y: 0 } : origin.value;
1473
+ const delta = { x: target.x - localOrigin.x, y: target.y - localOrigin.y };
1474
+ const size = origin.kind === 'anchor' ? anchorFrameSize(object) : null;
1475
+ if (origin.kind === 'anchor' && (!size || size.x === 0 || size.y === 0)) return;
1476
+ const next: readonly [number, number] = size
1477
+ ? [origin.value.x + delta.x / size.x, origin.value.y + delta.y / size.y]
1478
+ : [target.x, target.y];
1479
+ // `position` compensates through the object's own rotation/scale, which is
1480
+ // what keeps the content still under a rotated or scaled node.
1481
+ const matrix = object.localTransform;
1482
+ const current = this.a2d.getTransform(id);
1483
+ writeOrigin(id, origin.kind, next);
1484
+ if (current) {
1485
+ this.target.writeTransform(id, {
1486
+ ...current,
1487
+ position: [
1488
+ current.position[0] + matrix.a * delta.x + matrix.c * delta.y,
1489
+ current.position[1] + matrix.b * delta.x + matrix.d * delta.y,
1490
+ ],
1491
+ });
1492
+ }
1493
+ this.notify();
1494
+ if (phase === 'commit') {
1495
+ const ack = this.target.endTransformEdit(id);
1496
+ this.originEditId = null;
1497
+ return ack;
1498
+ }
1499
+ }
1500
+
1501
+ // --------------------------------------------------------------- inspector
1502
+
1503
+ readonly inspector: InspectorProvider = {
1504
+ properties: (id) => [
1505
+ ...this.target.properties(id),
1506
+ { path: 'locked', label: 'Locked', type: 'boolean', group: 'Visibility' },
1507
+ ],
1508
+ get: (id, path) => (path === 'locked' ? this.lockedIds.has(id) : this.target.get(id, path)),
1509
+ set: (id, path, value) => {
1510
+ if (path === 'locked') {
1511
+ if (value === true) this.lockedIds.add(id);
1512
+ else this.lockedIds.delete(id);
1513
+ this.notify();
1514
+ return;
1515
+ }
1516
+ // The TARGET's per-edit ack, passed straight through.
1517
+ return this.target.set(id, path, value);
1518
+ },
1519
+ remove: (id, path) => this.target.remove?.(id, path),
1520
+ };
1521
+
1522
+ private isPickLocked(id: string): boolean {
1523
+ let current: string | null = id;
1524
+ while (current) {
1525
+ if (this.lockedIds.has(current)) return true;
1526
+ current = this.projector.node(current)?.parentId ?? null;
1527
+ }
1528
+ return false;
1529
+ }
1530
+
1531
+ // ----------------------------------------------------------------- related
1532
+ //
1533
+ // A canvas subject's ONE honest jump is to the line that constructed it —
1534
+ // the shared kit piece (`creation-site-related.ts`), wired in the
1535
+ // constructor exactly when {@link truth} is present.
1536
+
1537
+ subscribe(listener: () => void): () => void {
1538
+ this.listeners.add(listener);
1539
+ return () => this.listeners.delete(listener);
1540
+ }
1541
+
1542
+ dispose(): void {
1543
+ this.boxEditSessions.clear();
1544
+ this.lockedIds.clear();
1545
+ for (const object of this.watchedForStructure) {
1546
+ object.off('childAdded', this.onStructureChanged);
1547
+ object.off('childRemoved', this.onStructureChanged);
1548
+ }
1549
+ this.watchedForStructure = [];
1550
+ this.structureHistory.dispose();
1551
+ this.target.dispose();
1552
+ this.listeners.clear();
1553
+ }
1554
+ }