@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,141 @@
1
+ /**
2
+ * The OID half of the canvas authoring surface's IDENTITY axis
3
+ * (`@volter/game-runtime/pixi/authoring`'s {@link CanvasIdentity}) — what a row is called
4
+ * when the display tree was authored as first-party `@pixi/react` TSX, and the
5
+ * resolution from such an id back to the source tag it addresses.
6
+ *
7
+ * Pure: no Pixi mount, no network, so both halves are unit-testable directly
8
+ * (`packages/editor/test/pixi-source-identity.test.ts`). The structural half of
9
+ * the same axis is `STRUCTURAL_CANVAS_IDENTITY`, in the engine beside the walk
10
+ * it parameterizes.
11
+ *
12
+ * ## Where the address comes from (MEASURED, not assumed)
13
+ *
14
+ * A canvas `.tsx` is stamped `data-oid` by the same transform every dom file
15
+ * gets (`ui-source/oid-transform.ts`'s `oidAttributeForSurface`: a `canvas`
16
+ * declared surface returns `data-oid`). `@pixi/react` hands every unrecognized
17
+ * prop to the Pixi class CONSTRUCTOR (`createInstance` → `new
18
+ * PixiComponent(pixiProps)`), and Pixi assigns its options onto the instance —
19
+ * so the stamp lands on the live `Container` under its own literal key,
20
+ * `container['data-oid']`.
21
+ *
22
+ * That is a fact about `@pixi/react`, not about this repo, and it is pinned by
23
+ * a real mount rather than reasoned about: `packages/editor/test/
24
+ * pixi-oid-stamp.test.tsx` mounts the real reconciler and asserts it. (The
25
+ * plausible-looking alternative — that the dash is PIERCED to `container.oid`
26
+ * the way fiber pierces `userData-oid` — is what this module was first written
27
+ * against, and the mount test is what caught it: every row silently fell back
28
+ * to a structural path.) {@link readContainerOid} is the ONE place that
29
+ * knows.
30
+ *
31
+ * Ids follow the three lane's `r3f:<worldId>:<oid>` precedent
32
+ * (`r3f-source-authoring-adapter.ts`) with a `pixi:` prefix, and they exist for
33
+ * that lane's reason: an oid signature is stable across a remount, so
34
+ * selection survives one.
35
+ */
36
+
37
+ import type { CanvasIdentity } from '@volter/game-runtime/pixi/authoring';
38
+ import type { Container } from 'pixi.js';
39
+ import { occurrenceId } from '@volter/editor-core/authoring/component-instance-root';
40
+
41
+ const PREFIX = 'pixi';
42
+
43
+ /** The live field the `data-oid` JSX prop becomes — see this module's header.
44
+ * Spelled once, here. */
45
+ const OID_FIELD = 'data-oid';
46
+ const INSTANCE_FIELD = 'data-authoring-instance';
47
+ const INSTANCE_LABEL_FIELD = 'data-authoring-label';
48
+ const INSTANCE_COMPONENT_FIELD = 'data-authoring-component';
49
+
50
+ /**
51
+ * The oid `@pixi/react` landed on a live display object, or `undefined`.
52
+ *
53
+ * Deliberately structural (`typeof === 'string'`): a game's own container
54
+ * subclass may declare an unrelated `oid` field, and a non-string one is not
55
+ * an address.
56
+ */
57
+ export function readContainerOid(object: unknown): string | undefined {
58
+ if (!object || typeof object !== 'object') return undefined;
59
+ const value = (object as Record<string, unknown>)[OID_FIELD];
60
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
61
+ }
62
+
63
+ function stringField(object: unknown, field: string): string | undefined {
64
+ if (!object || typeof object !== 'object') return undefined;
65
+ const value = (object as Record<string, unknown>)[field];
66
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
67
+ }
68
+
69
+ export function readContainerAuthoringInstance(object: unknown): string | undefined {
70
+ return stringField(object, INSTANCE_FIELD);
71
+ }
72
+
73
+ export function readContainerAuthoringLabel(object: unknown): string | undefined {
74
+ return stringField(object, INSTANCE_LABEL_FIELD);
75
+ }
76
+
77
+ export function readContainerAuthoringComponent(object: unknown): string | undefined {
78
+ return stringField(object, INSTANCE_COMPONENT_FIELD);
79
+ }
80
+
81
+ /** The first native Pixi host emitted by a component instance. */
82
+ export function isCanvasComponentInstanceRoot(object: Container): boolean {
83
+ const instance = readContainerAuthoringInstance(object);
84
+ if (!instance) return false;
85
+ return readContainerAuthoringInstance(object.parent) !== instance;
86
+ }
87
+
88
+ /** Callsite OID for a component root, otherwise the element's own OID. */
89
+ export function authoringOidForContainer(object: Container): string | undefined {
90
+ return isCanvasComponentInstanceRoot(object)
91
+ ? readContainerAuthoringInstance(object)
92
+ : readContainerOid(object);
93
+ }
94
+
95
+ /** `pixi:<worldId>:<oid>`, with `#n` for every occurrence past the first. */
96
+ export function pixiOidId(worldId: string, oid: string, occurrence = 0): string {
97
+ return occurrenceId(`${PREFIX}:${worldId}:${oid}`, occurrence);
98
+ }
99
+
100
+ /** The address for a node the source never stamped — an object the game built
101
+ * imperatively, or a library's internal child. Structural, so it is stable
102
+ * for as long as the tree's shape is. */
103
+ export function pixiPathId(worldId: string, path: readonly number[]): string {
104
+ return `${PREFIX}:${worldId}:@${path.join('.')}`;
105
+ }
106
+
107
+ /** The oid an id addresses, or `null` for a path id / an id minted for a
108
+ * DIFFERENT world. Never guesses. */
109
+ export function oidOfPixiId(worldId: string, id: string): string | null {
110
+ const head = `${PREFIX}:${worldId}:`;
111
+ if (!id.startsWith(head)) return null;
112
+ const rest = id.slice(head.length);
113
+ if (rest.startsWith('@')) return null;
114
+ const hash = rest.indexOf('#');
115
+ const oid = hash < 0 ? rest : rest.slice(0, hash);
116
+ return oid.length > 0 ? oid : null;
117
+ }
118
+
119
+ /**
120
+ * The identity a FIRST-PARTY canvas root supplies to the shared walk.
121
+ *
122
+ * A repeated callsite (`sim.grunts.map(…)` renders one JSX element ninety
123
+ * times) resolves ninety live objects to one oid; they are disambiguated with
124
+ * the same `#n` occurrence suffix the three lane uses, counted per walk — which
125
+ * is what {@link CanvasIdentity.beginWalk} exists for.
126
+ */
127
+ export function createOidCanvasIdentity(worldId: string): CanvasIdentity {
128
+ let occurrences = new Map<string, number>();
129
+ return {
130
+ beginWalk: () => {
131
+ occurrences = new Map();
132
+ },
133
+ idFor: (object: Container, path: readonly number[]): string => {
134
+ const oid = authoringOidForContainer(object);
135
+ if (oid === undefined) return pixiPathId(worldId, path);
136
+ const n = occurrences.get(oid) ?? 0;
137
+ occurrences.set(oid, n + 1);
138
+ return pixiOidId(worldId, oid, n);
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * STILL-STAGE PRESENTATION for design-time Pixi mounts — the two pieces the
3
+ * canvas design layer (`canvas-design-mount.ts`) and the isolated screen mount
4
+ * (`mount-isolated-pixi-screen.ts`) each spelled for themselves (owner-directed
5
+ * overlap audit, 2026-08-22): framing authored bounds into the Scene camera,
6
+ * and preparing a camera-presented frame for capture on a hidden tab.
7
+ *
8
+ * Both mounts share one presentation model: the stage is STILL (no ticker —
9
+ * Edit's paused clock), the editor camera owns pan/zoom, and a capture must
10
+ * see the exact camera-presented frame rather than a raw-stage extraction.
11
+ * What stays per-mount is the render itself (whose renderer, whose stage,
12
+ * whose camera pose) — handed in as `renderScene`.
13
+ */
14
+
15
+ import { registerPresentedCanvasFrame } from '@volter/editor-core/canvas-preview-frames';
16
+ import type { RootViewController } from '@volter/editor-core/authoring/world-pan-state';
17
+
18
+ /**
19
+ * Frame authored world bounds in the independent Scene camera. Unlike an
20
+ * asset/UI preview, a scene editor may magnify small subjects: zoom is an
21
+ * inspection tool, not a claim about the runtime's pixel scale. `false` when
22
+ * the pane or the bounds are too small to frame yet (a sub-2px pane is a
23
+ * dock mid-layout, not a viewport).
24
+ */
25
+ export function fitSceneView(
26
+ layer: HTMLElement,
27
+ bounds: { x: number; y: number; width: number; height: number },
28
+ view: RootViewController,
29
+ /** A floor on the zoom the fit may choose — the OPENING fit passes the zoom
30
+ * that frames the game's declared resolution, so a world whose floor dwarfs
31
+ * its actors still opens at the scale the player sees (see the mount). */
32
+ minZoom = 0.1,
33
+ ): boolean {
34
+ const rect = layer.getBoundingClientRect();
35
+ if (rect.width < 2 || rect.height < 2) return false;
36
+ if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
37
+ const zoom = Math.min(
38
+ 4,
39
+ Math.max(
40
+ minZoom,
41
+ Math.min((rect.width - 96) / bounds.width, (rect.height - 96) / bounds.height),
42
+ ),
43
+ );
44
+ view.setView(
45
+ rect.width / 2 - (bounds.x + bounds.width / 2) * zoom,
46
+ rect.height / 2 - (bounds.y + bounds.height / 2) * zoom,
47
+ zoom,
48
+ );
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * A hidden editor tab has no rAF, but capture is still a first-class reader of
54
+ * the authoring surface. Register a presenter that prepares the exact
55
+ * camera-presented frame at that boundary instead of falling back to raw-stage
56
+ * extraction: `renderScene` is retried across event-loop turns because the
57
+ * first frames of a mount can race layout (`false` = not renderable yet).
58
+ * Returns the unregister function.
59
+ */
60
+ export function registerStillFramePresenter(
61
+ canvas: HTMLCanvasElement,
62
+ renderScene: () => boolean,
63
+ ): () => void {
64
+ return registerPresentedCanvasFrame(canvas, async () => {
65
+ for (let turn = 0; turn < 24; turn++) {
66
+ if (renderScene()) return null;
67
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
68
+ }
69
+ return null;
70
+ });
71
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * ONE undo entry per STRUCTURAL op on a live canvas tree — the bracket
3
+ * `PixiAuthoringAdapter.structure` wraps every create/remove/duplicate/
4
+ * reparent/reorder in.
5
+ *
6
+ * It is the SAME history path a transform edit already takes (a
7
+ * {@link JsonHistoryResource} registered against `store.projectHistory`), not a
8
+ * second one: what differs is the STATE SHAPE, and that is why it is a resource
9
+ * of its own rather than a field bolted onto the write target's. The write
10
+ * target journals a node's VALUES (label, alpha, tint, transform) keyed by the
11
+ * adapter's structural-path id; a structural op changes what those ids even
12
+ * ARE, so a value snapshot cannot describe it. This resource journals
13
+ * PLACEMENTS instead — "which parent, at which index, in which local pose" —
14
+ * keyed by a uid stamped on the display object itself, which survives the
15
+ * re-walk that renames every id.
16
+ *
17
+ * WHY A UID AND A REGISTRY. A removed object must be resurrectable, so it is
18
+ * kept alive here (detached, never `destroy()`ed) and found again by uid; a
19
+ * JSON snapshot cannot hold a `Container` reference. The registry is the ONLY
20
+ * thing that makes undo-of-remove possible at all.
21
+ *
22
+ * IT IS DELIBERATELY EDIT-SCOPED. Only objects an editor op touched (plus the
23
+ * ancestors those ops named) are tracked, so a restore never reorders the
24
+ * thousands of sprites a RUNNING game adds and removes on its own every frame.
25
+ * For the same reason `conflictIdentity` narrows to the parent map: a game that
26
+ * appended one sprite of its own since the transaction was recorded has shifted
27
+ * every index without touching anything the editor authored, and hashing those
28
+ * indexes would make every undo fail preflight as a content-conflict — the
29
+ * failure the three lane paid for as #81.
30
+ */
31
+
32
+ import type { Container } from 'pixi.js';
33
+ import { editorConsole } from '@volter/editor-core/editor-console';
34
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
35
+ import { type JournalSubject, JsonHistoryResource } from '../history/json-history-resource';
36
+
37
+ /** The stage root's stand-in uid: it is not one of the adapter's nodes, but it
38
+ * is a legal parent for every op. */
39
+ const ROOT_UID = '#root';
40
+
41
+ /** The uid stamp, on the display object itself — the same technique
42
+ * `AuthoringAdapter2D` uses for `__authId`, for the same reason (there is no
43
+ * side table that survives the object outliving one walk). */
44
+ interface StructureStamped {
45
+ __vgaiStructUid?: string;
46
+ }
47
+
48
+ /** A node's own local pose, as plain numbers. Recorded because `reparent`
49
+ * rewrites it to preserve the world transform — undoing the move without
50
+ * undoing that rewrite would leave the node in the right parent at the wrong
51
+ * place. */
52
+ interface CanvasNodePose {
53
+ x: number;
54
+ y: number;
55
+ rotation: number;
56
+ scaleX: number;
57
+ scaleY: number;
58
+ pivotX: number;
59
+ pivotY: number;
60
+ skewX: number;
61
+ skewY: number;
62
+ }
63
+
64
+ interface CanvasPlacement {
65
+ /** The parent's uid, {@link ROOT_UID} for the stage root, or `null` when the
66
+ * node is detached (removed, or not yet added). */
67
+ parent: string | null;
68
+ /** Index among that parent's children; `-1` while detached. */
69
+ index: number;
70
+ pose?: CanvasNodePose;
71
+ }
72
+
73
+ export interface CanvasStructureState {
74
+ placements: Record<string, CanvasPlacement>;
75
+ }
76
+
77
+ function poseOf(object: Container): CanvasNodePose | undefined {
78
+ // A tracked node is not guaranteed to be a real PixiJS display object (the
79
+ // same tolerance `getTransform` and the structure watcher already carry).
80
+ if (!object.position || !object.scale || !object.pivot || !object.skew) return undefined;
81
+ return {
82
+ x: object.position.x,
83
+ y: object.position.y,
84
+ rotation: object.rotation,
85
+ scaleX: object.scale.x,
86
+ scaleY: object.scale.y,
87
+ pivotX: object.pivot.x,
88
+ pivotY: object.pivot.y,
89
+ skewX: object.skew.x,
90
+ skewY: object.skew.y,
91
+ };
92
+ }
93
+
94
+ function applyPose(object: Container, pose: CanvasNodePose): void {
95
+ if (!object.position || !object.scale || !object.pivot || !object.skew) return;
96
+ object.position.set(pose.x, pose.y);
97
+ object.scale.set(pose.scaleX, pose.scaleY);
98
+ object.pivot.set(pose.pivotX, pose.pivotY);
99
+ object.skew.set(pose.skewX, pose.skewY);
100
+ object.rotation = pose.rotation;
101
+ }
102
+
103
+ export class CanvasStructureHistory {
104
+ private readonly byUid = new Map<string, Container>();
105
+ private readonly resource: JsonHistoryResource<CanvasStructureState> | null;
106
+
107
+ constructor(
108
+ private readonly root: Container,
109
+ store: EditorShellStore,
110
+ /** Whose journal this structure stack belongs to — the world's for a held
111
+ * surface, the run's for play (`history/json-history-resource.ts`). */
112
+ journal: JournalSubject,
113
+ /** Re-index + notify after a restore put objects back. */
114
+ private readonly onRestored: () => void,
115
+ ) {
116
+ const history = store.projectHistory;
117
+ this.resource = history
118
+ ? new JsonHistoryResource<CanvasStructureState>({
119
+ history,
120
+ kind: 'session-state',
121
+ scope: 'session',
122
+ subject: { ...journal, id: `${journal.id}/world-2d-structure` },
123
+ displayName: 'World 2D structure',
124
+ capture: () => this.capture(),
125
+ conflictIdentity: (state) =>
126
+ Object.fromEntries(
127
+ Object.entries(state.placements).map(([uid, placement]) => [uid, placement.parent]),
128
+ ),
129
+ restore: async (state) => {
130
+ this.restore(state);
131
+ },
132
+ })
133
+ : null;
134
+ }
135
+
136
+ /** Register a display object so its placement is journaled and so a detached
137
+ * one can be found again. Idempotent; returns the uid. */
138
+ track(object: Container): string {
139
+ return this.uidOf(object);
140
+ }
141
+
142
+ /**
143
+ * Run ONE structural mutation as ONE undo entry.
144
+ *
145
+ * Every participant must be {@link track}ed BEFORE the call — including a
146
+ * node that does not exist in the tree yet, whose "before" placement is
147
+ * therefore `detached`, which is exactly what makes undo-of-create remove it.
148
+ */
149
+ run<T>(label: string, mutate: () => T): T {
150
+ this.resource?.assertCanMutate();
151
+ const before = this.capture();
152
+ const result = mutate();
153
+ if (this.resource) {
154
+ // JOURNALED LOUDLY. A rejected `record(...)` means the op IS applied to
155
+ // the live tree and has NO history entry: the user sees the node they
156
+ // created/moved/deleted and Ctrl+Z does nothing, with nothing said. Same
157
+ // statement (and same wording) as the three lane's `recordHistory`
158
+ // (`three-authoring-adapter.ts`).
159
+ //
160
+ // WHY THE REPORT IS HERE AND NOT IN THE VERB'S ACK. Every structural verb
161
+ // funnels through this method, and two of them — `structure.create` and
162
+ // `structure.duplicate` — return a synchronous node id by contract, so no
163
+ // caller can await this write. One report at the funnel is the only place
164
+ // that covers all of them identically; the pipe's ack answers a different
165
+ // question (where the BYTES went — nowhere, for this lane) and cannot
166
+ // carry a journaling failure without claiming it was a persistence one.
167
+ void this.resource.record(label, before, this.capture()).catch((error: unknown) => {
168
+ editorConsole.error(
169
+ `[canvas] failed to journal "${label}" into project history — the structural edit is ` +
170
+ `applied but has no history entry (it cannot be undone): ${
171
+ error instanceof Error ? error.message : String(error)
172
+ }`,
173
+ 'authoring',
174
+ );
175
+ });
176
+ }
177
+ return result;
178
+ }
179
+
180
+ dispose(): void {
181
+ this.resource?.dispose();
182
+ this.byUid.clear();
183
+ }
184
+
185
+ private uidOf(object: Container): string {
186
+ if (object === this.root) return ROOT_UID;
187
+ const stamped = object as Container & StructureStamped;
188
+ const existing = stamped.__vgaiStructUid;
189
+ if (existing && this.byUid.get(existing) === object) return existing;
190
+ const uid = existing ?? `s${crypto.randomUUID()}`;
191
+ stamped.__vgaiStructUid = uid;
192
+ this.byUid.set(uid, object);
193
+ return uid;
194
+ }
195
+
196
+ private capture(): CanvasStructureState {
197
+ const placements: Record<string, CanvasPlacement> = {};
198
+ // `uidOf` may register an ancestor mid-iteration; a Map iterator visits
199
+ // entries added during the walk, so those ancestors get placements of their
200
+ // own — bounded by the depth of the tree, and what makes a restore able to
201
+ // name the parent it is putting a node back under.
202
+ for (const [uid, object] of this.byUid) {
203
+ const parent = (object.parent as Container | null) ?? null;
204
+ const pose = poseOf(object);
205
+ placements[uid] = {
206
+ parent: parent ? this.uidOf(parent) : null,
207
+ index: parent ? (parent.children?.indexOf(object) ?? -1) : -1,
208
+ ...(pose ? { pose } : {}),
209
+ };
210
+ }
211
+ return { placements };
212
+ }
213
+
214
+ private restore(state: CanvasStructureState): void {
215
+ const entries = Object.entries(state.placements);
216
+ // Detach first, so the indexes the attach pass reads are the ones the
217
+ // snapshot was taken against.
218
+ for (const [uid, placement] of entries) {
219
+ if (placement.parent !== null) continue;
220
+ const object = this.byUid.get(uid);
221
+ object?.parent?.removeChild(object);
222
+ }
223
+ for (const [uid, placement] of entries
224
+ .filter(([, placement]) => placement.parent !== null)
225
+ .sort((a, b) => a[1].index - b[1].index)) {
226
+ const object = this.byUid.get(uid);
227
+ if (!object) continue;
228
+ const parent =
229
+ placement.parent === ROOT_UID ? this.root : this.byUid.get(placement.parent as string);
230
+ if (!parent || typeof parent.addChildAt !== 'function') continue;
231
+ const capacity = parent.children.length - (object.parent === parent ? 1 : 0);
232
+ parent.addChildAt(object, Math.max(0, Math.min(placement.index, capacity)));
233
+ if (placement.pose) applyPose(object, placement.pose);
234
+ }
235
+ this.onRestored();
236
+ }
237
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The CANVAS surface's transform vocabulary, as pure functions.
3
+ *
4
+ * Pixi's transform is `x`, `y`, `rotation` (a scalar, radians, about the
5
+ * screen normal) and `scale` — there is no z and no quaternion. The editor's
6
+ * neutral {@link Transform} is 3D by construction, so the canvas adapter has to
7
+ * translate in both directions. It used to carry that `angleToQuat`/
8
+ * `quatToAngle` pair inline (extraction cited: `pixi-live-write-target.ts`'s
9
+ * own header records where the code it was lifted out of lived). This module
10
+ * is that pair plus the SOURCE half the write path needs, in one place, so the
11
+ * live and source targets can never disagree about what "rotation" means.
12
+ *
13
+ * ## Why the source half is pure, and lives here
14
+ *
15
+ * Deciding which `@pixi/react` PROP a transform channel writes is the whole
16
+ * literal-vs-dynamic contract of the canvas lane, and it is decidable from two
17
+ * inputs alone: the channel, and the JSX attributes already on the tag. No
18
+ * live tree, no network, no adapter. Keeping it here is what makes it testable
19
+ * without mounting Pixi (`packages/editor/test/pixi-transform-channels.test.ts`).
20
+ *
21
+ * ## The scale rule, stated once
22
+ *
23
+ * `<pixiContainer scale={0.5}>` is the shipped idiom (see any prefab under an
24
+ * example project's `prefabs/`), and Pixi's own setter takes a number or a
25
+ * `PointData` — never an array. So a UNIFORM scale writes the scalar back, and
26
+ * a NON-UNIFORM one is only expressible through `@pixi/react`'s dashed-prop
27
+ * piercing (`scale-x`/`scale-y`, which land on `container.scale.x`/`.y`). This
28
+ * module writes the dashed pair when the tag already authors it, and otherwise
29
+ * REFUSES a non-uniform scale in a sentence naming the pair — never a silently
30
+ * lossy uniform write, and never an array literal Pixi would reject at
31
+ * runtime.
32
+ */
33
+
34
+ import type { Transform2DValue } from '@volter/game-runtime/pixi/authoring';
35
+ import type { Transform } from '@volter/editor-project/adapter';
36
+
37
+ /** Quaternion (xyzw) about Z ⇄ scalar radians — the one mapping both canvas
38
+ * adapters use. */
39
+ export function angleToQuat(a: number): [number, number, number, number] {
40
+ return [0, 0, Math.sin(a / 2), Math.cos(a / 2)];
41
+ }
42
+
43
+ export function quatToAngle(q: readonly [number, number, number, number]): number {
44
+ return 2 * Math.atan2(q[2], q[3]);
45
+ }
46
+
47
+ /** Pixi's own 2D transform → the editor's neutral 3D one (z pinned). */
48
+ export function toNeutralTransform(t: Transform2DValue): Transform {
49
+ return {
50
+ position: [t.position[0], t.position[1], 0],
51
+ rotation: angleToQuat(t.rotation),
52
+ scale: [t.scale[0], t.scale[1], 1],
53
+ };
54
+ }
55
+
56
+ /** …and back. The z channel is DROPPED rather than folded in anywhere: a
57
+ * canvas world has no depth axis, so carrying one would fabricate a value. */
58
+ export function fromNeutralTransform(t: Transform): Transform2DValue {
59
+ return {
60
+ position: [t.position[0], t.position[1]],
61
+ rotation: quatToAngle(t.rotation),
62
+ scale: [t.scale[0], t.scale[1]],
63
+ };
64
+ }
65
+
66
+ /** The subset of `JsxAttrInfo` this planner reads — declared structurally so a
67
+ * test can hand it plain objects. */
68
+ export interface PixiAttr {
69
+ readonly name: string;
70
+ readonly rawValue: string;
71
+ readonly isExpression: boolean;
72
+ readonly isLiteral: boolean;
73
+ }
74
+
75
+ /** One `prop={value}` write the plan resolves to. `value` is SOURCE TEXT. */
76
+ export interface PixiPropWrite {
77
+ readonly prop: string;
78
+ readonly value: string;
79
+ }
80
+
81
+ export type PixiChannelPlan =
82
+ | { readonly writable: true; readonly writes: readonly PixiPropWrite[] }
83
+ | { readonly writable: false; readonly reason: string };
84
+
85
+ const NUMBER_RE = /^-?\d+(\.\d+)?$/;
86
+ const EPS = 1e-6;
87
+
88
+ /** ≤4 decimals, no trailing zeros, no negative zero — the same source-number
89
+ * formatting the R3F writer uses. */
90
+ export function formatSourceNumber(n: number): string {
91
+ const rounded = Math.round(n * 1e4) / 1e4;
92
+ return Object.is(rounded, -0) ? '0' : String(rounded);
93
+ }
94
+
95
+ function attr(attrs: readonly PixiAttr[], name: string): PixiAttr | undefined {
96
+ return attrs.find((candidate) => candidate.name === name);
97
+ }
98
+
99
+ /**
100
+ * A prop is writable when it is ABSENT (the writer appends it) or authored as
101
+ * a literal. An expression-bound prop (`x={grunt.x}`) is the sacred refusal:
102
+ * the game computes it every frame, so writing a number there would be a
103
+ * two-truths divergence the next tick erases.
104
+ */
105
+ function propRefusal(attrs: readonly PixiAttr[], prop: string): string | null {
106
+ const found = attr(attrs, prop);
107
+ if (!found) return null;
108
+ if (found.isExpression && !found.isLiteral) {
109
+ return `${prop} is controlled by the JSX expression {${found.rawValue}}.`;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Which props one transform channel writes, given what the tag already says.
116
+ *
117
+ * `next` is the DESIRED transform in Pixi's own vocabulary. Returns the exact
118
+ * `prop={value}` writes to make, or one sentence saying why the channel is not
119
+ * writable here.
120
+ */
121
+ export function planChannelWrite(
122
+ channel: 'position' | 'rotation' | 'scale',
123
+ next: Transform2DValue,
124
+ attrs: readonly PixiAttr[],
125
+ ): PixiChannelPlan {
126
+ if (channel === 'position') {
127
+ for (const prop of ['x', 'y'] as const) {
128
+ const refusal = propRefusal(attrs, prop);
129
+ if (refusal) return { writable: false, reason: refusal };
130
+ }
131
+ return {
132
+ writable: true,
133
+ writes: [
134
+ { prop: 'x', value: formatSourceNumber(next.position[0]) },
135
+ { prop: 'y', value: formatSourceNumber(next.position[1]) },
136
+ ],
137
+ };
138
+ }
139
+
140
+ if (channel === 'rotation') {
141
+ const refusal = propRefusal(attrs, 'rotation');
142
+ if (refusal) return { writable: false, reason: refusal };
143
+ return {
144
+ writable: true,
145
+ writes: [{ prop: 'rotation', value: formatSourceNumber(next.rotation) }],
146
+ };
147
+ }
148
+
149
+ // --- scale (see this module's header for the rule) ---
150
+ const dashed = attr(attrs, 'scale-x') ?? attr(attrs, 'scale-y');
151
+ if (dashed) {
152
+ for (const prop of ['scale-x', 'scale-y'] as const) {
153
+ const refusal = propRefusal(attrs, prop);
154
+ if (refusal) return { writable: false, reason: refusal };
155
+ }
156
+ return {
157
+ writable: true,
158
+ writes: [
159
+ { prop: 'scale-x', value: formatSourceNumber(next.scale[0]) },
160
+ { prop: 'scale-y', value: formatSourceNumber(next.scale[1]) },
161
+ ],
162
+ };
163
+ }
164
+
165
+ const refusal = propRefusal(attrs, 'scale');
166
+ if (refusal) return { writable: false, reason: refusal };
167
+ const authored = attr(attrs, 'scale');
168
+ if (authored && !NUMBER_RE.test(authored.rawValue.trim())) {
169
+ return {
170
+ writable: false,
171
+ reason:
172
+ `scale is authored as {${authored.rawValue}} — this editor writes the scalar ` +
173
+ 'shorthand or the `scale-x`/`scale-y` pair, not an object literal.',
174
+ };
175
+ }
176
+ const [sx, sy] = next.scale;
177
+ if (Math.abs(sx - sy) >= EPS) {
178
+ return {
179
+ writable: false,
180
+ reason:
181
+ 'A non-uniform scale needs the `scale-x`/`scale-y` props — this tag authors the ' +
182
+ 'uniform `scale` shorthand, which cannot hold two values.',
183
+ };
184
+ }
185
+ return { writable: true, writes: [{ prop: 'scale', value: formatSourceNumber(sx) }] };
186
+ }
187
+
188
+ /** Whether two 2D transforms differ enough to be worth a source write. */
189
+ export function transform2DChanged(
190
+ channel: 'position' | 'rotation' | 'scale',
191
+ before: Transform2DValue,
192
+ after: Transform2DValue,
193
+ ): boolean {
194
+ if (channel === 'position') {
195
+ return (
196
+ Math.abs(before.position[0] - after.position[0]) >= EPS ||
197
+ Math.abs(before.position[1] - after.position[1]) >= EPS
198
+ );
199
+ }
200
+ if (channel === 'rotation') return Math.abs(before.rotation - after.rotation) >= EPS;
201
+ return (
202
+ Math.abs(before.scale[0] - after.scale[0]) >= EPS ||
203
+ Math.abs(before.scale[1] - after.scale[1]) >= EPS
204
+ );
205
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * One-shot selection handoff for UI remounts that rebuild the scene between
3
+ * the outgoing and incoming owners. Keys are weak so closing a store cannot
4
+ * leave editor state retained by the handoff.
5
+ */
6
+ export class SelectionRemountHandoff<Key extends object> {
7
+ private readonly pending = new WeakMap<Key, readonly string[]>();
8
+
9
+ remember(key: Key, selection: Iterable<string>): void {
10
+ const ids = [...selection];
11
+ if (ids.length === 0) this.pending.delete(key);
12
+ else this.pending.set(key, ids);
13
+ }
14
+
15
+ take(key: Key, currentSelection: Iterable<string>): readonly string[] {
16
+ const current = [...currentSelection];
17
+ const previous = this.pending.get(key) ?? [];
18
+ this.pending.delete(key);
19
+ // A selection made while the incoming owner was mounting is newer than
20
+ // the outgoing owner's handoff and must win.
21
+ return current.length > 0 ? current : previous;
22
+ }
23
+ }