tosijs-3d 0.7.2 → 0.7.5

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 (175) hide show
  1. package/CHANGELOG.md +998 -0
  2. package/dist/b3d-aircraft.d.ts +14 -0
  3. package/dist/b3d-aircraft.d.ts.map +1 -1
  4. package/dist/b3d-aircraft.js +45 -0
  5. package/dist/b3d-aircraft.js.map +1 -1
  6. package/dist/b3d-ambient.d.ts +12 -0
  7. package/dist/b3d-ambient.d.ts.map +1 -1
  8. package/dist/b3d-ambient.js +83 -1
  9. package/dist/b3d-ambient.js.map +1 -1
  10. package/dist/b3d-biped.d.ts +290 -0
  11. package/dist/b3d-biped.d.ts.map +1 -1
  12. package/dist/b3d-biped.js +1366 -22
  13. package/dist/b3d-biped.js.map +1 -1
  14. package/dist/b3d-collisions.d.ts.map +1 -1
  15. package/dist/b3d-collisions.js +45 -7
  16. package/dist/b3d-collisions.js.map +1 -1
  17. package/dist/b3d-controllable.d.ts +50 -0
  18. package/dist/b3d-controllable.d.ts.map +1 -1
  19. package/dist/b3d-controllable.js +61 -0
  20. package/dist/b3d-controllable.js.map +1 -1
  21. package/dist/b3d-death.d.ts +26 -0
  22. package/dist/b3d-death.d.ts.map +1 -1
  23. package/dist/b3d-death.js +224 -2
  24. package/dist/b3d-death.js.map +1 -1
  25. package/dist/b3d-exploder.js +1 -1
  26. package/dist/b3d-galaxy.js +1 -1
  27. package/dist/b3d-hud.js +1 -1
  28. package/dist/b3d-hud.js.map +1 -1
  29. package/dist/b3d-interactive.d.ts +80 -0
  30. package/dist/b3d-interactive.d.ts.map +1 -0
  31. package/dist/b3d-interactive.js +284 -0
  32. package/dist/b3d-interactive.js.map +1 -0
  33. package/dist/b3d-library.d.ts +39 -0
  34. package/dist/b3d-library.d.ts.map +1 -1
  35. package/dist/b3d-library.js +68 -1
  36. package/dist/b3d-library.js.map +1 -1
  37. package/dist/b3d-panel.js +1 -1
  38. package/dist/b3d-panel.js.map +1 -1
  39. package/dist/b3d-physics.js +1 -1
  40. package/dist/b3d-sound.js +1 -1
  41. package/dist/b3d-svg-plane.d.ts +35 -0
  42. package/dist/b3d-svg-plane.d.ts.map +1 -1
  43. package/dist/b3d-svg-plane.js +194 -9
  44. package/dist/b3d-svg-plane.js.map +1 -1
  45. package/dist/b3d-terrain.js +1 -1
  46. package/dist/b3d-water.d.ts.map +1 -1
  47. package/dist/b3d-water.js +32 -6
  48. package/dist/b3d-water.js.map +1 -1
  49. package/dist/buoyancy.d.ts +90 -0
  50. package/dist/buoyancy.d.ts.map +1 -0
  51. package/dist/buoyancy.js +150 -0
  52. package/dist/buoyancy.js.map +1 -0
  53. package/dist/curve-field.d.ts +39 -0
  54. package/dist/curve-field.d.ts.map +1 -0
  55. package/dist/curve-field.js +491 -0
  56. package/dist/curve-field.js.map +1 -0
  57. package/dist/curve.d.ts +241 -0
  58. package/dist/curve.d.ts.map +1 -0
  59. package/dist/curve.js +559 -0
  60. package/dist/curve.js.map +1 -0
  61. package/dist/dialog-placement.d.ts +69 -7
  62. package/dist/dialog-placement.d.ts.map +1 -1
  63. package/dist/dialog-placement.js +91 -10
  64. package/dist/dialog-placement.js.map +1 -1
  65. package/dist/embed-font.d.ts +48 -0
  66. package/dist/embed-font.d.ts.map +1 -0
  67. package/dist/embed-font.js +135 -0
  68. package/dist/embed-font.js.map +1 -0
  69. package/dist/footprint-field.d.ts +30 -0
  70. package/dist/footprint-field.d.ts.map +1 -0
  71. package/dist/footprint-field.js +259 -0
  72. package/dist/footprint-field.js.map +1 -0
  73. package/dist/frame-panel.d.ts.map +1 -1
  74. package/dist/frame-panel.js +15 -12
  75. package/dist/frame-panel.js.map +1 -1
  76. package/dist/glb-manifest.d.ts +75 -0
  77. package/dist/glb-manifest.d.ts.map +1 -0
  78. package/dist/glb-manifest.js +198 -0
  79. package/dist/glb-manifest.js.map +1 -0
  80. package/dist/icon-data.d.ts.map +1 -1
  81. package/dist/icon-data.js +20 -1
  82. package/dist/icon-data.js.map +1 -1
  83. package/dist/icon-grid.d.ts +66 -0
  84. package/dist/icon-grid.d.ts.map +1 -0
  85. package/dist/icon-grid.js +430 -0
  86. package/dist/icon-grid.js.map +1 -0
  87. package/dist/index.d.ts +37 -4
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +43 -3
  90. package/dist/index.js.map +1 -1
  91. package/dist/interaction.d.ts +66 -0
  92. package/dist/interaction.d.ts.map +1 -0
  93. package/dist/interaction.js +117 -0
  94. package/dist/interaction.js.map +1 -0
  95. package/dist/interactive-behavior.d.ts +105 -0
  96. package/dist/interactive-behavior.d.ts.map +1 -0
  97. package/dist/interactive-behavior.js +253 -0
  98. package/dist/interactive-behavior.js.map +1 -0
  99. package/dist/key-layout.d.ts +112 -1
  100. package/dist/key-layout.d.ts.map +1 -1
  101. package/dist/key-layout.js +157 -6
  102. package/dist/key-layout.js.map +1 -1
  103. package/dist/keyboard-gamepad.d.ts.map +1 -1
  104. package/dist/keyboard-gamepad.js +19 -6
  105. package/dist/keyboard-gamepad.js.map +1 -1
  106. package/dist/keyboard.d.ts +131 -1
  107. package/dist/keyboard.d.ts.map +1 -1
  108. package/dist/keyboard.js +653 -40
  109. package/dist/keyboard.js.map +1 -1
  110. package/dist/mantle.d.ts +56 -0
  111. package/dist/mantle.d.ts.map +1 -0
  112. package/dist/mantle.js +115 -0
  113. package/dist/mantle.js.map +1 -0
  114. package/dist/popup-chrome.d.ts +8 -0
  115. package/dist/popup-chrome.d.ts.map +1 -1
  116. package/dist/popup-chrome.js +32 -5
  117. package/dist/popup-chrome.js.map +1 -1
  118. package/dist/popup-surface.d.ts +5 -1
  119. package/dist/popup-surface.d.ts.map +1 -1
  120. package/dist/popup-surface.js +199 -17
  121. package/dist/popup-surface.js.map +1 -1
  122. package/dist/rounded-rect.js +1 -1
  123. package/dist/selection.js +1 -1
  124. package/dist/selection.js.map +1 -1
  125. package/dist/surface.d.ts.map +1 -1
  126. package/dist/surface.js +111 -15
  127. package/dist/surface.js.map +1 -1
  128. package/dist/svg-icons.d.ts.map +1 -1
  129. package/dist/svg-icons.js +14 -2
  130. package/dist/svg-icons.js.map +1 -1
  131. package/dist/svg-texture.d.ts.map +1 -1
  132. package/dist/svg-texture.js +63 -2
  133. package/dist/svg-texture.js.map +1 -1
  134. package/dist/swim-aim.d.ts +78 -0
  135. package/dist/swim-aim.d.ts.map +1 -0
  136. package/dist/swim-aim.js +140 -0
  137. package/dist/swim-aim.js.map +1 -0
  138. package/dist/table.js +1 -1
  139. package/dist/table.js.map +1 -1
  140. package/dist/theme-editor.d.ts +41 -0
  141. package/dist/theme-editor.d.ts.map +1 -0
  142. package/dist/theme-editor.js +303 -0
  143. package/dist/theme-editor.js.map +1 -0
  144. package/dist/tosi-b3d.d.ts +24 -4
  145. package/dist/tosi-b3d.d.ts.map +1 -1
  146. package/dist/tosi-b3d.js +247 -38
  147. package/dist/tosi-b3d.js.map +1 -1
  148. package/dist/vector-field.d.ts +58 -0
  149. package/dist/vector-field.d.ts.map +1 -0
  150. package/dist/vector-field.js +350 -0
  151. package/dist/vector-field.js.map +1 -0
  152. package/dist/virtual-gamepad.d.ts.map +1 -1
  153. package/dist/virtual-gamepad.js +46 -9
  154. package/dist/virtual-gamepad.js.map +1 -1
  155. package/dist/w3d-theme.d.ts +115 -17
  156. package/dist/w3d-theme.d.ts.map +1 -1
  157. package/dist/w3d-theme.js +312 -1
  158. package/dist/w3d-theme.js.map +1 -1
  159. package/dist/water-normal.d.ts +26 -0
  160. package/dist/water-normal.d.ts.map +1 -0
  161. package/dist/water-normal.js +105 -0
  162. package/dist/water-normal.js.map +1 -0
  163. package/dist/widgets3d-layout.d.ts +58 -0
  164. package/dist/widgets3d-layout.d.ts.map +1 -1
  165. package/dist/widgets3d-layout.js +79 -1
  166. package/dist/widgets3d-layout.js.map +1 -1
  167. package/dist/widgets3d.d.ts +188 -2
  168. package/dist/widgets3d.d.ts.map +1 -1
  169. package/dist/widgets3d.js +744 -116
  170. package/dist/widgets3d.js.map +1 -1
  171. package/dist/wreck-fall.d.ts +86 -0
  172. package/dist/wreck-fall.d.ts.map +1 -0
  173. package/dist/wreck-fall.js +129 -0
  174. package/dist/wreck-fall.js.map +1 -0
  175. package/package.json +9 -6
package/CHANGELOG.md CHANGED
@@ -6,6 +6,1004 @@ All notable changes to **tosijs-3d**. This project is pre-1.0 (`0.x`), so minor
6
6
  versions may carry breaking peer-dependency changes — each is called out in a
7
7
  **⚠️ Breaking** block in its version section below, with what a consumer must do.
8
8
 
9
+ ## 0.7.5
10
+
11
+ ### Added
12
+
13
+ - **`iconGrid3d` — segmented select, tool palette and mode picker in one.**
14
+ Three widgets collapse into one because the difference between them was never
15
+ layout, it was **semantics**: the grid owns layout, hit-testing and focus, and
16
+ `handleChange` lets you impose your own rule. It receives what *would* happen
17
+ and returns what should; returning `previous` is the veto, because one way to
18
+ say no is easier to get right than two.
19
+
20
+ Cell size is two cases — 48px touch, 24px pointer, from `(pointer: coarse)`.
21
+ **XR is deliberately not a third size**: growing targets in UI units leaves the
22
+ glyphs where they are and inflates the buttons around them, buying hit accuracy
23
+ by making the panel harder to read. Scale the panel in world space instead and
24
+ both move together.
25
+
26
+ - **An on-screen keyboard you can summon.** `inputField` draws a ⌨ affordance,
27
+ and it is a **toggle** for a shared preference rather than a one-shot: you are
28
+ not asking for a keyboard once, you are saying that reaching a real one is
29
+ inconvenient, and that stays true for the next field. Auto-open follows the
30
+ same preference (`autoKeyboardEnabled`/`setAutoKeyboard`, both exported).
31
+
32
+ The glyph is always drawn because the DEVICE cannot tell you whether a keyboard
33
+ is within REACH — a console, a laptop docked to a screen across the room, a
34
+ headset with a desk under it. Sniffing gets all of those wrong in the same
35
+ direction.
36
+
37
+ **Caps lock by holding shift.** A tap stays one-shot; a hold locks; a tap while
38
+ locked unlocks. The key tints accent while locked, because a mode you cannot
39
+ see is a mode you will be surprised by.
40
+
41
+ - **Popups can escape their panel — a real layer, in both presentations.**
42
+ `panel3d.useDomLayer(container)` mounts flat popups as positioned siblings
43
+ outside the panel's `<svg>`; `panelScene` mounts them as their own plane, with
44
+ genuine z-separation. A layer belongs to a PRESENTATION, and there are two of
45
+ them, so `showLayer` fans out and closing closes both — one popup wearing two
46
+ faces, exactly as the panel already is.
47
+
48
+ - **`data-presentation` — one UI, two appearances.** Mark a node `dom` or
49
+ `texture`: `svg-texture` strips `dom`-only nodes when it rasterises, and the
50
+ DOM layer hides `texture`-only ones by stylesheet (a serialised SVG inherits no
51
+ page CSS, which is what makes that work). Popup chrome uses it — the move and
52
+ close glyphs are handled by picking, which exists only in the scene, so flat
53
+ they were decoration you could press with no effect.
54
+
55
+ It deliberately cannot make a widget *behave* differently between
56
+ presentations. Differing in what you draw is safe; differing in what you do is
57
+ how the two drift.
58
+
59
+ - **`select3d` opens a menu from its value**, keeping its steppers. Nudging to
60
+ the next option while watching it is a different gesture from picking a named
61
+ one out of a list, and a stepper is better at it.
62
+
63
+ This needed a seam: a widget cannot reach its own panel, because it is
64
+ constructed before the panel that will hold it. `Widget3d.setHost()` and
65
+ `WidgetHost` (`showPopup`/`showLayer`/`closePopup`/`bounds`/`top`/`hasLayer`)
66
+ are that seam.
67
+
68
+ - **A kitchen-sink demo and an overview on the UI page**, with the doc order
69
+ sorted so features come first and the pure models sort to the bottom.
70
+
71
+ ### Changed
72
+
73
+ - **The scene-panel button wears the tosijs-3d owl**, not a gear. The
74
+ `scenePanelGear` part name is unchanged — a consumer may be styling it, and a
75
+ part is an anchor rather than a description.
76
+ - **Keyboard keys use SVG icons** for enter, backspace and shift instead of
77
+ unicode glyphs. A codepoint is at the mercy of whatever font the panel
78
+ resolved, and a rasterised texture is its own document with its own fallback,
79
+ so `⏎` could arrive as a box.
80
+
81
+ ### Fixed
82
+
83
+ - **Nested panel pointer events were handled twice** — once by the inner panel,
84
+ then again when they bubbled to the outer one, which routed them back in by
85
+ coordinates. A tap survives that; a HOLD does not, so press-hold-drag on the
86
+ spacebar never fired in the DOM (3D worked, because a texture has no DOM
87
+ events).
88
+ - **`vector3d` did not forward its host**, so the fields inside a coordinate row
89
+ could never summon a keyboard — and therefore never showed a numpad.
90
+ - **The keyboard follows focus** between fields, and its layout reverts: a
91
+ takeover cleared the shared record but not the previous owner's, so returning
92
+ to a text field left the numpad up.
93
+ - **It dismisses** when the host makes something else the receiver
94
+ (`setActive(false)`, which is distinct from `focusClear` — D-pad focus moving
95
+ on does not stop text arriving).
96
+ - **Placement follows the FIELD, not the panel**, and the scene layer no longer
97
+ clamps to the panel: a plane is not cropped by anything, so a keyboard for a
98
+ field low in the panel hangs below it rather than being pushed up over the
99
+ field it types into.
100
+ - **The popup close target no longer shrinks with its glyph.** `chromeHit` tests
101
+ a boundary, not the glyph box, so making the × smaller quietly made it harder
102
+ to hit — wrong for a controller ray. The target is now sized on its own terms.
103
+ - **The return key drew a box.** `icons/stroked/corner-down-left.svg` is an
104
+ 18-byte file containing the TEXT `cornerDownRight0f` — a mirror reference that
105
+ `svgIcons` resolves and `iconGlyph` cannot. The icon guard now checks that a
106
+ name RENDERS, not merely that it exists.
107
+
108
+ ## 0.7.4
109
+
110
+ ### Added
111
+
112
+ - **`curve3d` + `footprint3d` — author a terrain province instead of coding
113
+ one.** A province is a **footprint** (extent by direction) plus **one curve
114
+ per layer**: a `shape` that remaps the height sample, and a `falloff` that
115
+ says how far its say extends. The pure model is `curve.ts` (Babylon-free,
116
+ 55 tests); the province editor demo lives on `/curve-field/`.
117
+
118
+ **The range is closed on purpose.** A curve maps `[0,1]` to `[0,1]` and a drag
119
+ clamps rather than pushing the range, because a profile that can return 1.4
120
+ silently changes the height a province occupies — which is exactly what
121
+ `carve`/`patch-field` must agree about, and it fails as _geometry_ while
122
+ reporting nothing. Amplitude belongs to the block; shape belongs to the curve.
123
+ `blendSample` composes them convexly, so a tile's bounds are known before
124
+ anything is evaluated, however many provinces overlap.
125
+
126
+ **A falloff is pinned to 0 at its edge; a profile is not.** A province still
127
+ carrying weight at its boundary does not blend into the terrain around it —
128
+ another silent step. Not monotonic, though: a crater rim and a volcano cone
129
+ are non-monotonic falloffs, so the edge is pinned and the middle left alone.
130
+
131
+ **A footprint is a polygon, not a sampled curve.** `ngon(6)` is six vertices,
132
+ and `polygonExtent` casts a real ray at the straight edge — interpolating
133
+ radius against angle bows every edge inward, which is why an earlier draft
134
+ carried twelve samples per edge to avoid doing one intersection. A circle is
135
+ therefore the expensive shape: a 16-gon, indistinguishable at province scale.
136
+ Vertices cannot pass their neighbours or reach the centre, which keeps the
137
+ polygon star-shaped — the property that makes "extent in this direction" have
138
+ an answer.
139
+
140
+ Presets are named for what they **are**: `shelf + mountains`, `desert
141
+ terraces`, `plateau`, `smooth edge`, `abrupt edge`, `messy circle`. "ease
142
+ in-out" describes a graph; "smooth edge" describes a province.
143
+
144
+ - **`vector3d` / `euler3d` — a coordinate on ONE row.** Three stacked labelled
145
+ fields is why an inspector panel ends up three times taller than it needs to
146
+ be. `euler3d` is not a styling variant: it **wraps** into `(-180, 180]` where
147
+ `vector3d` clamps, because a clamp fights you at exactly the angles you most
148
+ want to scrub through. Degrees, per the angle rule.
149
+
150
+ A row is **three tab stops**, not one: the host tracks focus per widget, so
151
+ the row keeps its own axis index and lights exactly one caret — all three at
152
+ once says focus is everywhere, which says nothing.
153
+
154
+ ### Fixed
155
+
156
+ - **A yielded camera is always given back.** Dragging a popup detaches the
157
+ camera so it does not orbit inside the same gesture, but the restore sat
158
+ behind an empty-popup-list early return — so anything that emptied the list
159
+ mid-gesture stranded the camera detached, with no way back. A detached camera
160
+ is a dead scene, not a glitch. Also added a `window` pointerup/pointercancel
161
+ backstop, because a pointer released outside the canvas produces no scene
162
+ event at all, and dragging toward the edge is how you leave the canvas.
163
+
164
+ - **`openPopup` threw when opened by a click.** It called `attachDrag()` from a
165
+ `whenMesh` callback declared above it — fine while that callback defers, which
166
+ is what happens at mount. But `whenReady` fires immediately when the scene is
167
+ already up, and appending a plane to a live scene runs its `sceneReady`
168
+ synchronously too, so opening a popup from a click completed the whole chain
169
+ inside `openPopup` and hit `attachDrag` in its temporal dead zone. Minified,
170
+ that reads `Cannot access 'E' before initialization`. The throw escaped before
171
+ the function returned, so the caller got no popup **and** no drag — one
172
+ exception, two symptoms, neither pointing at declaration order.
173
+
174
+ - **Dragging a popup no longer re-centres it under the pointer.** Babylon's
175
+ `_startDrag` falls back to a ray from the camera position when `fromRay` is
176
+ omitted, so passing only `startPickedPoint` was not enough: it still
177
+ recomputed the drag plane from a default. Both are needed — the ray says where
178
+ the gesture points, the point says where on the panel it took hold.
179
+
180
+ - **A panel owns its background.** It painted nothing of its own, so the seam
181
+ between the title bar and the content box leaked two ways: `box` draws its
182
+ background rounded, so its top corners curve away from the square-bottomed
183
+ bar, and it insets by `borderWidth / 2`, giving a hairline down both sides.
184
+ A panel is an opaque thing; the parts now sit on top of it. Panel chrome also
185
+ reads the theme, which it never did — it was hardcoded.
186
+
187
+ - **Popups have a rim, since they cannot have a drop shadow.** The panel is
188
+ emissive so it is unlit, and a cast shadow needs a receiver — a popup floating
189
+ in front of a scene has nothing to fall on. Drawn at **double** stroke width
190
+ so the clip discards the outer half of a stroke that straddles its path,
191
+ leaving the intended width with no half-pixel arithmetic. `muted` is a mid
192
+ grey deliberately: the backdrop is arbitrary 3D, so a light rim vanishes on a
193
+ pale scene and a dark one on a dark scene.
194
+
195
+ - **Numeric fields scrub** — `inputField({type:'number', scrub, step, min, max})`
196
+ drags to adjust and clicks to type (tosijs-3d#50). Scrubbing lives on the
197
+ field rather than in a separate control because a typed field already knows it
198
+ is numeric; otherwise every numeric widget needs its own copy of parse, format
199
+ and commit.
200
+
201
+ **Tap versus drag is a distance, not a mode** — a press that never travels
202
+ places the caret, one that moves scrubs. No modifier and no separate hit zone,
203
+ which matters most in XR where aiming is the expensive part.
204
+
205
+ - **`slider3d`'s `step` is documented** — it always quantised the drag _and_ the
206
+ reported value, but nothing said so, so a consumer generating panels from JSON
207
+ Schema concluded sliders were continuous and turned snap settings into
208
+ cyclers. There is now a **widget reference table** covering every widget's
209
+ options, plus the methods on a panel and a field.
210
+ - **⚠️ `b3dWater`'s normal map is now PROCEDURAL by default** (tosijs-3d#46).
211
+ It defaulted to `/waterbump.png` — a file that ships in this repo's `static/`
212
+ and **not in the published package** — so every consumer taking the default
213
+ pointed at something they had never been told to serve. Babylon falls back
214
+ when a texture will not decode, so the sea rendered as a **checkerboard**,
215
+ which reads as a style rather than a fault; it was reported as _"is the water
216
+ supposed to look like a checkerboard (it actually looks awesome…)"_.
217
+
218
+ Shipping the PNG would have fixed the missing file and left the real problem:
219
+ a default that depends on a fixed URL resolving in someone else's app. The
220
+ built-in map is now generated from this library's own Perlin noise — no file,
221
+ no network, no path, and it works offline and in a headset. It tiles by
222
+ construction (sampled on a torus), so there is no seam to hide.
223
+
224
+ An explicit `normalMap` that fails to load now **logs an error** instead of
225
+ silently becoming a checkerboard. Pass `normalMap` to override.
226
+
227
+ - **`panel.openPopup()` — a popup is just another panel** (tosijs-3d#37, item
228
+ 4). This was the seam blocking a real `select`, the colour picker, and any
229
+ in-panel menu: `panel3d` returned a bare `<svg>` while `openPopup`/`openMenu`
230
+ wanted a `Surface`, so a control inside a panel had nowhere to put its list.
231
+
232
+ Nothing new was needed to build one — it is a `panel3d` with `height: 'fit'`
233
+ placed by `placePopup` (which already flips and clamps). It returns the panel
234
+ plus **where** it goes; mounting stays the host's job, because that is the
235
+ part that genuinely differs: flat it is a positioned sibling, in a scene it is
236
+ another plane. A popup living inside its opener would be clipped by the
237
+ opener's own viewBox, which is exactly why it has to be its own panel.
238
+
239
+ The popup is capped at its bounds, so a long list scrolls rather than growing
240
+ past the surface it must land on — flipping cannot rescue a popup that is
241
+ taller than both sides.
242
+
243
+ - **Fixed: injected colour controls set tokens to `[object Event]`.** A tosijs
244
+ `Component` binds any `on*` prop as a **DOM event listener**, so an injected
245
+ `colorInput({onChange})` calls back with an `Event` rather than a colour.
246
+ Setting a token to one throws nothing — it stringifies, fails to parse, and
247
+ the widget paints **black**. `themeEditor` now accepts either and ignores
248
+ anything it cannot read as a colour.
249
+ - **⚠️ Panels no longer cast shadows by default.** `register()` is the
250
+ shadow-caster contract and a panel registers like any other mesh, so every
251
+ in-scene panel threw a hard-edged rectangle across whatever it faced.
252
+
253
+ It is the right default for what a panel _is_: UI is not scenery — a HUD or an
254
+ inspector is something you look **through** the world at, and a shadow makes
255
+ it furniture. It is also the worst case for the shadow map, being a large flat
256
+ quad that (when camera-relative) never leaves `activeDistance` and so never
257
+ culls. Set `castShadow` for a panel that really is scenery — a sign on a wall,
258
+ a screen in a room.
259
+
260
+ - **The scene panel sits in the top-left corner**, inset equally from top and
261
+ left, instead of hanging below the gear button. It sat clear of the button
262
+ that opens it, which pushed its own content off the bottom on a short scene —
263
+ and covering the gear is fine, because the panel carries its own close box.
264
+ - **The scene panel updates when pause state changes elsewhere.** The transport
265
+ row picks its label and icon from `paused`, but nothing repainted the panel
266
+ when that flipped — so resuming from the pause _dialog_ left the panel still
267
+ offering **Play**. It only looked right before because the common path is
268
+ pressing the panel's own button, which reopens the panel afterwards; a second
269
+ route to the same state had no reason to.
270
+ - **The `surface` demo's debug readout is a real 3D popup**, not an overlay
271
+ painted into the opener's own SVG — so in the scene it is a panel with depth
272
+ that can sit in front of the surface and be dragged in world space, rather
273
+ than a picture of one stuck to the plane. The flat view keeps the in-panel
274
+ form, because in a document a popup _is_ a box drawn over another box.
275
+ - **`buttonActiveText`** — the label colour while a clickable is held or
276
+ selected. Separate from `text` because the background under it has changed: a
277
+ theme whose `buttonActive` is a strong accent needs a light label on it, and
278
+ baking that into `text` would force every label in the panel to follow a
279
+ decision that is only about buttons. A held `button3d` and a selected icon
280
+ both take it.
281
+ - **"button" means any CLICKABLE**, and now says so in the source.
282
+ `buttonBg`/`buttonHover`/`buttonActive`/`buttonActiveText` style every widget
283
+ you can press — buttons, icon buttons, toggles, list rows, select cyclers.
284
+ `widget*` would be vaguer (a label is a widget and has no press state) and
285
+ `clickable*` is what they mean but longer than anyone will type.
286
+ - **`iconBar3d` colours like a button.** It used `buttonActive` — the _press_
287
+ colour — for the selected item, so a selected icon looked permanently held and
288
+ pressing one showed nothing new. Now three escalating states (`buttonBg` →
289
+ `buttonHover` → `buttonActive` while held) plus **`selectedBg`** for "this one
290
+ is on", which is a different axis and why the theme has a separate token for
291
+ it. Release clears the pressed look _before_ the handler runs, so a handler
292
+ that rebuilds the panel cannot leave a button stuck looking held; and a press
293
+ that drifts off its button no longer fires it.
294
+ - **`strokeWidth` reaches icons.** `iconGlyph` hardcoded `2`, so the token
295
+ affected nothing but the text caret — a themed panel could not make its icons
296
+ match its own line weight. It now defaults to `w3dTheme.strokeWidth`, and an
297
+ explicit option still wins (an icon used as a mark may want its own weight).
298
+ - **Web fonts now reach in-scene panels** — `registerSvgFont(family, url)`
299
+ fetches the face, base64s it, and injects an `@font-face` **into the
300
+ serialised SVG**, which is the only place a rasterised copy can find it.
301
+
302
+ A serialised SVG is its own document: it inherits neither the page's font
303
+ faces nor its custom properties (the second half is why `w3d-theme` bakes
304
+ literals). The symptom was quiet — a scene panel in the fallback family while
305
+ the identical flat panel rendered correctly.
306
+
307
+ Only faces the markup actually mentions are injected, because the payload is
308
+ re-parsed on every rasterisation; a woff2 is 20–100 KB and base64 adds a
309
+ third, so carrying every registered font on every texture would be the real
310
+ cost. Cached per URL, and a failed fetch is not cached as the answer.
311
+
312
+ - **`withTheme(partial, build)` — one default, per-panel overrides.**
313
+ `setW3dTheme` sets the default; this builds something under an override and
314
+ restores it afterwards (in a `finally`, so a throw cannot leave the palette
315
+ changed — a theme that silently persists after an error makes the _next_
316
+ widget look wrong for no visible reason).
317
+
318
+ It works because of the property that makes a single global table safe: a
319
+ widget reads the theme when it is **built**. So a scope is just set-build-
320
+ restore — no plumbing, no second table, and a widget built inside keeps its
321
+ colours forever, since they were baked into its attributes.
322
+
323
+ **Why not `panel3d({ theme })`:** a panel's children are constructed as
324
+ _arguments_, so they already exist by the time the panel function runs. An
325
+ option on the panel could only recolour the panel's own background while its
326
+ contents kept the default — worse than not offering it. Wrapping the
327
+ construction puts the children inside the scope, because that is when they
328
+ evaluate.
329
+
330
+ - **`themeEditor()` — the palette editor is a component**, not demo code. An
331
+ adopter theming an app wants exactly this UI, and copying it out of a doc
332
+ comment is how it drifts from the palette it edits. Its own module, so it
333
+ tree-shakes out of a build that never imports it.
334
+
335
+ Every metric has a **slider and a number field, kept in sync** — a slider
336
+ alone hides the value you are setting, and you cannot drag an exact 0.05.
337
+ The colour control is **injected**: alpha matters (`panelBg`, `rowHover` and
338
+ `selectedBg` are `rgba()`) and `<input type="color">` silently drops it, but
339
+ tosijs-ui is deliberately not a dependency — so pass its `colorInput`, or take
340
+ the native fallback knowing what it costs.
341
+
342
+ - **⚠️ The theme now actually reaches the widgets.** Every `--w3d-*` value was
343
+ captured in a module-level `const` at **import** time, so `setW3dTheme` could
344
+ never affect anything — colours, fonts and metrics alike. Only
345
+ `roundedRadius` appeared to work, because it happened to be read inline.
346
+ Reads are now live (getters), so a widget takes the theme in force when it is
347
+ **built**, which is what the docs always claimed.
348
+ - **The SVG UI is themeable** — 21 tokens, up from 17. Added `focus`,
349
+ `selectedBg`, `disabledBg`, `disabledText`, `strokeWidth`, `roundedRadius`,
350
+ `spacing`, `lineHeight`, `codeFontFamily`, `codeFontWeight`, plus `overlay`,
351
+ `divider`, `placeholder` and `caret`.
352
+
353
+ **`setW3dTheme(partial)` overrides at runtime.** The `--w3d-*` variables are
354
+ read once at load and deliberately so — an SVG destined for a texture is
355
+ serialised away from the document, where `var(--w3d-text)` resolves against
356
+ nothing and paints black. This is the way back in, and what a theme editor
357
+ needs. Widgets read the theme when they are **built**, so rebuild them after
358
+ calling it; a widget that re-read its colours would have to re-resolve them
359
+ per rasterised texture, which is the cost this design exists to avoid.
360
+
361
+ The four interaction states (`rowHover`, `focus`, `selectedBg`, `disabledBg`)
362
+ are separate tokens because they must stay tellable apart — `focus` is a
363
+ **stroke** rather than a fill, since a focus ring has to be visible on a
364
+ hovered row _and_ a selected one. Live demo on the `w3d-theme` page.
365
+
366
+ - **20 more icons**, for the ensemble editor: `mousePointer`, `refreshCcw`/`Cw`,
367
+ `move`, `copy`, `delete`, `trash`/`trash2`, `plus`/`plusCircle`,
368
+ `rotateCw`/`Ccw`, and the complete `corner*` family (8). 61 total.
369
+ - **`fieldGroup.attach(target?)`** wires real `keydown` events to the focused
370
+ field and returns a detacher. Opt-in — the library still never grabs the
371
+ document by itself — but without it every flat host writes the same six lines,
372
+ and a field you can click into that then refuses every character is a bad
373
+ first impression. It was one: the theme demo shipped with an unusable field.
374
+
375
+ It maps the event's modifier flags explicitly rather than passing the event,
376
+ so `keyIntent` keeps taking a plain shape and stays testable without a DOM.
377
+
378
+ - **⚠️ `fieldGroup` moved under `ui.*`** (`ui.fieldGroup`), alongside
379
+ `ui.inputField` and `ui.keyboard` — it is part of the same SVG UI surface and
380
+ was inconsistently top-level. Unreleased, so nothing external moves.
381
+ - **`fieldGroup` — one keyboard, many fields.** Three chores that always travel
382
+ together and were hand-rolled in every host (tosijs-3d#37, items 1 and 7):
383
+ **exclusivity** (focusing one un-focuses the rest — two lit fields both
384
+ claiming the keyboard is worse than none), **commit on leave** (so a
385
+ half-typed `1.` never survives the move), and **layout** (the incoming field's
386
+ `type` picks the keyboard mode, which is the point of having a type).
387
+
388
+ `handleKey(key, mods)` takes a key NAME rather than an event, so the same
389
+ routing serves a DOM listener, a synthetic source or a test — and it returns
390
+ whether the key was consumed, so a host knows when to `preventDefault`.
391
+ Attaching a real listener stays the host's choice; this library never grabs
392
+ the document.
393
+
394
+ - **`keyIntent`** — pure DOM-key → field-intent mapping. Named keys (`Tab`,
395
+ `Escape`, `F5`) and anything with Ctrl/Cmd/Alt are **not consumed**: swallowing
396
+ a shortcut because a field happens to have focus is worse than handling no
397
+ keys at all.
398
+ - **`inputField.onFocus` is settable on the object** (mirroring `onChange`), so
399
+ a manager learns about focus it did not initiate. Without it a tap and a
400
+ programmatic focus disagree about who is active, and keys go to the wrong
401
+ field — silently, and only sometimes.
402
+ - **`inputField` has a `type`** — `'text' | 'number' | 'integer' | 'email' |
403
+ 'url' | 'tel'`, HTML's `inputmode` idea. **One property, three jobs**: the
404
+ host raises the matching keyboard layout on focus
405
+ (`field.keyboardMode` → `keyboard.setMode`), `commit()` normalises or refuses
406
+ the value, and `isValid()` answers without the host knowing the field's kind.
407
+ This is why there is no separate `numberField` — a number field is this one,
408
+ configured (tosijs-3d#37, item 2).
409
+
410
+ The layouts already existed (`numpad`, `dial`, `email`, `url`) and nothing
411
+ chose between them, so focusing a numeric field raised `alpha` and left you to
412
+ find the numpad — two deliberate taps per field, and worse in a headset where
413
+ there is no physical keyboard to fall back on.
414
+
415
+ **Validity while typing and validity as an answer are different questions**,
416
+ and conflating them is what makes typed fields either unusable or liars: `-`
417
+ and `1.` are legitimate things to have typed and illegitimate things to have
418
+ meant. So `isValid()` accepts them and `commit()` does not — and a refused
419
+ commit restores the last good value rather than writing `NaN` into the
420
+ document. Enter commits _before_ `onEnter` fires, so no handler has to re-do
421
+ the parse.
422
+
423
+ - **`slider3d` can show its value permanently** — `showValue: 'peek' | 'always'
424
+ | 'never'` (default `'peek'`, the existing behaviour) plus a `format` hook for
425
+ units. ensemble's coordinates were unreadable because a handle position is not
426
+ a number (tosijs-3d#37, item 3).
427
+
428
+ `'always'` reserves width for the **widest** value in the range (measured
429
+ through `format`, so units and precision count) and shortens the track to
430
+ clear it. Sizing to the _current_ value would make the track twitch mid-drag,
431
+ which reads as the slider fighting you.
432
+
433
+ - **`row3d` — lay widgets side by side.** A panel only stacks, so a
434
+ label-and-field pair cost two rows and eight fields became sixteen rows of
435
+ mostly whitespace (tosijs-3d#37, item 5). `weights` give the usual label/field
436
+ split; children are middle-aligned by default, since top-aligning a short
437
+ label beside a taller control makes it look detached from what it names.
438
+
439
+ Pointer routing is **by column**, delegating in each child's own coordinates —
440
+ a widget cannot know it has been put in a row, so it must still receive
441
+ `(0,0)` at its own top-left. Hit-testing follows the same path, which keeps
442
+ "grab between the controls to scroll" working inside a row.
443
+
444
+ - **`panel3d` sizes itself: `height: 'fit'` is the new default**, with
445
+ `maxHeight` to cap it (past the cap it scrolls rather than growing — fitting
446
+ and scrolling are the same mechanism seen from either side of a limit).
447
+ - **`panel.measure()` → `{content, viewport, overflow, fits}`.** The panel has
448
+ always known this (`stackLayout` returns the total) and simply never said, so
449
+ every consumer sizing a panel was guessing at a number the panel could have
450
+ told them.
451
+
452
+ Both exist because **clipping is silent**: a panel too short for its content
453
+ looks exactly like a panel missing its last control. The ensemble editor got
454
+ three heights wrong in one sitting — a command hidden behind another panel, an
455
+ option cut in half, a list showing five of eight rows — and noticed none of
456
+ them at the time (tosijs-3d#37, item 6).
457
+
458
+ The ordering is the whole trick: widgets measure against the inner WIDTH,
459
+ which is known from `width` alone, so the stack can be measured before the
460
+ panel has a height and the height derived from it. The previous code fixed the
461
+ height first and had no way to discover it was wrong.
462
+
463
+ **Behaviour change:** a `panel3d` with no explicit `height` used to be 480 and
464
+ now fits its content. All nine call sites in this repo pass a height, so
465
+ nothing here changed; an adopter relying on the old default should pass
466
+ `height: 480`.
467
+
468
+ ## 0.7.3
469
+
470
+ ### Added
471
+
472
+ - **⚠️ The biped uses the GTA V control layout.** Left stick moves and
473
+ **strafes**; right stick **turns the body** and pitches. It was tank-controlled
474
+ (left stick X turned), which nobody has muscle memory for any more and which
475
+ left the right stick doing camera work that could not steer. Turning the body
476
+ with the right stick makes **swim direction and body facing one thing** rather
477
+ than two that can disagree — the thing that made look-directed swimming fiddly.
478
+ The camera now sits straight behind the body and has no yaw of its own, so it
479
+ cannot end up pointing somewhere the character is not. Measured: strafe moves
480
+ 4.65 m sideways with the yaw unchanged; turn rotates 34° with zero translation.
481
+ - **The biped's right stick is a LOOK control**, and jump and sneak exist.
482
+ Tonio spotted that look-directed swimming had nothing to aim with on a flat
483
+ screen: the right stick was bound to `cameraZoom` (Y) and a snap-back peek (X),
484
+ so a character had no aim at all. Now `lookX`/`lookY`, **persistent rather than
485
+ sprung** — a character's camera is how you look _around_, where the aircraft's
486
+ springs back because there it is a glance off the flight path. A `FollowCamera`
487
+ has no pitch of its own, so pitch is height: raise the camera and it looks down
488
+ at its locked target. Zoom moves to the d-pad, which sneak vacated.
489
+ - **Swim aim now reads that look** rather than integrating the stick
490
+ separately, so "swim where you are looking" is literally true flat and is the
491
+ same rule the headset already followed with your head.
492
+ - **Jump** — right bumper only. `A` briefly aliased it, and Tonio removed the
493
+ alias: the face buttons are **reserved for actions** (they are primary and
494
+ secondary fire on the aircraft), and a control vocabulary that changes
495
+ meaning per vehicle is one you have to relearn. The ground snap swallowed the
496
+ first version whole — the impulse lifted the body ~7 cm, the probe still saw
497
+ ground 0.6 m below, and the snap put it straight back, a jump that rose
498
+ exactly 0.00 m. The snap now yields while you are rising.
499
+ - **Standing jumps brace; running jumps fire on the press.** A running jump's
500
+ anticipation _is the run_ — the character is already loaded and moving, so a
501
+ wind-up can only read as a stumble. A standing jump has no run-up to borrow
502
+ from, so holding scrubs the clip into the braced pose and **parks there**,
503
+ and releasing launches. The hold is not a delay tax: it **scales the jump**
504
+ (`jumpMinScale` 0.45 → full), so it is a choice rather than a wait. Measured:
505
+ full brace 1.13 m, short hold 0.45 m, nothing at all while held.
506
+ - **The animation is retimed to the jump, not the jump to the animation.**
507
+ Physics stays fixed at `jumpSpeed`, and the clip's `speedRatio` is set so it
508
+ lasts exactly the flight time. Measured: `running-jump` plays at **1.02×**
509
+ (0.93 s clip over 0.92 s of flight). The first attempt had this backwards —
510
+ sizing the launch from the clip length — which made the jump a consequence of
511
+ whatever the animator exported.
512
+ - The jump clip holds for the **whole airborne period**, and the clip is chosen
513
+ at the press and never re-picked, so a standing jump cannot switch to the
514
+ running clip in mid-air.
515
+ - **Sneak** — left bumper, a **toggle on land and a held control in water**: a
516
+ stance you adopt for a while versus a thing you do continuously, and a toggle
517
+ whose state you must remember with your head underwater is worse than useless.
518
+ - **⚠️ Jumping is instantaneous again, and animated in three phases.** The
519
+ crouch-on-press/launch-on-release model was an adaptation to the stock rig's
520
+ single one-shot clip that happened to open with a crouch. Quaternius ships
521
+ `Jump_Start` / `Jump_Loop` / `Jump_Land`, where **start is a takeoff, not a
522
+ wind-up** — Tonio: _"it's basically designed for instantaneous jumps where
523
+ once you jump you enter the jump state and that's it."_ That is also the
524
+ platform-jumper contract in `MOBILITY-DESIGN.md`: the character does exactly
525
+ what you pressed, now; anticipation belongs to the intent model, where the
526
+ character decides before you ask. `jumpWindup` and `jumpMinScale` are gone, and
527
+ so is the `speedRatio` retiming — each clip now plays for as long as it is
528
+ actually true instead of being stretched to fit a flight it could not know.
529
+ - **`b3d-library` surfaces the glb's own catalogue** (#45). `getManifest()`
530
+ returns `{count, categories, items:[{name, category, tags, size}]}` built from
531
+ the loaded nodes; `getInfo(name)` answers `{category, tags, clips}` **without
532
+ instantiating**, which is what a clip picker needs (`metadata.animationGroups`
533
+ requires an instance to exist first). `getNames()` now narrows to the declared
534
+ items when a catalogue is present — the data-driven twin of the `.model`
535
+ convention, and a packed kit badly over-reports without it (measured: 559 raw
536
+ nodes vs 131 declared items). `getRootNames()`/`getHierarchy()` still expose
537
+ everything, so sub-part targets keep working, and `instantiate()` carries the
538
+ extras onto what it returns — including the `canonical: true` wrapper, which
539
+ was the actual reason the data looked unreachable.
540
+ - **`glb-manifest`** — the pure half: category/tag/size queries over a library
541
+ catalogue, plus GLB JSON-chunk parsing for the case where you want a
542
+ library's contents _without_ loading it into a scene. Note Babylon surfaces
543
+ per-**node** extras and drops per-**scene** ones, so `manifestFromNodes` is
544
+ the path that needs no parsing at all.
545
+ - **Mantling — the biped climbs onto a ledge too high to step onto.** No button:
546
+ push into it and the character solves it, which is the intent model rather
547
+ than the platform jumper. `mantle.ts` is the pure half (Babylon-free, 13
548
+ tests): `canMantle` (every clause a way of NOT being a ledge — too low is a
549
+ step, too high a wall, no landing is a fence you would be stranded on),
550
+ `mantlePath` (an ARC — rise leads translation, because a straight line drives
551
+ the body through the lip) and `mantleClip` (picks `ClimbUp_1m`/`_2m` by
552
+ MEASURING the ledge, since Quaternius indexes those clips by height).
553
+
554
+ It arrived as _"when you swim to the water's edge, you just pop instantly to
555
+ the surface onto the land"_, but it is not a water feature — climbing out of a
556
+ pond is mantling a lip of height h, so the bank, the low wall and the crate
557
+ are one verb. A swimmer's reach is measured from the **waterline**, not the
558
+ dangling feet: your hands are at the surface, and the feet reading made every
559
+ bank in the demo 3 m tall.
560
+
561
+ New attribute `climbReach` (2.2 m). The probe is throttled to 12 Hz — see
562
+ MOBILITY-DESIGN → "Probes: one budgeted read of the surroundings".
563
+
564
+ - **The wade↔swim transition is verified** — it had been the one untestable
565
+ case, because no water in the 0.2–0.9 m band existed anywhere in the demo.
566
+ With the canal's variable depth it now does: walking deep → shelf → deep
567
+ across a HARD step (4.24 m → 0.37 m → 4.56 m, the worst case for flicker)
568
+ produces exactly two state changes over 304 samples, one at each edge.
569
+ - **`wadeDepth` is a biped attribute** (`0.45` = fraction of standing height,
570
+ ~0.8 m on a 1.83 m rig). Below it you wade; above it buoyancy takes over.
571
+ Tonio set the band — _"about 0.4-0.5 (it's hard to swim in water less than
572
+ waist deep)"_ — which is the real constraint: swimming shallower than that
573
+ is not a choice a person gets to make, the bottom is in the way. A fraction
574
+ rather than metres because it is a fact about the **body**, so a smaller
575
+ character starts swimming sooner in the same pond for free.
576
+ - **Camera zoom-IN works.** `cameraZoom` was `Math.max(0, dpadUp - dpadDown)`,
577
+ which discarded the whole zoom-in half — down produced `0`, not `−1`, so the
578
+ camera could only retreat. The unit test asserted the broken value, having
579
+ been written beside the broken line.
580
+ - **Vertical look moves the chase rig in XR.** Flat, `lookY` tilts the
581
+ `FollowCamera` via `heightOffset`; in a headset there is no `FollowCamera`, so
582
+ it did nothing. A regression from moving camera zoom onto the D-pad — XR
583
+ controllers have no D-pad, so the headset lost both the zoom and the tilt in
584
+ one move. Same formula as flat, clamped, so the two feel alike.
585
+
586
+ **Known gap:** camera zoom is now unreachable in VR. It needs a home that
587
+ exists on an XR controller — most likely a slider on the in-headset scene
588
+ panel.
589
+
590
+ - **Strafing is a toggle, `strafing="off"` by default.** Both sticks then turn
591
+ you — the left while moving or not, the right without moving — and the
592
+ sidestep clips never play. Off on principle: sidestepping is a shooter idiom
593
+ that reads oddly on a character meant to move like a person. That it also
594
+ avoids the weakest clips in the Quaternius set is a bonus, not the reason.
595
+ - **The pose is averaged over the cycle, not sampled once** — the fix for
596
+ _"I seem to porpoise out of the water"_. A swim cycle is not a fixed shape:
597
+ over `Swim_Fwd_Loop`'s 1.33 s the body's lowest point swings from −1.26 to
598
+ −0.28 as the legs kick. A single sample landed wherever the settle timer fell,
599
+ and the shallow end of that swing drove the derived buoyancy to its clamp and
600
+ fired the swimmer out of the water — intermittently, because it depended on
601
+ the phase.
602
+ - **`buoyancy` is METRES, not a multiplier** (`0` default = waterline at the
603
+ head; `0.1` floats ten centimetres higher). A multiplier is not authorable —
604
+ its effect depends on how tall the pose happens to be, so the same number
605
+ means different things for a tread and a crawl. Tonio: _"we can keep buoyancy
606
+ as a strict z offset for a given figure in water."_
607
+ - **The waterline is anchored at the swimmer's HEAD.** Which is what swimming
608
+ _is_ — you keep your head at the surface, and you do it by swimming rather
609
+ than by floating — so it is a fact about the activity, not about a clip, and
610
+ it holds for any humanoid rig without per-animation tuning. Treading now sits
611
+ with the water just above the neck and the head clear; a front crawl breaks
612
+ the surface. Both from one rule.
613
+ - **Swimmers float where the ANIMATION says, not where a constant says.** The
614
+ swim clips are authored with the **root at the waterline** — measured on the
615
+ Quaternius rig, `Swim_Idle_Loop` spans −1.37…+0.50 about the root and
616
+ `Swim_Fwd_Loop` −0.60…+0.31, so floating the root on the surface gives a tread
617
+ with head and shoulders out and a crawl with the head just breaking. The
618
+ equilibrium is now READ from the pose rather than tuned, so any standard
619
+ animation set floats correctly untouched.
620
+
621
+ The root itself is **not** a usable anchor: it means different things in
622
+ different clips — feet when standing, roughly waterline when swimming — and
623
+ taking it literally floated this rig at armpit height, since its root sits 73%
624
+ up the treading pose. It is still what tells us a pose _is_ a swim pose (the
625
+ body hangs below it), just not where the water goes.
626
+
627
+ Three bugs fell out of the old assumption that the root is at the feet, which
628
+ is true only while standing: the real head sat ~1.2 m under while treading;
629
+ the legs were buried in the seabed (the floor clamped the root, not the body's
630
+ lowest point); and `buoyancy` appeared to do nothing, because clearing that
631
+ head needed a submersion of 0.14 — `buoyancy ≈ 7`.
632
+
633
+ - **`buoyancy` is a biped attribute** — now a **trim** on the animation's
634
+ waterline (`1` = as authored, higher rides higher), because the absolute is
635
+ not ours to choose: the clip already encodes it, and a number here would be a
636
+ second opinion that goes stale with the next animation set.
637
+ - **A clip change no longer briefly makes a swimmer stand.** A newly-started
638
+ clip cannot be measured until its pose settles, and falling back to the
639
+ standing assumption for those frames put the root back at the feet — so a
640
+ swimmer floating at the waterline read as barely submerged and walked off
641
+ across it. Moving is exactly what changes the clip, which is why it showed as
642
+ _"when you move he tends to jump up to the surface and walk"_. An unmeasured
643
+ clip now inherits the last swim pose, `height / -bottom` is clamped so a
644
+ mid-blend reading cannot launch anyone, and **no floor under you means you
645
+ cannot stand** — full stop, rather than a number derived from where the body
646
+ drifted to.
647
+ - **The swim/stand test no longer feeds back on itself.** It asks how deep the
648
+ water is **at your feet on the floor**, which is stable across the switch —
649
+ the previous form measured the current pose, so raising buoyancy raised the
650
+ body, flipped the pose upright, and the upright reading locked the flip in:
651
+ at `buoyancy` 1.3 the character corked out and stood on the surface.
652
+ - **Neutral buoyancy starts at 1.5 m of head depth, not 0.5 m.** Holding depth
653
+ is for DIVING; at half a metre a swimmer whose head dipped barely under went
654
+ neutral and stayed there, with the up-thrust throttled near the surface too —
655
+ Tonio: _"still treading water with head underwater and it's hard to swim up."_
656
+ - **Sidestepping uses the lateral clips** rather than the forward walk — the
657
+ slide is gone. UAL ships a full eight-way set (Fwd, Fwd_L/R, Left, Right, Bwd,
658
+ Bwd_L/R) for jog, crouch **and** crawl; the biped now picks the lateral one
659
+ when sideways motion dominates, crouched or not, and falls back to walking on
660
+ a rig that has no such clip. Sneaking at rest holds `Crouch_Idle_Loop`.
661
+ - **⚠️ The right stick's Y is inverted by default** (`invertLookY: 'off'` to
662
+ restore) — pushing away tips the view down, the way a camera head works.
663
+ - **The follow camera can no longer go underground.** Pitch drives its HEIGHT,
664
+ so looking up walked it downward until the world vanished; `cameraMinHeight`
665
+ (0.5 m) floors it.
666
+ - **`ualAnimationStates()`** — the clip-name map for Quaternius UAL rigs, so
667
+ adopting that library is one line rather than twelve hand-written states. It
668
+ retires two fakes: `walkBackwards` becomes a real `Jog_Bwd_Loop` instead of
669
+ the walk cycle played in reverse, and `sneak` gets a `Crouch_Idle_Loop` that
670
+ holds at rest. `Jump_Start` / `Jump_Loop` / `Jump_Land` are mapped separately,
671
+ which is what will let the jump's brace genuinely hold rather than scrubbing a
672
+ one-shot — addressable now, not yet wired.
673
+ - **⚠️ The `tosi-b3d` demo uses a 1.83 m rig** from the CDN
674
+ (`quaternius/UAL1_core.glb`) instead of `omnidude.glb`, which measured
675
+ **0.88 m** — half human scale. **Camera framing roughly doubled** to match
676
+ (`cameraHeightOffset`, `cameraTargetHeight`, follow distances): that is the one
677
+ part of the tuning genuinely scale-bound, because it is in metres. Everything
678
+ else — run speed, step height, jump, the collision ellipsoid — was already
679
+ human-scale and is now _correct_ rather than needing adjustment. A project
680
+ using its own small rig will want the camera numbers back down.
681
+ - **The biped swims.** Water was already a medium rather than a boundary line
682
+ (underwater fog with a continuous crossing); the biped now treats it as one
683
+ too. Tonio: _"can we change the rate at which the biped falls in water. And
684
+ while we are at it the biped could learn to tread water and swim."_ Both
685
+ animations were already in the standard set, so this is one equation plus
686
+ wiring.
687
+ - **`buoyancy.ts`** — pure, tested. A body is slightly less dense than water,
688
+ so it is pushed up in proportion to how much of it is submerged and rests
689
+ where that balances its weight. Everything readable falls out of that one
690
+ equation instead of being special-cased: plunge-and-bob when you drop in, a
691
+ head that ends up **above** the surface (equilibrium is partial submersion —
692
+ nothing targets a head height), and wading that does nothing until the water
693
+ is deep enough to lift you. Sinking is ~1.5 m/s against ~20 m/s in air, and
694
+ drag blends by submersion rather than switching at the waterline, so
695
+ crossing the surface does not read as bouncing off it.
696
+ - **Swimming is deep enough AND not resting on the floor** — _resting on_,
697
+ not _within reach of_. The first version asked "is there ground below me?"
698
+ and left the character standing on the seabed under six metres of water.
699
+ The floor stops you sinking; it does not hold you down.
700
+ - `swim` while moving, `tread-water` while holding station.
701
+ - **Fixed after report: swimming broke when you pitched and turned at once.**
702
+ The body's yaw was read back out of the _pitched_ matrix, and
703
+ `atan2(forward.x, forward.z)` is ill-conditioned there — at 70° the
704
+ horizontal part is scaled by `cos 70° = 0.34`, so x and z collapse toward
705
+ zero, the recovered yaw gets noisy, and writing it straight back compounds
706
+ the noise into a body that wanders. The yaw is now **captured once from the
707
+ level matrix** and integrated from the turn input thereafter, so the only
708
+ reading happens while well conditioned. It hid because pitching _and_
709
+ turning together is one stick on a controller and two hands on a keyboard.
710
+ - Also fixed: the **mouse wheel** still fed `rightStickY`, which had become
711
+ `lookY` — so a scroll tipped the swimmer, since the swim aim is the look
712
+ pitch. It feeds the zoom axis now, so "the wheel zooms" survives remapping.
713
+ - **Look-directed swimming** (`swim-aim.ts`, pure + tested): the BODY pitches,
714
+ and the stroke follows for free — the biped already swims along its own
715
+ forward vector, so there is no separate vertical term and no way for aim and
716
+ motion to disagree. Aim comes from your **head** in a headset, and from the
717
+ right stick flat, because the biped's `FollowCamera` has a fixed pitch and
718
+ there is nothing to read. The stick **integrates** rather than mapping to an
719
+ absolute angle, so releasing it holds the descent instead of springing back
720
+ to level. Leaving the water unwinds the pitch on its own.
721
+ - **You can dive.** `sneak` down, `jump` up — crouch-to-descend matches the
722
+ GTA-V control vocabulary this project follows and leaves the triggers free.
723
+ Thrust competes with buoyancy rather than replacing it, so letting go hands
724
+ the vertical back to physics instead of pinning you.
725
+ - **Released, you HOLD depth and drift up slowly** rather than corking to the
726
+ surface (Tonio's call). Once your head is properly under, buoyancy blends
727
+ toward neutral — but stays just above 1, so you surface if you stop paying
728
+ attention. You also glide a little deeper after letting go: that is
729
+ momentum, and letting go is not a brake. Measured live in 12 m of water:
730
+ dive to the seabed, release, drift up at **0.2 m/s**; hold `jump` and it
731
+ becomes **0.65 m/s**.
732
+ - Verified live: released on a seabed under 6 m of water the biped rose and
733
+ settled at **0.866** submersion against a predicted 0.870, and a walk
734
+ downhill waded in and began swimming on its own.
735
+
736
+ ### Changed
737
+
738
+ - **Toolchain bumped** — `tosijs` 1.7.8 → 1.8.0, `tosijs-ui` 1.9.8 → 1.12.3,
739
+ `haltija` 1.11.2 → 1.12.5, and **`tjs-lang` 0.13.6 added explicitly**: it is a
740
+ peer of tosijs-ui and was **not installed here at all**, which nothing warned
741
+ about. The site built anyway, so "works" and "works properly" were
742
+ indistinguishable — installing it changed the build output (`tjs-lang bundles
743
+ served same-origin at /tjs/` is new).
744
+
745
+ `peerDependencies` is deliberately unchanged: `tosijs ^1.7.8` already admits
746
+ 1.8.0, so developing against the newer one costs consumers nothing and this
747
+ stays a **patch**.
748
+
749
+ Fallout, all fixed: an unused variable in `wreck-fall` failed lint, which
750
+ `bun start` runs first — so the dev server would not start, and because
751
+ `bun format` is `eslint && prettier`, **prettier had been silently skipped**
752
+ on every file since. Also: `bun run typecheck` is **green again** (the
753
+ standing `model-transform.test.ts:449` red is gone).
754
+
755
+ - **`.haltija.json` pins agent commands to this project's origin.** A shared
756
+ haltija server holds tabs from several repos and commands followed browser
757
+ FOCUS, so a command run from this directory could silently drive another
758
+ project's page — which happened repeatedly. New in haltija 1.12.
759
+
760
+ ### Fixed
761
+
762
+ - **Entering water flipped your heading 180°.** With a mirrored glTF root
763
+ (`scaling.z = −1`) world forward is `−(R·ẑ)`, so reading yaw back gives
764
+ `θ + π` and writing it produces `−F`. I had reasoned this round-tripped; it
765
+ does not. On land nothing rewrites the quaternion, so it only showed the
766
+ instant pitch engaged — which is the instant you start swimming.
767
+ - **You could wade on top of the water.** Holding surface-thrust lifted you
768
+ until submersion fell under the swim threshold, at which point you were
769
+ classed as standing and got a walk cycle while still afloat. Up-thrust is now
770
+ gated by head depth, exactly as the upward aim is: you cannot push yourself
771
+ out of water, and surfacing is buoyancy's job anyway.
772
+ - **The swim/stand switch twitched at the waterline.** Walking down a ramp,
773
+ submersion hovers around the threshold and the mode flickered. `isSwimming`
774
+ now enters at 0.5 and holds until 0.35.
775
+
776
+ - **`setAnimationState` could pick the wrong clip.** The lookup was
777
+ `group.name.endsWith(animation)`, and a suffix match is ambiguous the moment
778
+ one clip's name ends with another's. Asking a Quaternius rig for `Idle_Loop`
779
+ played **`Crouch_Idle_Loop`** — but it was already latent in the stock set,
780
+ where `running-jump` ends with `jump`, so `setAnimationState('jump')` could
781
+ match the running jump depending on array order. Now an exact name match
782
+ (after stripping Babylon's `Clone of ` prefix), with the suffix behaviour kept
783
+ only as a fallback for rigs whose exporter adds some other prefix.
784
+
785
+ - **A `_collideCylinder` child of a `_collideMesh` parent built a cylinder around
786
+ the PARENT's whole subtree.** Reported as _"I put a collideMesh on the hull and
787
+ collideCylinders on each mast but the ship just seems to have a single giant
788
+ squat cylinder"_ — and the effect was a **34.4 m wide, 26 m tall** invisible
789
+ barrier you could not walk within twenty metres of. Two independent faults,
790
+ both in how a collider's bounds are chosen:
791
+
792
+ - The root-walk climbed while the parent had **any** collide annotation, so
793
+ each mast climbed onto the hull. The shape came from the leaf and the bounds
794
+ from the root, so it built the mast's _cylinder_ around the hull's _subtree_
795
+ — and one `processed` entry then swallowed the other two masts, which is why
796
+ there was exactly one. It now climbs only across the **same** annotation.
797
+ (The tell was the generated mesh's name: `Hull_collideMesh_collider` should
798
+ be impossible, because `_collideMesh` never builds a primitive.)
799
+ - Bounds combined the annotated node's children even when the node had its own
800
+ geometry. A mast is a Mesh 0.55 × 20.6 × 0.64 carrying the Crow's Nest, Flag,
801
+ Spars and SquareSails, so combining gave a 10.2 m cylinder around each mast
802
+ — the deck still walled off. Children are combined only when the annotated
803
+ node has no geometry of its own, which is the GLB TransformNode case the
804
+ behaviour was written for.
805
+
806
+ Ship colliders now measure **0.58–0.64 m across** at mast height, and the hull
807
+ keeps its mesh collider.
808
+
809
+ - **Ambient particles spawned on the wrong side of the water** — leaves below it,
810
+ bubbles above, and a lot of them. Reported while swimming, which is what made
811
+ it constant: third person parks the camera right at the surface. Intensity was
812
+ never the problem; it already ramps correctly on the camera's depth. The
813
+ **spawn box** was: a cube of side 2·radius centred on the eye, so with the eye
814
+ near the surface half of it sits in the wrong medium. Intensity says how much
815
+ to emit and cannot say where, so no amount of ramping could have fixed it. The
816
+ box is now clipped at the waterline — measured with the camera on the surface,
817
+ `leaves/above` spawns from −0.20 up and both `underwater` presets from −0.20
818
+ down, against a surface at −0.20. Same lesson as the biped's plane-vs-volume
819
+ submersion test, one layer along: a medium with a boundary needs a volume with
820
+ one too.
821
+
822
+ - **World-placed dialogs rendered MIRRORED** — the death/respawn panel read
823
+ "NWOD", and so did the pause panel in any scene using `placement="world"`.
824
+ Reported from a headset, but not headset-specific: it was mirrored flat too,
825
+ and had been since 0.7.2. A Babylon plane's visible face is local **−Z**, so
826
+ aiming `atan2(dx, dz)` at the eye — which points local **+Z** at it — shows you
827
+ the panel's **back**, and a `doubleSided` back reuses the front's UVs rather
828
+ than vanishing. Hence a mirror instead of a missing dialog, which is why it
829
+ survived a release. Now `dialog-placement.facingYawDeg`, tested on the property
830
+ that matters: turn the plane's visible face by this yaw and it points at the
831
+ eye. The same change fixes touch, which was mirrored with it — every
832
+ right-aligned control on a world dialog was mapping to the left.
833
+
834
+ - **The pause/continue panel is DOM on a flat screen**, and stays an in-scene
835
+ plane in VR — the scene panel's rule (one widget list, two presentations)
836
+ applied to the modal that most needed it. Tonio: _"in flat 3d the continue
837
+ should be presented in the dom like the scene panel."_ On a monitor the DOM
838
+ overlay is better on every axis that has bitten this panel: it cannot be
839
+ occluded, cannot be put somewhere odd by a line-of-sight cast, needs no raycast
840
+ to be clicked, and is exactly where you are already looking. All that machinery
841
+ exists because a headset has no DOM, which is the only place it earns its cost.
842
+ The same `panel3d` SVG serves both — its widgets' listeners work natively in
843
+ the DOM, and `handlePointer` serves the in-scene path.
844
+ - **Death and world-placed dialogs now survive a floating-origin rebase.**
845
+ Neither had opted in, so in a terrain scene — the only kind big enough to
846
+ rebase, which is why it hid — a shift mid-death slid the spectate camera, the
847
+ fire emitters and the dialog away from the crash while the world moved out
848
+ from under them. That reads exactly like the origin-teleport fixed above ("the
849
+ wreck is way off, I am looking at nothing"), and two causes producing one
850
+ description is how a fixed bug looks unfixed. Both use an origin LISTENER
851
+ rather than `registerWorldRoot`, because both hold world coordinates in JS and
852
+ the element owns the transform (shifting the node would be undone next render).
853
+ The falling wreck needed nothing — it re-reads its position from the node every
854
+ frame, so a shift is absorbed.
855
+ - **Leaving VR now pauses in EVERY scene**, not only those with
856
+ `enterXrOnResume`. Taking the headset off is a departure, not a view change.
857
+ The gate existed on the reasoning that a flat scene which merely visited XR
858
+ shouldn't acquire a pause panel on the way out; superseded by the symmetry,
859
+ which is the stronger argument (Tonio: _"just as entering VR should unpause,
860
+ exiting VR should probably pause"_) — one gesture means resume, its inverse
861
+ means stop, and a rule you opt into is not a pair. `enterXrOnResume` still owns
862
+ resume → enter.
863
+ - **The biped got stuck on slopes** — the cost of the fix below, found and fixed
864
+ in the same session. Snapping the feet onto the surface put the collision
865
+ ellipsoid's bottom exactly ON it, so moving into rising ground embedded it and
866
+ Babylon refused the move. The old 15 cm float had been acting as the clearance.
867
+ The body now starts a step above the feet (`STEP_OFFSET`, which is Unity's Step
868
+ Offset): anything lower than a step is walked over, the probe stands you on it
869
+ afterwards, and a wall is still a wall.
870
+ - **The biped floated above or sank into slopes, and never corrected.** Tonio,
871
+ on a long-standing one: _"you often end up a little offset from the ground …
872
+ this never really corrects. It just gets randomly messed up again when you
873
+ navigate another slope."_ Not random — the ground check was a **dead band with
874
+ no corrective term**: it probed 0.15 m down from just above the feet and, if
875
+ it found anything, did nothing. So the biped fell until the probe happened to
876
+ see ground and then stopped wherever in that 0.15 m window it landed, and any
877
+ error inside the band was permanent.
878
+
879
+ Slopes made it worse both ways. Going up, `moveWithCollisions` slides the body
880
+ up the ellipsoid and leaves it high in the band. Going down — "especially
881
+ down" — gravity moved at most `min(0.1, 9.81·dt)`, a hard **0.1 m per frame**
882
+ clamp, so a brisk descent outran it and it floated the whole way.
883
+
884
+ Now it probes a step up and a step down and puts the feet **on** the surface,
885
+ and a real fall accumulates velocity instead of moving a fixed amount per
886
+ frame (with the probe extended by the frame's fall so it cannot step over the
887
+ ground between frames). Measured on the b3d demo scene: **390 consecutive
888
+ frames walking a slope at offset 0.00000**, where before the error wandered
889
+ freely inside a 15 cm band. The probe also goes through `collidable()` now, so
890
+ a UI panel is no longer something you can stand on.
891
+
892
+ - **Dying in VR teleported you to the world origin.** Tonio: _"I collided with
893
+ wreckage high up and respawned at the origin or starting point with the wrecked
894
+ plane hanging in mid-air off in the distance."_ He was not moved away from the
895
+ wreck — the wreck stayed exactly where he died (measured: a corpse drifts 0.05 m
896
+ in 10 s); **he** was moved. `releaseFocus()` nulls the focused entity, so the
897
+ next XR frame finds nothing piloted and falls through to free locomotion, which
898
+ did `rig.parent = null`. That KEEPS THE LOCAL POSE and reinterprets it as world,
899
+ so a rig at local `(0, 2, −5)` behind its parent lands at `(0, 2, −5)` in the
900
+ world. Now `rig.setParent(null)`, which preserves the world transform.
901
+
902
+ It had been latent since 0.7.0 as the cockpit-only oddity in `TODO.md`
903
+ ("the aircraft was moved way away from me"); parenting the chase rig — the
904
+ jitter fix above — made it reachable from the view people actually fly in,
905
+ which is how a five-month-old note finally got diagnosed. Both spellings are
906
+ now pinned in `babylon-orientation.test.ts`, because they look interchangeable
907
+ and the wrong one is shorter.
908
+
909
+ - **`B3dControllable.halt()`** — tell an entity it is dead, rather than relying on
910
+ `releaseFocus()` nulling its input provider and `_update` short-circuiting on
911
+ that. `b3d-death` calls it. Belt and braces for the focus-managed case; the only
912
+ thing that stops a `die()` handed an entity the focus manager never held.
913
+ - **A wreck hung in the air where it died.** Tonio, from a headset: _"I collided
914
+ with wreckage high up … the wrecked plane hanging in mid-air (it should really
915
+ tumble to the ground)."_ Hanging wreckage is worse than untidy — it is a solid
916
+ object in the sky, so the debris of one death becomes the cause of the next.
917
+ A wreck now falls: a tumbling ballistic descent on the velocity it died with,
918
+ one bad bounce, a skid, and a rest. The fires and the spectate camera go down
919
+ with it (a smoke column left at the kill point with the wreck 100 m below it
920
+ is worse than no fire at all). Rules are pure and tested in **`wreck-fall`**;
921
+ the spin is DERIVED from the velocity, never random, so the same crash looks
922
+ the same twice.
923
+ - **How you died decides how far it goes.** Flying into something is an
924
+ inelastic collision that eats most of the energy (`carry: 0.25`); being shot
925
+ down leaves you with nearly all of it (`0.7`). At full carry a 90 m/s crash
926
+ from 130 m travelled ~450 m before landing — a glide, not a crash, and it
927
+ dragged the spectate camera across that much terrain. Tonio: _"The plane
928
+ went flying off into the distance … pretty funny but not as expected."_
929
+ - Drag settled at `0.005` (≈44 m/s terminal). `0.02` was ten times too much (a
930
+ wreck shed its speed in half a second); `0.002` is the figure for a
931
+ STREAMLINED body, and a broken airframe tumbles broadside. Each was
932
+ plausible in isolation; only a real crash told them apart.
933
+ - New seam **`B3dControllable.getWorldVelocity()`**. `b3d-aircraft`'s own
934
+ `velocity` field reads ZERO in wing-borne flight — the fly-by-wire path
935
+ moves the node directly — so a wreck launched from it dropped straight down
936
+ out of a 60 m/s dive.
937
+ - A wreck with **nothing under it** (the edge of a finite ground, a kill over
938
+ open water) is abandoned after 1500 m rather than falling forever with the
939
+ camera chasing it down. Found by killing outside a 600 m ground plane: it
940
+ reached y = −25 and kept going.
941
+ - **The VR chase camera jittered and drifted with speed** — Tonio, in a headset:
942
+ _"throttle up the aircraft gets further away, throttle down it gets closer",_
943
+ and it jitters climbing, diving and turning. Three faults, all in the XR rig
944
+ and none in the flat one (which measures rigid to 0.2 mm):
945
+
946
+ - It was **not parented**. The XR rig runs in `onXRFrameObservable`, which
947
+ fires BEFORE `scene.render()`, while an entity moves in
948
+ `registerBeforeRender`, which fires inside it — so the rig was positioned
949
+ from last frame's aircraft position, every frame, and a variable frame time
950
+ turns that fixed lag into jitter.
951
+ - It **eased toward a world-space target**, and a first-order tracker sits
952
+ `v/k` behind — ~6.8 m at 61 m/s, proportional to speed. Hence the throttle
953
+ symptom.
954
+ - The easing used `lerp(a, b, k*dt)`, which is **not frame-rate independent**
955
+ (`1 - exp(-k*dt)` is).
956
+
957
+ The rig is now a CHILD of a level position+heading anchor the entity updates in
958
+ the same tick it moves, at a fixed local offset, with no easing at all — rigid
959
+ by construction rather than by good timing. New seam:
960
+ **`B3dControllable.getChaseAnchor()`**, `null` by default; `b3d-aircraft`
961
+ provides one. Entities without an anchor keep the eased path, now with the
962
+ correct exponential form.
963
+
964
+ - **All three panel sites now aim themselves the same way**, through
965
+ `dialog-placement.faceViewer`. `frame-panel` and the XR settings panel used to
966
+ face you with their BACK and cancel the resulting mirror with
967
+ `tex.uScale = -1` — the settings panel also flipping `1 - uv.x` on every pick,
968
+ two compensations for one avoidable cause. Both are gone. No visible change
969
+ (they were correct on screen and under the finger); it removes the trap that
970
+ produced the world-dialog bug above, since the knowledge lived in a comment in
971
+ two files and comments do not travel.
972
+ - **`rollDeg` keeps its meaning**: turning a panel around reverses how a roll
973
+ reads, so `faceViewer` negates it. Worth stating because reasoning said the
974
+ flips cancelled and they do not — the only roll in the library is `180°`,
975
+ which is symmetric and agrees with the wrong answer.
976
+
977
+ ### Added
978
+
979
+ - **`<tosi-b3d-interactive>` — a mesh you can touch** (#36), the substrate doors,
980
+ knobs, switches, levers, consoles and lamps all stand on. `b3d-button` is a
981
+ floating Babylon GUI widget; this is the other thing — world geometry you reach
982
+ out to. Requested by tosijs-3d-ensemble, whose framing was fair and lands:
983
+ _tosijs-3d has almost nothing for building a PLACE, as opposed to a battle._
984
+ Point at a mesh (or a named sub-mesh — the knob, not the door), reach it, use
985
+ it; `hover` / `unhover` / `activate` / `refused` arrive as bubbling events or
986
+ `whenX` callbacks.
987
+ - **One implementation, flat and immersive.** Babylon routes XR controller rays
988
+ through `scene.onPointerObservable`, the same observable a mouse feeds, so
989
+ there is no XR branch and no second path to keep in step.
990
+ - **Composition, not a god-feature.** `vetoes` is the seam ensemble asked for:
991
+ a `lockable` pushes one, `interactive` never learns what a lock is, and the
992
+ refusal NAMES the refuser — the difference between a locked door and a broken
993
+ one. Vetoes run at activation, not at hover, so a locked door still highlights
994
+ and still reports being tried; silence is a bug report.
995
+ - **It never touches the transform.** An element that manages a node owns its
996
+ transform (the rule #35 was a violation of), so this reads the scene and never
997
+ writes to it. A door that opens moves its ELEMENT.
998
+ - **`interaction.ts`** — the pure, Babylon-free rules (`interactStep`,
999
+ `activationVeto`, `withinReach`), unit tested, so what counts as "you used it"
1000
+ can be argued about without a scene. Activation is press-then-**release** on the
1001
+ thing, because a press you drag off and release elsewhere is how anyone recovers
1002
+ from touching the wrong thing — and aim wanders more in a headset, not less.
1003
+ - **`InteractiveBehavior`** — the attachable form, for anything that already owns
1004
+ a mesh (a loader, a biped, a vehicle), plus `nearestInteractive` / `useNearest`
1005
+ for the "walk up and press E" control that wires to `ControlInput.interact`.
1006
+
9
1007
  ## 0.7.2
10
1008
 
11
1009
  ### Fixed