@volter/editor-threejs 0.5.65 → 0.5.67

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 (112) hide show
  1. package/NOTICE +2 -0
  2. package/contributions/animation-mixers.service.ts +20 -0
  3. package/contributions/animation-timeline.utility.tsx +44 -0
  4. package/contributions/three-integration.service.ts +13 -0
  5. package/dist-node/serving.mjs +405 -0
  6. package/package.json +113 -5
  7. package/serving/animation-live-module.ts +70 -0
  8. package/serving/animation-stamp.ts +88 -0
  9. package/serving/index.ts +14 -0
  10. package/serving/model-import-conversion.ts +344 -0
  11. package/src/adapter/ingest/scene-capture.ts +1 -23
  12. package/src/adapter/renderer-config.ts +3 -4
  13. package/src/adapter/three-contract.ts +72 -0
  14. package/src/animation/live-mixers.ts +55 -0
  15. package/src/ecs/object-marks.ts +1 -1
  16. package/src/ecs/user-data.ts +0 -16
  17. package/src/host-hierarchy-objects.ts +31 -0
  18. package/src/kit/animation/three-clips-subject.ts +190 -0
  19. package/src/kit/asset-compare.ts +294 -0
  20. package/src/kit/asset-preview-command.ts +265 -0
  21. package/src/kit/asset-preview-framing.ts +357 -0
  22. package/src/kit/asset-preview.ts +2802 -0
  23. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  24. package/src/kit/authoring/component-instance-root.ts +171 -0
  25. package/src/kit/authoring/design-time-settle.ts +343 -0
  26. package/src/kit/authoring/live-object-transform.ts +62 -0
  27. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  28. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  29. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  30. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  31. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  32. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  33. package/src/kit/authoring/three-projection-core.ts +226 -0
  34. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  35. package/src/kit/authoring/viewport-raycast.ts +240 -0
  36. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  37. package/src/kit/camera-authoring.ts +175 -0
  38. package/src/kit/components/CameraInfo.tsx +56 -0
  39. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  40. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  41. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  42. package/src/kit/components/StageHost.tsx +2547 -0
  43. package/src/kit/components/StageOverlays.tsx +21 -0
  44. package/src/kit/components/StatsOverlay.tsx +78 -0
  45. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  46. package/src/kit/components/ViewportFurniture.tsx +655 -0
  47. package/src/kit/components/ViewportOverlay.tsx +215 -0
  48. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  49. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  50. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  51. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  52. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  53. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  54. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  55. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  56. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  57. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  58. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  59. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  60. package/src/kit/components/stage-keyboard.tsx +40 -0
  61. package/src/kit/components/stage-overlay-set.tsx +105 -0
  62. package/src/kit/components/stage-presence-markers.ts +482 -0
  63. package/src/kit/components/stage-transform-chrome.ts +30 -0
  64. package/src/kit/components/stage-transform-tools.tsx +73 -0
  65. package/src/kit/components/stage-view-name.ts +30 -0
  66. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  67. package/src/kit/components/world-root-binding.ts +64 -0
  68. package/src/kit/constraint-helper.ts +338 -0
  69. package/src/kit/editor-shell-store.ts +814 -0
  70. package/src/kit/editor-viewport.ts +6621 -0
  71. package/src/kit/entity-lod.ts +31 -0
  72. package/src/kit/entity-object.ts +92 -0
  73. package/src/kit/hierarchy-mark-reader.ts +74 -0
  74. package/src/kit/instanced-presentation.ts +164 -0
  75. package/src/kit/live-module-source.ts +230 -0
  76. package/src/kit/model-thumbnail.ts +539 -0
  77. package/src/kit/play-camera-flight.ts +300 -0
  78. package/src/kit/projection/three.ts +898 -0
  79. package/src/kit/reflection-probe-helper.ts +142 -0
  80. package/src/kit/scene-document-viewport.ts +51 -0
  81. package/src/kit/scene-framing.ts +315 -0
  82. package/src/kit/scene-view-fog.ts +89 -0
  83. package/src/kit/spatial-handle-visuals.ts +332 -0
  84. package/src/kit/stories/three-story-model.ts +66 -0
  85. package/src/kit/three-canvas-render.ts +44 -0
  86. package/src/kit/three-hierarchy-row-media.ts +26 -0
  87. package/src/kit/three-inspection-media.ts +73 -0
  88. package/src/kit/three-integration.ts +86 -0
  89. package/src/kit/three-state.ts +33 -0
  90. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  91. package/src/kit/three-viewport/camera-fit.ts +41 -0
  92. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  93. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  94. package/src/kit/three-viewport/selection-outline.ts +333 -0
  95. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  96. package/src/kit/three-viewport/source-color.ts +197 -0
  97. package/src/kit/three-viewport/studio-environment.ts +96 -0
  98. package/src/kit/trigger-volume-helper.ts +116 -0
  99. package/src/kit/viewport-actions.ts +128 -0
  100. package/src/kit/viewport-authoring-policy.ts +154 -0
  101. package/src/kit/viewport-commands.ts +318 -0
  102. package/src/kit/viewport-hotkeys.ts +119 -0
  103. package/src/kit/viewport-shading-boundary.ts +12 -0
  104. package/src/kit/viewport-status-facet.ts +53 -0
  105. package/src/object3d-contributions.ts +494 -0
  106. package/src/render/viewport-shading.ts +6 -2
  107. package/src/viewport/content-bounds.ts +38 -4
  108. package/src/viewport/environment.ts +16 -0
  109. package/src/viewport-api.ts +92 -0
  110. package/src/viewport-door.ts +237 -0
  111. package/src/animation/animation-clock.ts +0 -479
  112. package/src/animation/runtime-inspection.ts +0 -45
@@ -0,0 +1,171 @@
1
+ /**
2
+ * ONE predicate for "is this object the outermost node of a component
3
+ * instance?", shared by every reader of the OID convention.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * the source-authoring integration's serving plugin transform stamps `userData.authoringInstance` (the
8
+ * CALLSITE oid) on **every host element inside a component definition**, not
9
+ * just on the one the component returns — that is what lets a click on a mesh
10
+ * deep inside `<Coin/>` resolve to the `<Coin/>` callsite, and what lets a
11
+ * gizzmo drag rewrite the callsite's own props.
12
+ *
13
+ * So `authoringInstance !== undefined` answers "which instance does this node
14
+ * BELONG to", and it is the wrong question to ask when you mean "is this node
15
+ * that instance's root". Reading it as the latter is what made the hierarchy
16
+ * print `Stage` on every one of `Stage`'s interior nodes and type all of them
17
+ * `component`: `WorldEnvironment`, `GridMap` and `Coins` all carry `Stage`'s
18
+ * stamp, because they are all inside `Stage`'s definition.
19
+ *
20
+ * The boundary question has a structural answer that needs no extra stamp: an
21
+ * instance's root is the node whose `authoringInstance` its PARENT does not
22
+ * share. A component that returns several spatial roots (R3F003) therefore has
23
+ * several — which is exactly what that warning is about, and not something this
24
+ * predicate should hide.
25
+ */
26
+
27
+ import { getUserData } from '@volter/editor-threejs/ecs/user-data';
28
+ import type * as THREE from 'three';
29
+
30
+ /**
31
+ * True when `object` is the outermost node of one component instance — the row
32
+ * that should print the component's label and read as `role: 'component'`.
33
+ *
34
+ * False for an interior host element of that same instance (it belongs to the
35
+ * instance but is not its boundary) and for any node the transform never
36
+ * stamped.
37
+ */
38
+ export function isComponentInstanceRoot(object: THREE.Object3D | null | undefined): boolean {
39
+ return instanceCallsiteOid(object) !== undefined;
40
+ }
41
+
42
+ /**
43
+ * WHICH INSTANCE this node belongs to — the raw `authoringInstance` stamp,
44
+ * read in exactly one place.
45
+ *
46
+ * Deliberately NOT the same question as {@link isComponentInstanceRoot} (see
47
+ * this module's header): every host element inside a component definition
48
+ * carries it, so it groups a subtree and never bounds one. Callers that want
49
+ * "same instance?" (the constraint stack's own-parts walk, the adapter's owner
50
+ * chain) want THIS; callers that want "is this the instance's row" want the
51
+ * predicate above.
52
+ */
53
+ export function instanceStampOf(object: THREE.Object3D | null | undefined): string | undefined {
54
+ const instance = getUserData(object, 'authoringInstance');
55
+ return typeof instance === 'string' && instance ? instance : undefined;
56
+ }
57
+
58
+ /**
59
+ * The element's OWN definition-side oid — where this JSX element is WRITTEN, as
60
+ * opposed to the callsite that instantiated whatever renders it.
61
+ *
62
+ * `oid` is stamped by the source-authoring integration's serving plugin on every host element, so it is
63
+ * present on a component's root element and on each of its interior ones alike.
64
+ * It is NOT an address: at an instance root the address is the callsite
65
+ * ({@link authoringOidOf}). Read it when the DEFINITION is the subject —
66
+ * "Fork Component…" copying the component, or the occurrence rule below asking
67
+ * which definition element a live object renders.
68
+ */
69
+ export function ownOidOf(
70
+ /** An `Object3D`, or a MATERIAL — fiber can pierce a stamp onto a
71
+ * `<meshStandardMaterial userData-oid=…>` child element, and a material
72
+ * carries `userData` without being a scene-graph node, so that stamp is its
73
+ * only handle. Structural, so both pass without this module knowing which. */
74
+ stamped: { readonly userData?: Record<string, unknown> } | null | undefined,
75
+ ): string | undefined {
76
+ const own = stamped?.userData?.['oid'];
77
+ return typeof own === 'string' && own ? own : undefined;
78
+ }
79
+
80
+ /** The callsite oid `object` is the ROOT of, or `undefined`. */
81
+ function instanceCallsiteOid(object: THREE.Object3D | null | undefined): string | undefined {
82
+ const instance = instanceStampOf(object);
83
+ if (instance === undefined) return undefined;
84
+ return getUserData(object?.parent, 'authoringInstance') === instance ? undefined : instance;
85
+ }
86
+
87
+ /** A third-party R3F component cannot be instrumented inside its package, but
88
+ * many ecosystem components forward unknown props to their native host root.
89
+ * The editor-injected callsite prop therefore lands directly on the resulting
90
+ * Object3D (Drei cameras are the canonical example). It is a callsite address,
91
+ * not the object's definition-side `userData.oid`. */
92
+ function forwardedCallsiteOid(object: THREE.Object3D | null | undefined): string | undefined {
93
+ const forwarded = (object as (THREE.Object3D & { __vgaiOid?: unknown }) | null | undefined)
94
+ ?.__vgaiOid;
95
+ return typeof forwarded === 'string' && forwarded ? forwarded : undefined;
96
+ }
97
+
98
+ /**
99
+ * THE OCCURRENCE CONVENTION — one source address, several live objects.
100
+ *
101
+ * A single JSX element can render more than once (a `.map()` over 44 spawn
102
+ * points instantiates ONE `<Coin/>` callsite 44 times), and a component's
103
+ * interior element can be reparented out of its instance's subtree so that it,
104
+ * too, resolves to the callsite oid. Either way the adapters' walks meet the
105
+ * same oid twice, and both mint the same shape of id: the first one seen in
106
+ * depth-first order keeps the bare address, later ones get `#1`, `#2`, ….
107
+ *
108
+ * The suffix is spelled HERE and nowhere else. It was previously written out at
109
+ * four sites — two minting walks (`projection/three.ts`,
110
+ * `R3fSourceAuthoringAdapter.indexGraph`), one authority check, and one
111
+ * presence report that stripped it back off with `split('#')[0]` — which is how
112
+ * a convention becomes four conventions.
113
+ */
114
+ export function occurrenceId(baseId: string, occurrence: number): string {
115
+ return occurrence === 0 ? baseId : `${baseId}#${occurrence}`;
116
+ }
117
+
118
+ /** True for `…#1`, `…#2` — an id that is NOT its address's first occurrence. */
119
+ export function isOccurrenceId(id: string): boolean {
120
+ return id.includes('#');
121
+ }
122
+
123
+ /** `id` with any occurrence suffix removed — the address all its occurrences share. */
124
+ export function withoutOccurrence(id: string): string {
125
+ const hash = id.indexOf('#');
126
+ return hash < 0 ? id : id.slice(0, hash);
127
+ }
128
+
129
+ /**
130
+ * Whether two live objects are renders of THE SAME JSX element.
131
+ *
132
+ * This is the CAPABILITY question behind an occurrence id, and it is the whole
133
+ * reason the two causes above must not be treated alike. N objects that share
134
+ * one callsite AND render one definition element are N renders of one element;
135
+ * the element is the unit of edit, so a literal prop on it is honestly
136
+ * writable and moving one moves all — which is exactly what the source says. A
137
+ * reparented interior element renders a DIFFERENT definition element, merely
138
+ * addressed by the callsite it escaped from, so writing that callsite's props
139
+ * would edit the instance instead of the object under the pointer.
140
+ *
141
+ * Both objects having no own oid counts as the same element: an unstamped pair
142
+ * is a component with several spatial roots (R3F003), which the callsite's own
143
+ * authoring contract already refuses on better grounds than a guess here.
144
+ */
145
+ export function rendersSameSourceElement(
146
+ a: THREE.Object3D | null | undefined,
147
+ b: THREE.Object3D | null | undefined,
148
+ ): boolean {
149
+ return ownOidOf(a) === ownOidOf(b);
150
+ }
151
+
152
+ /**
153
+ * The oid that ADDRESSES `object` in source — the key every id, source
154
+ * location and prop write resolves through.
155
+ *
156
+ * At an instance ROOT it is the CALLSITE oid, because that is where the author
157
+ * put this instance and where a gizmo drag must land: moving `Coin1` rewrites
158
+ * `<Coin name='Coin1' position={…}/>` in the parent scene, not the shared
159
+ * `<Coin/>` definition every coin renders from.
160
+ *
161
+ * ANYWHERE ELSE it is the element's OWN definition oid. An interior host
162
+ * element of a component carries the instance stamp too (that is how a click
163
+ * on it resolves to the instance), but the stamp is not its address: reading
164
+ * it as one gave every interior node of `Stage` the id
165
+ * `r3f:<world>:<Stage's callsite>#N` — a position among `Stage`'s stamped
166
+ * elements rather than a name — and pointed each one's source location and
167
+ * prop writes at the `<Stage/>` callsite instead of at itself.
168
+ */
169
+ export function authoringOidOf(object: THREE.Object3D | null | undefined): string | undefined {
170
+ return instanceCallsiteOid(object) ?? forwardedCallsiteOid(object) ?? ownOidOf(object);
171
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * THE DESIGN-TIME SETTLE, and the only place its constants are spelled.
3
+ *
4
+ * This is deliberately NOT an interactive-document boot step. Scene and Asset
5
+ * Lab documents show authored state immediately and advance content only when
6
+ * the user starts an explicit simulation or animation transport. A hidden
7
+ * settle before reveal violated Edit ≠ Play and made a complex local scene wait
8
+ * up to four seconds for construction quiescence plus 90 invisible steps.
9
+ *
10
+ * The remaining callers are deliberate off-screen preview/capture operations
11
+ * that ask for a derived rest pose. That is why {@link settleDesignWorld} takes
12
+ * a {@link SettleTarget} rather than a viewport type: the seam is "a world with
13
+ * a host-driven tick," while the caller owns the decision to simulate.
14
+ *
15
+ * **The settle is a PURE FUNCTION OF SOURCE.** It never reads accumulated
16
+ * state, never persists a result, and is never a second truth beside the
17
+ * source: every mount re-derives the world from the file and re-runs the same
18
+ * bounded simulation from the same authored spawn. That is what makes the two
19
+ * interaction contracts hold — a gizmo commit writes the JSX literal, the
20
+ * session re-derives from source, and the settle re-runs from the EDIT's
21
+ * position (the edit wins, nothing snaps back); and two mounts of identical
22
+ * source produce the same rest state.
23
+ *
24
+ * ## THE SEAM IT DRIVES, and why it is not a physics API
25
+ *
26
+ * `MountedThreeRoot.update(dt)` — the world's own host-driven tick, the exact
27
+ * one `runFrameImpl` (`@volter/editor-game/runtime/game`) calls at play time. For an R3F
28
+ * world that is `editor-game/src/host/roots/r3f-root.tsx`'s `update()`: it runs the
29
+ * engine phases and then `advance(elapsed, true, state)`, which under
30
+ * `frameloop: 'never'` is what runs every `useFrame` subscriber.
31
+ *
32
+ * A physics integration is one of those subscribers, so driving `update()`
33
+ * drives the world's physics THROUGH THE WORLD'S OWN DECLARATION — including
34
+ * whatever gravity/iterations/solver props its own `<Physics>` element
35
+ * carries. Worked example, `@react-three/cannon@6.6.0`: its provider registers
36
+ * `useFrame(loop)` (`node_modules/@react-three/cannon/dist/index.js:12166`)
37
+ * and `loop` posts `worker.step({ maxSubSteps, stepSize, timeSinceLastCalled })`
38
+ * (`:12032`) to its own Web Worker. Nothing here knows any of that, and
39
+ * nothing here should: the editor reaching for a named physics library's step
40
+ * API would be an editor that only settles the physics engines it has heard
41
+ * of.
42
+ *
43
+ * ## WHY IT IS ASYNC, and why it steps in ROUNDS
44
+ *
45
+ * That worker is OFF-THREAD. `worker.step()` posts a message; the resulting
46
+ * pose arrives on a later task and is applied to `object.matrix` from the
47
+ * worker's own `frame` handler (`:12120`). A synchronous burst of `update()`
48
+ * calls would therefore measure nothing but the spawn pose. So the settle
49
+ * yields to the event loop, and it does so once per ROUND of
50
+ * {@link SETTLE_STEPS_PER_ROUND} steps rather than once per step: the worker
51
+ * queue is FIFO, so a round's steps pipeline and its frames land together,
52
+ * which costs one event-loop turn per round instead of one per step.
53
+ *
54
+ * ## WHY REST IS ONLY DECLARED AFTER MOTION
55
+ *
56
+ * The naive rest test — "the scene did not move this round, so we are done" —
57
+ * is TRUE at round one for the exact reason the settle exists: an off-thread
58
+ * physics engine has not answered yet, so nothing has moved YET. Declaring
59
+ * rest there would settle to the spawn pose and call it a rest state. Rest is
60
+ * therefore gated on having OBSERVED motion first; a world that never moves
61
+ * under simulation ends at {@link SETTLE_MAX_ROUNDS} with reason `'still'`,
62
+ * which costs it that many event-loop turns and nothing else.
63
+ *
64
+ * Non-finite poses are the same class of not-yet-answered and are handled the
65
+ * same way: `@react-three/cannon` writes `object.matrix` from worker buffers
66
+ * that are unwritten at frame zero, so two of racing-game's meshes are
67
+ * measurably `NaN` before the first frame lands (see `content-bounds.ts`'s own
68
+ * measurement of exactly this). A round whose sample is non-finite is
69
+ * NOT COMPARABLE: it resets the rest run and is never counted as motion.
70
+ */
71
+
72
+ import type * as THREE from 'three';
73
+
74
+ /**
75
+ * What a settle needs from a world, and nothing more: the graph to measure and
76
+ * the world's OWN host-driven tick to advance. `MountedThreeRoot` satisfies it
77
+ * structurally, and so does every other design-time surface's source (a story
78
+ * mount's `advance`, a project contribution's `build().update`) — which is the
79
+ * point: one settle, one shape, no per-surface copy.
80
+ */
81
+ export interface SettleTarget {
82
+ /** The subtree whose motion is measured. A whole `THREE.Scene` for a mounted
83
+ * root; a story's own wrapper group for an off-screen story mount. */
84
+ readonly scene: THREE.Object3D;
85
+ /** The world's own tick. Absent ⇒ nothing to advance (`'unsupported'`). */
86
+ readonly update?: ((dt: number) => void) | undefined;
87
+ /**
88
+ * Whether this mount has an off-thread physics stepper subscribed (a
89
+ * `<Physics>` provider, a cannon worker, a first-party adapter).
90
+ *
91
+ * The quiet-wait and still-rounds exist because that stepper is SILENT at
92
+ * spawn: declaring rest before it answers would freeze a vehicle at a pose
93
+ * the game never has. A tree, a jump pad, a mesh with no physics has
94
+ * nothing that can move later, so both waits are wasted — pass `false` and
95
+ * the settle returns immediately. Omitted is the SAFE default (settle):
96
+ * callers that have not looked must not skip a rigid body.
97
+ */
98
+ readonly hasPhysicsSubscriber?: boolean;
99
+ }
100
+
101
+ /** The simulated timestep each settle step advances — the engine's own fixed
102
+ * timestep (`core/game-loop.ts`'s `fixedTimestep ?? 1/60`), so a settle step
103
+ * is the same quantum a play frame is. */
104
+ export const SETTLE_STEP_SECONDS = 1 / 60;
105
+
106
+ /** Steps advanced between event-loop yields. 6 steps = 0.1 simulated second —
107
+ * enough that an off-thread physics worker's frames for the round have
108
+ * something to show, small enough that rest is detected within 0.1s of it
109
+ * happening. */
110
+ export const SETTLE_STEPS_PER_ROUND = 6;
111
+
112
+ /** Hard budget: 15 rounds = 90 steps = **1.5 simulated seconds**. A body
113
+ * dropped onto its own suspension or resolved out of an interpenetration
114
+ * reaches rest well inside that; a world that is still moving at 1.5s is
115
+ * animating rather than settling, and Edit mode owes it a freeze, not a
116
+ * longer run. */
117
+ export const SETTLE_MAX_ROUNDS = 15;
118
+
119
+ /** Rest threshold: world units moved by the FASTEST-moving node across one
120
+ * round. 1e-4 units per 0.1s is 1 millimetre per second in a metres-scaled
121
+ * world — below it, nothing a viewer or a capture can distinguish is
122
+ * happening. */
123
+ export const SETTLE_REST_EPSILON = 1e-4;
124
+
125
+ /** Consecutive quiet rounds required before rest is declared, so a body
126
+ * pausing at the top of a bounce is not mistaken for a rest state. */
127
+ export const SETTLE_REST_ROUNDS = 2;
128
+
129
+ /** After the last step, event-loop turns awaited for an off-thread physics
130
+ * engine's in-flight frames to land. This is what makes "then FREEZES" true
131
+ * rather than asserted: the settle does not return while a pose it caused is
132
+ * still on its way. */
133
+ export const SETTLE_DRAIN_TURNS = 10;
134
+
135
+ /**
136
+ * How long the scene graph must stop CHANGING SHAPE before the settle starts
137
+ * stepping, and the hard ceiling on waiting for that.
138
+ *
139
+ * WHY THIS EXISTS, measured. A world's `mount()` resolves at fiber's first
140
+ * commit, which is NOT the end of construction: suspended asset loads
141
+ * (racing-game's Draco chassis/track GLBs and its HDR environment) add nodes —
142
+ * including physics bodies — for as long as they take. Step the world during
143
+ * that window and a body created at round 3 gets 12 fewer steps than one
144
+ * created at round 0, so the SAME source settles differently depending on how
145
+ * warm the HTTP cache was. Measured directly: two boots of unchanged
146
+ * racing-game source produced chassis Y = 0.7549 and 0.7927.
147
+ *
148
+ * So the settle waits for the world to stop being BUILT before it simulates.
149
+ * Node count is the signal (an asset resolving adds nodes; a texture arriving
150
+ * does not change the count and does not change physics either). Both bounds
151
+ * are wall-clock because what is being waited on is I/O, not frames.
152
+ */
153
+ export const SETTLE_BUILD_STABLE_MS = 250;
154
+ export const SETTLE_BUILD_TIMEOUT_MS = 4000;
155
+
156
+ /** Why the settle ended. */
157
+ export type SettleOutcome =
158
+ /** The world moved and then came to rest. */
159
+ | 'rest'
160
+ /** The world was still moving when the step budget ran out. */
161
+ | 'budget'
162
+ /** Nothing in the world ever moved under simulation. */
163
+ | 'still'
164
+ /** The world is not host-driven — there is no `update(dt)` to advance. */
165
+ | 'unsupported';
166
+
167
+ export interface SettleReport {
168
+ readonly outcome: SettleOutcome;
169
+ /** Simulation steps actually advanced. */
170
+ readonly steps: number;
171
+ /** World units the fastest node moved across the final measured round. */
172
+ readonly lastMotion: number;
173
+ /** Milliseconds spent waiting for the world to stop being BUILT, and whether
174
+ * that wait ended because the graph went quiet (`true`) or because it hit
175
+ * {@link SETTLE_BUILD_TIMEOUT_MS} (`false` — the settle then runs against a
176
+ * world still loading, and its result is honestly not reproducible). */
177
+ readonly buildWaitMs: number;
178
+ readonly buildSettled: boolean;
179
+ }
180
+
181
+ /** Injectables. Real callers pass nothing; a test drives the loop and the
182
+ * clock so the wall-clock bounds above cost it no wall clock. */
183
+ export interface SettleHooks {
184
+ yieldTurn?: () => Promise<void>;
185
+ now?: () => number;
186
+ }
187
+
188
+ /** Every node's world position, flattened. Length changes (an async model
189
+ * resolving mid-settle) make two samples NOT COMPARABLE, which is the honest
190
+ * answer rather than a wrong distance. */
191
+ function samplePositions(scene: THREE.Object3D): number[] {
192
+ scene.updateMatrixWorld(true);
193
+ const out: number[] = [];
194
+ scene.traverse((object) => {
195
+ const e = object.matrixWorld.elements;
196
+ out.push(e[12] as number, e[13] as number, e[14] as number);
197
+ });
198
+ return out;
199
+ }
200
+
201
+ /** How many nodes the world currently has — the build-quiesce signal. */
202
+ function countNodes(scene: THREE.Object3D): number {
203
+ let n = 0;
204
+ scene.traverse(() => {
205
+ n++;
206
+ });
207
+ return n;
208
+ }
209
+
210
+ /**
211
+ * The largest distance any node moved between two samples, or `NaN` when the
212
+ * two are not comparable (different node counts, or a pose that is not a
213
+ * number yet).
214
+ */
215
+ function maxMotion(before: number[], after: number[]): number {
216
+ if (before.length !== after.length) return Number.NaN;
217
+ let max = 0;
218
+ for (let i = 0; i < after.length; i += 3) {
219
+ const dx = (after[i] as number) - (before[i] as number);
220
+ const dy = (after[i + 1] as number) - (before[i + 1] as number);
221
+ const dz = (after[i + 2] as number) - (before[i + 2] as number);
222
+ const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
223
+ if (!Number.isFinite(d)) return Number.NaN;
224
+ if (d > max) max = d;
225
+ }
226
+ return max;
227
+ }
228
+
229
+ /** One event-loop turn — a MACROtask, because an off-thread worker's message
230
+ * cannot land in a microtask drain. */
231
+ const nextTurn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
232
+
233
+ /**
234
+ * Block until the world's node count has held still for
235
+ * {@link SETTLE_BUILD_STABLE_MS}, or {@link SETTLE_BUILD_TIMEOUT_MS} runs out.
236
+ * See those constants for the measurement that put this phase here.
237
+ */
238
+ async function awaitConstructionQuiesced(
239
+ scene: THREE.Object3D,
240
+ yieldTurn: () => Promise<void>,
241
+ now: () => number,
242
+ ): Promise<{ buildWaitMs: number; buildSettled: boolean }> {
243
+ const start = now();
244
+ let nodes = countNodes(scene);
245
+ let quietSince = start;
246
+ let buildSettled = false;
247
+ while (now() - start < SETTLE_BUILD_TIMEOUT_MS) {
248
+ if (now() - quietSince >= SETTLE_BUILD_STABLE_MS) {
249
+ buildSettled = true;
250
+ break;
251
+ }
252
+ await yieldTurn();
253
+ const next = countNodes(scene);
254
+ if (next !== nodes) {
255
+ nodes = next;
256
+ quietSince = now();
257
+ }
258
+ }
259
+ return { buildWaitMs: now() - start, buildSettled };
260
+ }
261
+
262
+ function idleSettleReport(outcome: 'unsupported' | 'still'): SettleReport {
263
+ return { outcome, steps: 0, lastMotion: 0, buildWaitMs: 0, buildSettled: true };
264
+ }
265
+
266
+ /**
267
+ * The narrow skip: no physics subscriber means nothing can move after spawn,
268
+ * so the construction quiet-wait and the still-rounds are both wasted. Omitted
269
+ * is treated as "yes, settle" — a caller that has not looked must not skip a
270
+ * rigid body. The predicate lives here so a revert (always-skip, or treating
271
+ * omitted as skip) reds the physics rest test.
272
+ */
273
+ export function designWorldHasPhysicsSubscriber(root: SettleTarget): boolean {
274
+ return root.hasPhysicsSubscriber !== false;
275
+ }
276
+
277
+ /**
278
+ * Derive a rest pose for a caller that explicitly requested one, then leave the
279
+ * world frozen. Advances the world's OWN `update(dt)` — see this module's
280
+ * header for why that is the seam and why the loop is shaped the way it is.
281
+ */
282
+ export async function settleDesignWorld(
283
+ root: SettleTarget,
284
+ hooks: SettleHooks = {},
285
+ ): Promise<SettleReport> {
286
+ const yieldTurn = hooks.yieldTurn ?? nextTurn;
287
+ const now = hooks.now ?? (() => Date.now());
288
+ const update = root.update?.bind(root);
289
+ if (!update) return idleSettleReport('unsupported');
290
+ if (!designWorldHasPhysicsSubscriber(root)) return idleSettleReport('still');
291
+
292
+ // ---- 1. wait for the world to stop being BUILT
293
+ const { buildWaitMs, buildSettled } = await awaitConstructionQuiesced(root.scene, yieldTurn, now);
294
+
295
+ // ---- 2. the bounded settle itself
296
+ let previous = samplePositions(root.scene);
297
+ let steps = 0;
298
+ let quietRounds = 0;
299
+ let observedMotion = false;
300
+ let lastMotion = 0;
301
+ let outcome: SettleOutcome = 'still';
302
+
303
+ for (let round = 0; round < SETTLE_MAX_ROUNDS; round++) {
304
+ for (let step = 0; step < SETTLE_STEPS_PER_ROUND; step++) {
305
+ update(SETTLE_STEP_SECONDS);
306
+ steps++;
307
+ }
308
+ await yieldTurn();
309
+ const current = samplePositions(root.scene);
310
+ const motion = maxMotion(previous, current);
311
+ previous = current;
312
+ if (!Number.isFinite(motion)) {
313
+ // Not comparable — the world is mid-answer. Never rest, never motion.
314
+ quietRounds = 0;
315
+ continue;
316
+ }
317
+ lastMotion = motion;
318
+ if (motion > SETTLE_REST_EPSILON) {
319
+ observedMotion = true;
320
+ quietRounds = 0;
321
+ outcome = 'budget';
322
+ continue;
323
+ }
324
+ quietRounds++;
325
+ if (observedMotion && quietRounds >= SETTLE_REST_ROUNDS) {
326
+ outcome = 'rest';
327
+ break;
328
+ }
329
+ }
330
+
331
+ // ---- 3. FREEZE. Nothing is stepped from here; the drain only waits out
332
+ // poses this settle already caused, so the scene the editor draws from now
333
+ // on is a real rest state rather than one still being written into.
334
+ for (let turn = 0; turn < SETTLE_DRAIN_TURNS; turn++) {
335
+ await yieldTurn();
336
+ const current = samplePositions(root.scene);
337
+ const motion = maxMotion(previous, current);
338
+ previous = current;
339
+ if (Number.isFinite(motion) && motion <= SETTLE_REST_EPSILON) break;
340
+ }
341
+
342
+ return { outcome, steps, lastMotion, buildWaitMs, buildSettled };
343
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Reading the transform of a LIVE `Object3D` the editor did not write.
3
+ *
4
+ * THE TRAP, measured on the racing-game ingest (2026-08-15). A physics library
5
+ * that drives a node writes the node's MATRIX and turns matrix composition off:
6
+ * `@react-three/cannon`'s frame handler is literally
7
+ * `object.matrixAutoUpdate = false; object.matrix.copy(m)`. Three never
8
+ * decomposes that back, so `object.position`/`.quaternion`/`.scale` keep
9
+ * whatever they held when the body was created — forever. Everything derived
10
+ * from `matrixWorld` (selection brackets, bounds, three's own
11
+ * `TransformControls`) tracks the truth, while everything derived from
12
+ * `.position` shows the spawn point. Measured side by side: the driven chassis
13
+ * reported `[-110, 0.75, 220]` in the Inspector while the game's own state
14
+ * provider put it at `(-24.5, 0.85, 186.2)`.
15
+ *
16
+ * That mismatch is worse than a wrong number: origin markers and gizmo anchors
17
+ * derived from `.position` sit somewhere the object is not, while the bounding
18
+ * box around them is correct, so the editor contradicts itself on screen.
19
+ *
20
+ * THE RULE: **when a node owns its own matrix (`matrixAutoUpdate === false`),
21
+ * the MATRIX is the transform.** Decompose it — through three's own
22
+ * `Matrix4.decompose`, never a re-derivation. Otherwise the vector fields are
23
+ * authoritative and are read directly: deliberately not "always decompose",
24
+ * because for an ordinary node `matrix` is one frame BEHIND a write the editor
25
+ * just made, and reading it back would make a gizmo drag stutter.
26
+ *
27
+ * Nothing here is library-specific: the predicate is three's own flag, so any
28
+ * driver following the same convention (cannon, a custom integrator, an
29
+ * animation system baking matrices) is covered without naming it.
30
+ */
31
+
32
+ import * as THREE from 'three';
33
+
34
+ export interface LocalTransformRead {
35
+ readonly position: [number, number, number];
36
+ readonly quaternion: [number, number, number, number];
37
+ readonly scale: [number, number, number];
38
+ }
39
+
40
+ const _position = new THREE.Vector3();
41
+ const _quaternion = new THREE.Quaternion();
42
+ const _scale = new THREE.Vector3();
43
+
44
+ /**
45
+ * The node's LOCAL transform, from whichever of the two representations it
46
+ * actually maintains.
47
+ */
48
+ export function localTransformOf(object: THREE.Object3D): LocalTransformRead {
49
+ if (object.matrixAutoUpdate === false) {
50
+ object.matrix.decompose(_position, _quaternion, _scale);
51
+ return {
52
+ position: _position.toArray() as [number, number, number],
53
+ quaternion: _quaternion.toArray() as [number, number, number, number],
54
+ scale: _scale.toArray() as [number, number, number],
55
+ };
56
+ }
57
+ return {
58
+ position: object.position.toArray() as [number, number, number],
59
+ quaternion: object.quaternion.toArray() as [number, number, number, number],
60
+ scale: object.scale.toArray() as [number, number, number],
61
+ };
62
+ }