tosijs-3d 0.7.2 → 0.7.4

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