Changelog
All notable changes to tosijs-3d. This project is pre-1.0 (0.x), so minor
versions may carry breaking peer-dependency changes — each is called out in a
⚠️ Breaking block in its version section below, with what a consumer must do.
0.8.1
⚠️ Breaking
Nothing breaks at compile time. Two behaviours change, and Migration.md has the before/after for each.
- Aircraft guns are direct-hit, not area-effect.
gun-mode="blast"restores the old behaviour;gunBlastRadius/gunFullRadiusare attributes now rather than hardcoded. activationVetotakes an optional second parameter. Existing callers compile unchanged, and a veto that ignores its argument behaves identically. When the info is omitted a reach veto now refuses rather than passing — see Migration.md for why that direction, and how to say otherwise.select3dno longer draws ‹ › stepper arrows. The popup it always had is now the only way in, and is discoverable.
Changed
- Aircraft guns damage what they pass through, instead of firing an area
effect (#23). Blast damage resolves by distance to a destroyable's
registered point — ONE point, at the model's origin — so scaling a fighter 4×
puts its wing outside a 1.5 m blast and point-blank fire does nothing, with
no error. Direct-hit damage has no length scale in it and is immune to that
whole class.
gunMode="blast"restores the previous behaviour, andgunFullRadius/gunBlastRadiusare attributes now rather than hardcoded. - A disposed mesh now takes its own materials and textures with it.
mesh.dispose()leaves them behind, so every scene child that built one leaked it on every teardown — including every RE-PARENT, since a move disconnects the child while the scene deliberately survives. Measured: re-parenting one scene six times took materials 3 → 15 and textures 3 → 21, unbounded, meshes correctly flat.disposeMeshTreeCHECKS before disposing — a glTF file shares one material across many meshes and a disposed material still answersisReady(), so a wrong sharing rule turns siblings black silently. Textures held by the scene environment, a render target or a light's gel are exempt, because nothing references those from a material. - A blast finds every destroyable, not every
<tosi-b3d-destroyable>.detonateWarheadqueried one tag name, sob3d-loadermodels — which attach the same behaviour — were invisible to every explosion in the scene. The registry now lives onDestroyableBehavior, so anything attaching one is a target by construction.
Fixed
A second headset pass found the rest of these. Between the two, every one was invisible to a green suite — and the ones on panels were all reproducible flat, which is now written down rather than re-learned (CLAUDE.md, "A PANEL IS NOT A HEADSET-ONLY SURFACE").
- We were eating other people's keystrokes.
KeyboardGamepadlistens onwindowso you can steer without clicking the canvas first, and the on-screen keyboard listens there so an in-scene field types with no DOM focus — the only way that works in a headset. Both thenpreventDefaultwhat they claim, with no notion of where the event came from.ais a strafe key, so typing "lamp" into a search box lost the character AND strafed the demo, on any page carrying a scene.isTextEntry(event)is the one predicate all three listeners consult. An input you PRESS is not typing — space on a checkbox stays a press — and key RELEASE is deliberately unguarded, or a key pressed on the canvas and released in a field would stay down forever. - A popup is a popup, not something its panel crops.
select3d's menu andopenMenu3dwent to the panel-bounded path, so a menu was squeezed into whatever room was left under its own row. Only the keyboard calledshowLayer, because someone knew it existed.showPopupnow prefers a layer and keeps the bounded path as its fallback, so the fix reaches every caller;boundedPopupremains for a popup that must not escape. Picking an option dismisses it —closePopup()is the panel's own tracking and knows nothing about layers, so a menu set the value and stayed standing. - An in-scene panel's popups open on their own plane. A panel rasterised
onto a plane is not in the document, so the DOM layer cannot auto-install and
every popup took the cropped path.
panelScenehad registered a scene layer since the keyboard needed one; the<tosi-b3d>settings panel never did. The placement maths is extracted topanel-layerrather than copied — a formula at three sites, fixed at one, is a mistake this repo has already paid for. - A popup is drawn at its panel's scale. It took
width * 0.95whatever it contained: right for a keyboard, grotesque for a 160-unit menu at twice the text size of the thing that opened it. One viewBox unit is now the same physical size on a panel and its popup. - A press aimed at a popup reaches the popup. The XR panel's re-pick ignored what the ray actually hit and forced a pick against itself, so a press on a popup in FRONT of it was routed to the panel behind — close and move never saw a thing. An occluder that is itself UI now wins.
- No empty band above a flat popup. 30px was reserved for the move/close glyphs an in-scene popup draws; a DOM layer draws none. Reserved only when a mounting host actually draws chrome.
- The keyboard's × means "put it away". It closed only the presentation you pressed, leaving the other up and the field still believing it had a keyboard open — and left the mode on, so the glyph stayed lit over nothing and the next press flipped the preference instead of reopening. A takeover is still not a dismissal: swapping layouts between fields must not darken the glyph.
Migration.md's first block ran as a live example. The doc system executeshtmlfences, which is the natural fence for illustrative markup. Nowxml, anddoc-examples.test.ts— which scanned onlysrc/*.ts— fails on any live fence in a published markdown doc.
Five of these came from one headset pass on the terrain editor, which is the
argument for RELEASING.md step 5a in one paragraph: every one of them was
invisible to 2300 passing tests, and the first was invisible to a day of
reading the source.
A pinned panel header was clipped away — invisible, but still hittable. The header was appended inside the clipped body group, whose clip rect necessarily starts below it, so it was clipped in full while its band stayed reserved and the pointer router kept routing to it. Three symptoms, one cause: the Exit VR / Re-seat / Pause buttons vanished, the panel grew a tall empty band, and pressing where they should be worked. ⚠️ It only bit a panel that SCROLLS, because
clip-pathis only applied then — so every small panel in the demos and tests was fine and the terrain editor was not.An icon bar ran off the edge of its panel.
layout()ignored the width it was handed. A scene's bar is Exit VR + Re-seat + Perf Stats + one per registered debug source + the gadgets — ten items, 380px, in a 296px panel. It wraps now, and the hit test readsyas well asx.Noise
scalesliders are LOGARITHMIC. They are frequencies: on the old linear 0.005–0.3 track the whole usable band was the first 15% and the default sat at 3%, so every value you actually want was crushed against the left stop. The schema has saidx-scale: 'log'all along andterrainEditor3dhonours it — the hand-rolled demo panels did not, which is how it shipped.A scrolling panel has a scroll RAIL. Scrolling began on empty space, and a panel full of controls has none, so in a headset there was no gesture that scrolled it at all. The rail is also the only visible sign that there IS more below — a texture on a plane has no scrollbar, no overflow shadow and no momentum. Rows are measured twice so it never overlaps a control.
A slider keeps a usable track however narrow its row. The label took a flat 45% and the readout took whatever it needed, leaving a 53px track at 282px wide with a unit in the format. The track reserves a minimum now and the label yields, clipped so it cannot paint over the control it gave way to.
operablecontradicteduseNeareston the same door. With no pointer on it the getter knows no distance, so a reach veto blocks — whileuseNearest, which measures the distance itself, opens that same door 1 m away. Nothing in the behaviour knows where your actor is standing, sooperableFor(info)is the question to ask for a "press E" prompt; it runs the same veto pass asactivate(), so a check and the act cannot disagree.A terrain attribute change no longer rebuilds the whole world every frame, and a retained tile is the cell it claims to be. tosijs queues one render per animation frame and
slider3dwrites on every pointer move, so an attribute-driven rebuild that cleared the tile pool and refilled it unbounded cost a FULL rebuild per frame for as long as you dragged — ~50 ms of noise alone at the high tier, ~6.3 ms at the quest tier inside a 13.9 ms VR frame, before skirts, normals and the vertex uploads. It now marks the pool stale and re-cuts under the ordinaryfillBudget/tileBuildMscap, so the world morphs instead of blinking out.regenerate()keeps its documented unbounded behaviour, and now adopts the generation key so "set the attributes, then callregenerate()" stops paying for two rebuilds.Retaining tiles rather than clearing them exposed a second thing: a tile's key is
(level, gx, gz)and says nothing about SIZE or PLACE, so after atileSize/horizScalechange an old tile key-matched a desired cell that was a different size somewhere else — and the world came out gapped and wrong-scale while the streamer reported itself finished. A retained tile now adopts the whole desired cell and marks itself stale when its geometry moved, so the two rebuild paths agree about what the world is.disposeMeshTreecould dispose a texture a library model was still drawing. The material guard asksscene.meshesand was right; the texture guard askedscene.materials, which cannot see a library-instantiated model's materials — those belong to theAssetContainerwhile their meshes are in the scene. A texture shared with a hand-made material was disposed under the library model, which went black, silently. The in-use set is now derived from the meshes that remain.The attribute index lost three elements' documentation to one legend table. The generator rejects a table whose header names an identifier (
touch-gamepadmapsdata-partvalues to gamepad fields — its rows are values, not attributes), but applied the rejection to the whole DOCUMENT on the strength of the first table it found.b3d-lampopens with a capability matrix, so the point, spot and area lights all shipped bare attribute names with no default and no prose. Each table is judged on its own header now.operableanddebugStatecontradicted what a pointer press actually did. Both were routed through the same helper as a scriptedactivate(), which reports an unknown distance asInfinityso a reach veto fails closed. That is right for an activation and wrong for an inspection: a hovered door reportedoperable: falseandout-of-reach:blocksat the moment a press opened it. While the pointer is on it they now judge with the real hover, anddebugStateprints the info it judged with — it is the only debug readout that exists in a headset.The attribute index ships inside the package.
static/attributes.txtandattributes.jsonare infilesnow, so an agent already in a consumer project readsnode_modules/tosijs-3d/static/attributes.txtwith no network and no proxy. It was published to the doc site only, which is the one place the actor it was built for cannot reach.b3d-destroyablecould not place a library piece properly — three reports fromtosijs-3d-ensembleagainst one function, and one bug family: the async load treated its callback as if nothing could change underneath it.- Rotation was dropped (#48). Only x/y/z reached
instantiate. It looks like it should not matter, sinceAbstractMesh.render()syncsrx/ry/rz— but that is a COMPONENT render, and it had already run by the time the async callback assignedthis.mesh. Position applied, rotation did not. Now forwarded, and the transform is re-synced once the node exists. - No scale (#47).
sizeis the placeholder cube's edge length and does nothing oncelibraryis set, so a placed model could not be resized at all — ensemble measured a piece at 5.273 units forscale1, 2 and 4 alike. Newscaleattribute, uniform, applied to the instance root. - A removed element orphaned its node permanently (#49). Remove it inside
the load window and the disconnect handler found nothing to dispose, then
the pending callback built a node owned by no element — a ghost nothing
would ever move or free. Guarded with
loadGeneration, the mechanismb3d-aircraftalready used; the retry poll stops on a stale generation too.
- Rotation was dropped (#48). Only x/y/z reached
Two engines on one canvas are now impossible, and a lost context says so (#56). Building an engine over a live one disposed nothing and failed silently — the first context's shader programs go invalid while every uniform still reads back correctly, which is why it presented as four different bugs (white meshes, dark sky, empty scene, half-loaded scene). It now disposes the old engine and warns. Separately,
webglcontextlostis reported to the console and the debug ring instead of just rendering wrong.The flat scene panel now closes on a background press, like every other transient overlay here. A press inside it, or on the gear, still does not — the gear toggles, so closing first would make it look inert.
A still sky never found a late sun (#55).
updateSkywritessunPosition,rayleighandturbidityonly when a sun element's light exists, and the frame gate re-ran it only whentimeOfDaymoved — sorealtimeScale: 0, which is what a reproducible authored scene wants, ran it once and came up dark about four loads in five. The defaultrealtimeScale: 10hid it, because a drifting clock reopens the gate until some pass catches the sun. The retry is now gated on the OUTCOME rather than the clock, and bounded so a scene with no sun does not retry forever.A flat popup could open off-screen with no way back (#57). The DOM layer set
left/topstraight from the anchor with no bounds check, so a field in a right-hand panel opened a keyboard 112px past the right edge — rightmost column of keys unreachable, and unrecoverable, because the move/close chrome is drawn by picking and picking exists only in the scene. It now places throughplacePopup, the same pure flip/clamp the in-scene presentation uses, and the holder can be dragged — which is the better fix, since only the person typing knows what the popup is covering. A press that lands on a widget still belongs to the widget, or you could never type.chaseDistance/chaseHeight/chaseMinHeight/hudSizeaccepted a write and did nothing (#43). All four were consumed when the rig and HUD plane were built and never read again, so a settings slider bound to any of them read back correctly and moved nothing — ensemble measured bit-identical framing across a 3× change. Worse than an ordinary no-op becausechasePitchFollowon the same element IS live, so the element looked responsive and only some of it was. The chase geometry now comes from one path used at build AND every frame; the HUD remounts when its geometry changes, instead of latching on a boolean.select3dlooked like a cycler (#37). It has opened a popup list all along — nothing said so, so a consumer counted 23 taps to reach the 24th option. It now draws a downward chevron, the whole control opens the list, and the arrows are gone. Stepping survives only where a panel gives no host and a menu is impossible.An icon name colliding with
Object.prototypethrew rather than falling back —iconGlyph('constructor')and friends. 0.8.0 was the first release where table DATA becomes an icon name, with nocatchin the repaint path. Fixed at all three unsafe sites, includingregisterIcons, where__proto__was an assignment that would have corrupted the map rather than failing.spinner3dleaked into a module-global ticker.Widget3dgaineddispose?(), andB3dreleases the widget set it replaces on each repaint. Reaping byel.isConnectedwould have been wrong: an in-scene panel's SVG is never attached, so a visible VR spinner would have been culled.Panels size to their content, the in-scene icon bar is pinned, and the theme editor no longer follows you between demos (from the 0.8.0 headset pass).
Added
A spatial panel can be dragged, and the drag is aimed at where you POINT.
orbitFromAimwalks the controller ray to the sphere's radius and asks which way that point lies from the anchor. Using the ray's DIRECTION instead treats your hand as if it were at your eyes: the panel sat where the arithmetic said rather than where you pointed, and hand wobble swung it by the whole head-to-hand offset.The seat lives on the element, so a structural refresh — pressing Pause, say — no longer snaps the panel back to its default. A seat names an EDGE and the pin BLENDS across the horizon: a hard switch moved the panel by its entire height in one frame as you dragged through eye level. The grip is
iconGlyph('move'), the same mark a torn-off popup already uses.A spatial panel can be dragged, on the sphere around its rig anchor. Two properties fall out rather than being built: the distance cannot drift (so it can never be shoved into your face or out of reach) and the facing comes free.
panel-orbitis the pure half — no Babylon, no DOM — so the placement rule is testable without a headset.A drag past a limit follows you and stiffens, and letting go springs back: a hard stop is the right resting behaviour and the wrong live one, because a panel that simply stops following your hand reads as a dropped drag rather than a limit. The band and the clamp deliberately disagree — the panel may PASS a limit and may never come to REST past one, since out there its own Exit VR and Re-seat buttons are hard to reach.
A seat names an EDGE, not a centre: below the equator it is bottom-relative and above it top-relative, so a tall panel's extra height always grows back toward eye level instead of trailing into the floor or pushing past the ceiling it was just clamped to. The ceiling is 60°, which is the default seat exactly, so the panel can only ever be dragged somewhere more comfortable than where it started.
budgetedReach(reach, tileSize)andMAX_TILES_ACROSSare exported fromtosijs-3d/terrain-grid, so a UI can clamp its own slider against the same limit<tosi-b3d-terrain>enforces rather than guessing.reach×tileSizeis the footgun and neither looks dangerous alone: reach 5000 at tileSize 10 is a million finest tiles, which is the slider that killed a tab (#66) and got capped at "400 m" as a guess. Pure, no scene needed. The element's own_budgetedReachis now a call to it, so they cannot drift.A manipulator — translate, rotate and scale handles you can grab (#38), ported from
tosijs-3d-ensemblewhere it was shaken out against real authoring, including on a phone. Three files on the usual split:manipulator.ts(pure — the five grip readings, snapping, the drag reducer),manipulator-view.ts(the Babylon handles, with the two-pass pick and the euler composition folded in so a consumer no longer writes either), and<tosi-b3d-manipulator>. The flat pointer is wired;grab/grabNear/drag/releasetake world rays so a controller or a hand drives the same code. Notemove/turnrather thantranslate/rotate:translateis a realHTMLElementproperty and declaring it gives one word two owners.<tosi-b3d-beacon>— a clickable hull for anything with a position and no geometry: a lamp, a positional sound, a spawn point (#70). Invisible viavisibility = 0rather thanisVisible = false, which is the whole trick — Babylon's default pick test isisEnabled && isVisible && isPickable, so the obvious way to hide a hull makes it unpickable, and the failure returns whatever is behind it rather than nothing.beaconOwner(mesh)is the entire reading-side API.angle3dandarc3d— dial a bearing, dial a firing arc (#71). An angle on a linear track has ends where the quantity does not, so 359° and 1° sit at opposite ends of the slider.arc.tsis the pure model, and an arc is a CENTRE and a WIDTH rather than a start and an end — which makes "is this bearing inside" and "does that arc contain this one" one line each, and makes straddling north unrepresentable rather than special-cased. An envelope is drawn, not merely clamped against, so a turret's restricted arc explains the ship instead of silently snapping.picker3d— choosing one of five hundred (#37). Atablefor the virtualized filterable list, aninputFieldfor the query, aselect3dfor the family: two levels without a tree, because a tree needs a pointer to expand and two flat controls work from a D-pad. Filtering is AND across terms over the whole name, so word order cannot matter.terrainEditor3d— a terrain editor built FROMsceneSchemas.terrain()(#66), so a range cannot disagree with the element it edits. Curated, not generated: most of the schema's thirty properties are performance internals, and a panel showing all of them is a settings dump.FieldType: 'color'and a swatch oninputField(#72). Tolerant in, canonical out —#abc,redandrgb(255,128,0)all commit as#rrggbb, and a five-digit hex is valid WHILE TYPING and refused at commit rather than silently accepted. The swatch holds the last good colour while the text does not parse, so a half-typed value does not read as a lost one.One wind for the scene (#73) —
windSpeed,windBearingDeg,windGuston<tosi-b3d>, withwind="scene|own"on clouds, water and ambient. Clouds, water and leaves each carried their own wind in three spellings and could silently disagree.wind.tsis pure: Cartesian inside, degrees at the edge, and that IS the composition rule — a valley funnelling, a lee sheltering and a headland curling are all just contributions that add.Combat attribution (#8) —
Cause {by, kind, via, hops}ondamagedanddestroyed, threaded through chain links,tickanddeathBlast, and stored on theDestroyableso a reaction a frame later can still ask.byis the ORIGINATOR however many hops back, because re-attributing each hop to the thing next to it launders the credit away. Measured on the 48-drum cascade: one hit, 48 kills, all credited, hop counts 1/3/5/7/9/11/6/6.b3d-aircraftcan be damaged and locked (#23) —destroyable="on"attaches the same behaviourb3d-loaderuses, andblip="on"mounts a radar blip, without which an enemy cannot be missile-locked and a player reports that "missiles don't work".A veto is told about the activation (#36) —
blocks(info)receives{actor, source, distance, …}, so one door can answer differently for an NPC holding the key and a player who is not, and can tell a hand within reach from a ray across the room. A veto that ignores its argument is unaffected.A configured terrain now draws itself (#66).
<tosi-b3d-terrain>built a tile pool and filled it only when told, so a terrain with its attributes set rendered nothing until someone calledregenerate()— a silent failure that every consumer worked around with their own bounded retry. Ordinary attribute changes now regenerate on the next frame, like every other element. Keyed on the GENERATION attributes only, so awireframetoggle stays a material tweak, and relying on tosijs's own render batching for bursts rather than reimplementing it — five writes in a task are already onerender().b3d-turretandb3d-launchertake alibrary(#34), so a piece that IS a turret no longer renders as a cylinder and a box. Which node aims is declared by the MODEL — a_barrelsuffix, resolved like_centerOfGravityand the collider suffixes — because a rigger declares moving parts in Blender, not in scene markup._muzzleis separate and says where rounds leave: the same node on a simple gun, a different one on a multi-barrel mount. A model with neither still works — it yaws as a unit and the muzzle falls back to the existing forward offset.b3d-spawnercan spawn at an authored PLACE, not only on a ring around the player (#40).anchor="place"plusx/y/zandfacingDeg— a carrier deck, a hangar door, a dungeon entrance. Mutually exclusive with the distances by construction rather than by warning, and a placed spawn does not consume the RNG, so adding a launchpad cannot shift the encounters in a scene that promises "same seed, same battles".<tosi-b3d-prop>— a library model in the scene and nothing else (#68). Every existing route to a library-backed mesh brought behaviour with it: a plain prop had to be a<tosi-b3d-destroyable>carryingcapacity,armor,explodeand a combat id for something nothing will ever shoot.It matters more than a convenience element, because it is the difference between the CDN's 5,108 models being reachable or not from a page that cannot run JavaScript. Every model in those kit libraries sits at the origin with no translation (the format is built for
clone()), so a consumer with a JS build reaches all of them and a consumer writing declarative HTML reached none.library-urlcovers the one-model case without a separate<tosi-b3d-library>and atypehandshake, sharing one library element per URL so ten props do not fetch the kit ten times.Collision GROUPS —
markCollisionGroup(mesh, 'water')pluscollidable(reject, { ignoreGroups: ['water'] })(#44). The singleb3dNoCollideboolean answered "does anything hit this", which is one answer for every asker — and a sea needs two, since "the aircraft should not treat water as ground" and "shells should splash on water" were the same switch. Groups move the answer to the ASKER: the mesh says what it is, each mover says what it treats as solid, so one sea answers differently for a seaplane and a submarine on the same frame. Additive, and composes with the existing UI exclusion and reject hook rather than replacing either.b3d-waternow tags itself'water'.color3d— a colour picker that exists in a headset, andcolor.ts, the pure model under it (parse/format, RGB↔HSV,contrastInk; no DOM, so a consumer can validate a document's colours headlessly). This was the one control the SVG UI did not have: the only two ways to pick a colour were<input type="color">(which cannot express alpha) or a consumer injecting one, and neither exists in an immersive session — so the theme editor, of all things, had no colour control in the presentation this library is for.Colour icons for scene concepts —
sky,sun,water,terrain,fog,mesh,decor(#64). Unlike the stroked/filled sets these carry literal colours and nocurrentColor, so they do not follow the theme.
0.8.0
Lights, editors, and the schema surface tosijs-3d-ensemble asked for — plus
the two renames that were waiting for a minor.
⚠️ Breaking
Peer dependency:
tosijsis now^1.9.2(was^1.7.8). Upgrade tosijs alongside this. The floor moved because 1.9 fixes five issues we filed (tosijs#14/#15/#22/#24/#27) and holding it down only defers the work.Behaviour that changed with it, verified rather than assumed: a wrong-typed write to an
initAttributesprop used to be silently discarded and now warns and applies the value. Sofoo: falseon an'on'|'off'attribute genuinely setsfalsewhere it used to quietly default — useisOff(), which accepts'off',falseand'false'.Callbacks are
handleX, notonX.handleChange,handleClick,handleSelect. Both spellings work through 0.8.x and the old one warns once per name;onXis removed in 0.9.Not a style preference: these are plain factories today, but the moment one becomes a tosijs component the element creator binds an
on*prop as a DOM event listener and the class field is silently never called.handleXcannot be mistaken for an event name. Giving both, the new one wins.Migrated in this release —
inputField,keyboard,table,box/surface, popups, debug actions and the curve/footprint/vector fields. In other words: all of them.b3d-triggergainswhenEnter/whenExitinstead, because components usewhen*(see Fixed).
Added
handlerOf(handler-of.ts, exported from the barrel) — the shim behind thehandleX/onXrule, in its own dependency-free module so the smallest widget can adopt it without importingwidgets3d. Use it for your own widgets:handlerOf(config, 'handleChange', 'onChange')?.(value).Lamps —
b3dPointLight,b3dSpotLight,b3dAreaLight: a placed light with its own fixture geometry, which tracks the light (a fluorescent's bulb stutters as it strikes and dims to a red ember as it dies). Shadows where Babylon allows, gels where it allows (spot only), and a generated coneshape(circle/square+shapeAspect,shapeSoftness) plus a trueinnerAnglepenumbra.Light programs — one curve per channel (brightness, hue, saturation, range) split into attack / sustain / decay by two markers.
durationdivides by those markers, so dragging one retimes the lamp. Presets:steady,fluorescent,flickery,brownout,candle,fireplace.timeSourcepicks the real clock (default — a flicker should not freeze when the world pauses) or the world's.Editors —
curveProgram3d(several curves sharing one pair of draggable split markers),lightEditor3d(the whole lamp), andcurveMarkers. Controlled, withhandleCommit(value, describe)once per gesture carrying canonical values and a verb phrase, so a document records one undo step.scene-schemas— JSON Schema for all nine scene primitives: skybox, sun, water, ground, fog, clouds, ambient, terrain, reflections (and the hemispheric light). No DOM, no Babylon. So nobody hand-copies our attributes: a consumer's hand-written skybox schema carried 6 of 16 and disagreed on a default. Drift is caught by a test, not promised.province-climate— water, temperature and volcanism as bipolar bias curves (0.5leaves the base alone) plus water presence and level as scalars, because "there is a lake here at 12 m" is not something a curve can say.spinner3d/progress3d— the widget set could say what a value IS but not that work was under way. The spinner animates as GEOMETRY, not CSS, because a keyframe animation does not run once the SVG is rasterised to a texture — it would look right flat and freeze in a headset.Filtered tables —
filter/setFilter(predicate)andcounts. A filter never drops a selection: hiding is a view state, and losing a selection to it would make typing in a search box destructive.tablebutton columns —kind: 'button'withmenu(row)opens an action menu anchored to the cell. The button takes the press instead of the row, so a row's ⋯ does not also select it.registerIcons— consumers add icons every widget resolves by name.Log sliders —
scale: 'log' | 'log2',snap, andzeroStopfor a quantity that spans decades and can still be switched off.Action menus —
openMenu3d,button3d({ menu }),iconGrid3dmenu cells;disabledtakes a predicate so a menu can be a constant.table—ColumnSpec.kind: 'icon'.
Fixed
A move no longer costs a scene, and a genuine removal now disposes the engine — a leaked WebGL context is not just memory (Chrome caps them near 16 and force-loses the oldest).
shadows, the gel andgeometryeach accepted a write and did nothing; all three now sync per render. Lamps also never marked shadow receivers, so a shadow map was rendered that nothing read.canonicalProgramsilently droppedduration, so a committed edit turned a flickering lamp steady.A nested widget's popup opened at the top of its container rather than beside the control.
lightEditor3dshipped demo copy into every consumer's panel; the hint is now opt-in.The aircraft chase camera sat too close after content moved to human scale.
themeEditornever called back. It readconfig.onChangewhile its callers passedhandleChange, so the theme table updated and the host's rebuild never ran — the controls moved and the panels they edit did not.handleChangeis now the option;onChangestill works, deprecated.A metric typed out of range left the slider on the cap and the number field above it, with the theme taking the out-of-range value — the exact drift a paired slider-and-field exists to prevent. Values are clamped now.
Two demos (
w3d-theme,keyboard) passedhandleChangetoinputField, which readsonChange— so typing updated nothing. Demo code lives in doc comments and is not type-checked, which is why this class of mistake reaches a page instead of the compiler.The
curve-programdemo was truncated on the published site. Its doc comment contained a nested/* … */, and block comments do not nest — the inner*/closed the page early, so roughly half the demo silently vanished.The in-scene panel was sized by a guess, and the guess was wrong for the one panel that mattered. Height was
46 + rows × 48, which assumes a row is about 48px.lightEditor3dis ONE row that lays out over 1200px, so its panel was built ~142px tall and you scrolled a postage stamp. It now sizes to its content (height: 'fit') with a cap, so the same panel is 620px — Tonio, from the headset: "crammed into a tiny tiny view so you have to scroll it constantly… I didn't try messing with curves because they were too far down."The in-scene panel's icon bar no longer scrolls away. It carries Exit VR, and a control you need in order to LEAVE is the one thing a headset panel cannot afford to lose.
panel3dgained aheaderoption — rows pinned above the scrolling body — which also ends a divergence, since the flat presentation has always pinned its bar by rendering it as DOM.The theme editor no longer follows you to other demos. It edits the global table deliberately (that is what makes both panels re-theme live), but the doc site is one page, so walking away left everything else wearing your palette. The demo now restores it on unmount.
distcould not be imported by Node at all — 394 relative specifiers were written without an extension (from './tosi-b3d'), which every bundler and Bun resolves and Node's ESM resolver does not. Reported bytosijs-3d-ensemble(#69) after they fixed the identical fault in their own package and the failure moved one level down into ours.Source specifiers now carry
.js— the TypeScript convention, passed through bytscand resolved fine by bundlers, so it works everywhere and keeps the per-filedistwe ship deliberately. That covers deep package subpaths too (@babylonjs/core/Misc/observable.js), which failed the same way. Verified by importing the built package under Node 24, not by reasoning about it.A test now asserts it of the source, so it fails at authoring time rather than after a publish — the only moment anyone was going to notice, since every loop here is a bundler or Bun.
Subpath exports —
import { validateLight } from 'tosijs-3d/light-settings'now works. The pure modules were split from their editors precisely so a consumer could validate documents headlessly, but the only export was the barrel, which defines custom elements on import and needsHTMLElement. So the headless half was still behind a browser. It is not now.⚠️ This is a wildcard (
"./*"→./dist/*.js), so every module indist/is now a supported specifier and covered by semver — renaming one is a breaking change from here on. 142 of them were already public through the barrel; the genuinely new surface is nine. The three superseded modules (reflections,dynamic-shadows,rippling-water) are explicitly blocked so the wildcard cannot resurrect them.handleXnow works on every callback option, andonXis deprecated. It used to depend on which widget you were holding —curve3dtookhandleChange,inputFieldtookonChange, and neither complained about the other — which is what made three of the bugs above possible. Migrated:table(handleSelect/handleActivate),box'sBoxChildandbutton(handleActivate),surface'sMenuItem(handleSelect),inputField(handleChange/handleEnter/handleFocus, on the object as well as in config),keyboard(handleKey/handleAction/handleCaretMove), popups (handleClose), and debug-source actions (handleClick). EveryonXstill works, warns once per name, and is removed in 0.9.b3d-triggergainswhenEnter/whenExit(components usewhen*, likewhenImpact). PassingonEnterto the element creator never set the field at all — it added a listener for the'enter'CustomEvent, so the callback got an Event whiletrigger.onEnter = fngot the trigger. Same callback, two different arguments depending on how you attached it.The
curve-programandcurve-fieldeditors did not exist inside a headset: both put their controls in flat DOM beside the canvas, which an immersive session does not render. Both now also build throughscenePanel.
Changed
- Doc pages are named for what they are (
Light program editor,Curve editor,Lamps) rather than for their module. URLs are unchanged. - ⚠️
shadows="on"is not free — one extra render of the caster list per lamp per frame, worse with a largerange. Preferofffor dynamically inserted content.
0.7.8
Lights you can see, edit and animate — and the editor surface tosijs-3d-ensemble
asked for, built to their spec so they can adopt it without writing widgets.
Added
Lamps.
b3dPointLight,b3dSpotLight,b3dAreaLight— a placed light that comes with its own fixture geometry (toggle it, scale it, bring a GLB, or parent your own to the lamp'snode). Shadows where Babylon allows them (point/spot;RectAreaLightis not aShadowLight) and gels where it does (spot only —projectionTexture, bitmap or SVG). The two gaps warn once rather than being faked.The fixture tracks its light: a fluorescent's bulb stutters as it strikes and dims to a red ember as it dies.
Light programs (
light-modulation). A lamp's whole life as ONE curve per channel — brightness, hue, saturation, range — divided into attack / sustain / decay by two markers. The seams cannot jump because there is only one curve, so the curve's value atattackEndIS the sustain level.⚠️ Contract: if
sustainEndprecedesattackEndthe sustain collapses to zero width ATattackEnd, deterministically. Stated because a consumer's validator reports it as a warning rather than an error, which is only correct while the behaviour is specified. Changing it is a breaking change.curveProgram3d— several curves sharing one pair of draggable split markers, as a single field.lightEditor3dwraps it with the lamp's static properties (power switch, type, colour, intensity, range, shadows).light-settings— a lamp as DATA: schema, canonical form, validator. No DOM and no Babylon, so it works in a headless runner.Curve interop, to
tosijs-3d-ensemble's specification:canonicalCurve(4dp, stable bytes),readCurve(accepts the bare array or a{kind, points}wrapper),validateCurve/validateProgram/validateLight(never throw;error/warningonly; relative JSON Pointer paths), andcurveSchema/lightProgramSchema/lightSettingsSchemaemittingx-widget+x-curve-kindfor panels generated from JSON Schema.handleCommitalongside the liveonChangeoncurve3d,curveMarkers,curveProgram3dandlightEditor3d— one commit per gesture, carrying canonical values and a bare verb phrase ('move attack split'), so a document records one undo step per edit instead of one per pointer-move.Log sliders.
slider3d({ scale: 'log' | 'log2' })gives every decade or octave equal travel, plussnapto quantise the value. For intensity, noise scale, grid size — anything where a linear track puts everything you want in its first few percent.Action menus.
openMenu3d/menu3d,button3d({ menu }), and amenuon aniconGrid3dcell.disabledtakes a predicate, so a menu can be a module-level constant instead of being rebuilt whenever its state moves.WidgetHostis now exported — it was unnameable from outside.
Fixed
- Lamps never marked shadow RECEIVERS, so a
shadows="on"lamp rendered a shadow map nothing read — a full extra render pass that presented as lights shining through solid objects. shadowsaccepted a write and did nothing. It was set up once at scene ready and never re-read, so toggling it in an editor had no effect.- A pale lamp could not be reddened. Rotating a hue preserves saturation and
the saturation curve is
[0,1], so it could only desaturate — "pale tube dies as a red ember" was inexpressible. AddedsaturationScale. - The aircraft chase camera sat too close after content moved to human scale; pulled back 30% with the framing angle preserved (0.13° drift).
- Log slider values no longer carry float noise (
0.010000000000000009), which matters because they are serialised and diffed.
Changed
- ⚠️
shadows="on"is not free, and the cost is worst where it is least visible: every such lamp is an extra render of the caster list, per frame, and largerangesweeps more of the world into it. Preferofffor dynamically inserted content. SeeTODO.md→ arbitration for inserted lights.
0.7.7
A lifecycle release. <tosi-b3d> now distinguishes being moved from being
removed, which fixes an engine leak and a scene-corruption bug that were the
same root cause seen from two ends.
Fixed
A MOVE no longer costs a scene (#58, and the real cause of the black sky in #51).
connectedCallbackbuilt an Engine and a Scene unconditionally, so re-parenting built a SECOND pair — and disposing the first deleted its materials' shader programs, after which the surviving scene rendered black while every uniform read correct andisReady()returnedtrue.Re-parenting fires
disconnectedCallbackthenconnectedCallbackin the same task, and it need not touch the element at all: moving any ancestor does it to every descendant. Ensemble hit this with a<tosi-b3d>inside their own shadow root, re-parented by the doc system — their MutationObserver recorded notosi-b3dadd or remove at all while two scenes were being built.Teardown is now deferred by a task, so a reconnect cancels it and reuses the engine.
A genuine removal disposes the Engine and Scene. Teardown released the XR helper, the quality subscription and the debug timers, but never the engine — so every removed scene left a live render loop turning over a detached canvas forever. A WebGL context is not just memory: Chrome caps them near 16 per page and force-loses the oldest, so an SPA that visits scene pages works for a dozen route changes and then a scene that was fine goes black.
This is also the better explanation for doc-site slowdown after visiting several demos, and possibly for headset degradation — N abandoned render loops compete for frame time, which retained VRAM does not.
whenReadyno longer drops queued callbacks. Asking for the scene and having it torn down before it was ready lost the callback silently — no call, no error, nothing to observe. It now survives and fires against the next scene.addSceneListeneris no longer handed dead meshes. Past additions outlived their scene, so after a rebuild a late subscriber received nodes belonging to the disposed one.
Added
b3d.whenDisposed(cb)→ unsubscribe — the other half ofwhenReady, and the half that did not exist. Fires on a genuine disposal, not on a move. Durable across rebuilds, so register once.Use it for anything holding a reference into the scene that a child's
sceneDispose()does not cover — a timer closing over a mesh, a material cache, an observer on a node. After disposal those point at a dead scene, and Babylon's failure mode there is a black material that still reportsisReady(): silent, not loud.One rule governs both hooks: subscriptions are durable, scene state is not.
0.7.6
An adoption-feedback release: everything here came from tosijs-3d-ensemble
hitting it for real.
Fixed
The icon LANGUAGE now works on the texture path (#54).
icon-datastores every value with a trailing space, and a redirect is re-parsed as a name where that space is fatal — soparseStyleSuffixes('cornerUpRight0f ')returnednullwhile the trimmed form resolves. Every mirrored icon failed to resolve, which is the whole left-facing set (they are all stored as<rightVariant>0f), on both the DOM and texture paths.iconGlyphalso computed the composed style and then discarded it with a warning; it now applies it as an SVG transform. So a plain NAME carries the whole language everywhere —cornerUpLeft,chevron270r— and theKeyDef.iconRotate/iconFlipXworkaround is deleted rather than copied intoIconGridItem.iconExists(name)is exported, because the alternative is guessing:name in iconDatasays no to every composed name, and inspecting the stored value says no to every mirror.b3d-shadowsno longer writesreceiveShadowson instanced meshes (#53) — Babylon warns once per instance and ensemble measured ~10,000 messages on one load. The write was already a no-op (an instance takes the flag from its source), so nothing that received shadows before stops now.You can swim up again. Head depth was derived twice and the two disagreed: the up-thrust gate included
bodyBottom, the up-AIM clamp did not. A swim pose puts the root at the waterline with the body below it, so the aim clamp thought the head was a body-length shallower than it was and clamped upward aim to nothing — you could push up but not aim up.The keyboard works with ONE presentation.
useDomLayerwas opt-in, so a flat-only consumer got no keyboard at all on aheight: 'fit'panel and a cropped one on a tall panel. The DOM layer now installs itself when the panel is on the page.The sky advances by measured time, not by the interval it asked for — a backgrounded tab throttles timers, so the day cycle ran ~10x slow and never caught up.
Changed
- Underwater fog eases toward the surface rather than being full-strength until the crossing band.
- Console hygiene: icon warnings fire once per problem rather than once per
draw (they run from drawing code), demos report on the page instead of logging,
and our own deprecated tosijs usages are gone —
elementCreator({tag})→static preferredTagName,static styleSpec→shadowStyleSpec. b3d-physicsimportsPhysicsViewerfrom the barrel rather than mixing a deep path with it in the same module — the classic route to two Babylon module instances in a consumer's bundle.
0.7.5
Added
iconGrid3d— segmented select, tool palette and mode picker in one. Three widgets collapse into one because the difference between them was never layout, it was semantics: the grid owns layout, hit-testing and focus, andhandleChangelets you impose your own rule. It receives what would happen and returns what should; returningpreviousis the veto, because one way to say no is easier to get right than two.Cell size is two cases — 48px touch, 24px pointer, from
(pointer: coarse). XR is deliberately not a third size: growing targets in UI units leaves the glyphs where they are and inflates the buttons around them, buying hit accuracy by making the panel harder to read. Scale the panel in world space instead and both move together.An on-screen keyboard you can summon.
inputFielddraws a ⌨ affordance, and it is a toggle for a shared preference rather than a one-shot: you are not asking for a keyboard once, you are saying that reaching a real one is inconvenient, and that stays true for the next field. Auto-open follows the same preference (autoKeyboardEnabled/setAutoKeyboard, both exported).The glyph is always drawn because the DEVICE cannot tell you whether a keyboard is within REACH — a console, a laptop docked to a screen across the room, a headset with a desk under it. Sniffing gets all of those wrong in the same direction.
Caps lock by holding shift. A tap stays one-shot; a hold locks; a tap while locked unlocks. The key tints accent while locked, because a mode you cannot see is a mode you will be surprised by.
Popups can escape their panel — a real layer, in both presentations.
panel3d.useDomLayer(container)mounts flat popups as positioned siblings outside the panel's<svg>;panelScenemounts them as their own plane, with genuine z-separation. A layer belongs to a PRESENTATION, and there are two of them, soshowLayerfans out and closing closes both — one popup wearing two faces, exactly as the panel already is.data-presentation— one UI, two appearances. Mark a nodedomortexture:svg-texturestripsdom-only nodes when it rasterises, and the DOM layer hidestexture-only ones by stylesheet (a serialised SVG inherits no page CSS, which is what makes that work). Popup chrome uses it — the move and close glyphs are handled by picking, which exists only in the scene, so flat they were decoration you could press with no effect.It deliberately cannot make a widget behave differently between presentations. Differing in what you draw is safe; differing in what you do is how the two drift.
select3dopens a menu from its value, keeping its steppers. Nudging to the next option while watching it is a different gesture from picking a named one out of a list, and a stepper is better at it.This needed a seam: a widget cannot reach its own panel, because it is constructed before the panel that will hold it.
Widget3d.setHost()andWidgetHost(showPopup/showLayer/closePopup/bounds/top/hasLayer) are that seam.A kitchen-sink demo and an overview on the UI page, with the doc order sorted so features come first and the pure models sort to the bottom.
Changed
- The scene-panel button wears the tosijs-3d owl, not a gear. The
scenePanelGearpart name is unchanged — a consumer may be styling it, and a part is an anchor rather than a description. - Keyboard keys use SVG icons for enter, backspace and shift instead of
unicode glyphs. A codepoint is at the mercy of whatever font the panel
resolved, and a rasterised texture is its own document with its own fallback,
so
⏎could arrive as a box.
Fixed
- Nested panel pointer events were handled twice — once by the inner panel, then again when they bubbled to the outer one, which routed them back in by coordinates. A tap survives that; a HOLD does not, so press-hold-drag on the spacebar never fired in the DOM (3D worked, because a texture has no DOM events).
vector3ddid not forward its host, so the fields inside a coordinate row could never summon a keyboard — and therefore never showed a numpad.- The keyboard follows focus between fields, and its layout reverts: a takeover cleared the shared record but not the previous owner's, so returning to a text field left the numpad up.
- It dismisses when the host makes something else the receiver
(
setActive(false), which is distinct fromfocusClear— D-pad focus moving on does not stop text arriving). - Placement follows the FIELD, not the panel, and the scene layer no longer clamps to the panel: a plane is not cropped by anything, so a keyboard for a field low in the panel hangs below it rather than being pushed up over the field it types into.
- The popup close target no longer shrinks with its glyph.
chromeHittests a boundary, not the glyph box, so making the × smaller quietly made it harder to hit — wrong for a controller ray. The target is now sized on its own terms. - The return key drew a box.
icons/stroked/corner-down-left.svgis an 18-byte file containing the TEXTcornerDownRight0f— a mirror reference thatsvgIconsresolves andiconGlyphcannot. The icon guard now checks that a name RENDERS, not merely that it exists.
0.7.4
Added
curve3d+footprint3d— author a terrain province instead of coding one. A province is a footprint (extent by direction) plus one curve per layer: ashapethat remaps the height sample, and afalloffthat says how far its say extends. The pure model iscurve.ts(Babylon-free, 55 tests); the province editor demo lives on/curve-field/.The range is closed on purpose. A curve maps
[0,1]to[0,1]and a drag clamps rather than pushing the range, because a profile that can return 1.4 silently changes the height a province occupies — which is exactly whatcarve/patch-fieldmust agree about, and it fails as geometry while reporting nothing. Amplitude belongs to the block; shape belongs to the curve.blendSamplecomposes them convexly, so a tile's bounds are known before anything is evaluated, however many provinces overlap.A falloff is pinned to 0 at its edge; a profile is not. A province still carrying weight at its boundary does not blend into the terrain around it — another silent step. Not monotonic, though: a crater rim and a volcano cone are non-monotonic falloffs, so the edge is pinned and the middle left alone.
A footprint is a polygon, not a sampled curve.
ngon(6)is six vertices, andpolygonExtentcasts a real ray at the straight edge — interpolating radius against angle bows every edge inward, which is why an earlier draft carried twelve samples per edge to avoid doing one intersection. A circle is therefore the expensive shape: a 16-gon, indistinguishable at province scale. Vertices cannot pass their neighbours or reach the centre, which keeps the polygon star-shaped — the property that makes "extent in this direction" have an answer.Presets are named for what they are:
shelf + mountains, `desert
terraces, plateau, smooth edge, abrupt edge, messy circle`. "ease
in-out" describes a graph; "smooth edge" describes a province.
vector3d/euler3d— a coordinate on ONE row. Three stacked labelled fields is why an inspector panel ends up three times taller than it needs to be.euler3dis not a styling variant: it wraps into(-180, 180]wherevector3dclamps, because a clamp fights you at exactly the angles you most want to scrub through. Degrees, per the angle rule.A row is three tab stops, not one: the host tracks focus per widget, so the row keeps its own axis index and lights exactly one caret — all three at once says focus is everywhere, which says nothing.
Fixed
A yielded camera is always given back. Dragging a popup detaches the camera so it does not orbit inside the same gesture, but the restore sat behind an empty-popup-list early return — so anything that emptied the list mid-gesture stranded the camera detached, with no way back. A detached camera is a dead scene, not a glitch. Also added a
windowpointerup/pointercancel backstop, because a pointer released outside the canvas produces no scene event at all, and dragging toward the edge is how you leave the canvas.openPopupthrew when opened by a click. It calledattachDrag()from awhenMeshcallback declared above it — fine while that callback defers, which is what happens at mount. ButwhenReadyfires immediately when the scene is already up, and appending a plane to a live scene runs itssceneReadysynchronously too, so opening a popup from a click completed the whole chain insideopenPopupand hitattachDragin its temporal dead zone. Minified, that readsCannot access 'E' before initialization. The throw escaped before the function returned, so the caller got no popup and no drag — one exception, two symptoms, neither pointing at declaration order.Dragging a popup no longer re-centres it under the pointer. Babylon's
_startDragfalls back to a ray from the camera position whenfromRayis omitted, so passing onlystartPickedPointwas not enough: it still recomputed the drag plane from a default. Both are needed — the ray says where the gesture points, the point says where on the panel it took hold.A panel owns its background. It painted nothing of its own, so the seam between the title bar and the content box leaked two ways:
boxdraws its background rounded, so its top corners curve away from the square-bottomed bar, and it insets byborderWidth / 2, giving a hairline down both sides. A panel is an opaque thing; the parts now sit on top of it. Panel chrome also reads the theme, which it never did — it was hardcoded.Popups have a rim, since they cannot have a drop shadow. The panel is emissive so it is unlit, and a cast shadow needs a receiver — a popup floating in front of a scene has nothing to fall on. Drawn at double stroke width so the clip discards the outer half of a stroke that straddles its path, leaving the intended width with no half-pixel arithmetic.
mutedis a mid grey deliberately: the backdrop is arbitrary 3D, so a light rim vanishes on a pale scene and a dark one on a dark scene.Numeric fields scrub —
inputField({type:'number', scrub, step, min, max})drags to adjust and clicks to type (tosijs-3d#50). Scrubbing lives on the field rather than in a separate control because a typed field already knows it is numeric; otherwise every numeric widget needs its own copy of parse, format and commit.Tap versus drag is a distance, not a mode — a press that never travels places the caret, one that moves scrubs. No modifier and no separate hit zone, which matters most in XR where aiming is the expensive part.
slider3d'sstepis documented — it always quantised the drag and the reported value, but nothing said so, so a consumer generating panels from JSON Schema concluded sliders were continuous and turned snap settings into cyclers. There is now a widget reference table covering every widget's options, plus the methods on a panel and a field.⚠️
b3dWater's normal map is now PROCEDURAL by default (tosijs-3d#46). It defaulted to/waterbump.png— a file that ships in this repo'sstatic/and not in the published package — so every consumer taking the default pointed at something they had never been told to serve. Babylon falls back when a texture will not decode, so the sea rendered as a checkerboard, which reads as a style rather than a fault; it was reported as "is the water supposed to look like a checkerboard (it actually looks awesome…)".Shipping the PNG would have fixed the missing file and left the real problem: a default that depends on a fixed URL resolving in someone else's app. The built-in map is now generated from this library's own Perlin noise — no file, no network, no path, and it works offline and in a headset. It tiles by construction (sampled on a torus), so there is no seam to hide.
An explicit
normalMapthat fails to load now logs an error instead of silently becoming a checkerboard. PassnormalMapto override.panel.openPopup()— a popup is just another panel (tosijs-3d#37, item 4). This was the seam blocking a realselect, the colour picker, and any in-panel menu:panel3dreturned a bare<svg>whileopenPopup/openMenuwanted aSurface, so a control inside a panel had nowhere to put its list.Nothing new was needed to build one — it is a
panel3dwithheight: 'fit'placed byplacePopup(which already flips and clamps). It returns the panel plus where it goes; mounting stays the host's job, because that is the part that genuinely differs: flat it is a positioned sibling, in a scene it is another plane. A popup living inside its opener would be clipped by the opener's own viewBox, which is exactly why it has to be its own panel.The popup is capped at its bounds, so a long list scrolls rather than growing past the surface it must land on — flipping cannot rescue a popup that is taller than both sides.
Fixed: injected colour controls set tokens to
[object Event]. A tosijsComponentbinds anyon*prop as a DOM event listener, so an injectedcolorInput({onChange})calls back with anEventrather than a colour. Setting a token to one throws nothing — it stringifies, fails to parse, and the widget paints black.themeEditornow accepts either and ignores anything it cannot read as a colour.⚠️ Panels no longer cast shadows by default.
register()is the shadow-caster contract and a panel registers like any other mesh, so every in-scene panel threw a hard-edged rectangle across whatever it faced.It is the right default for what a panel is: UI is not scenery — a HUD or an inspector is something you look through the world at, and a shadow makes it furniture. It is also the worst case for the shadow map, being a large flat quad that (when camera-relative) never leaves
activeDistanceand so never culls. SetcastShadowfor a panel that really is scenery — a sign on a wall, a screen in a room.The scene panel sits in the top-left corner, inset equally from top and left, instead of hanging below the gear button. It sat clear of the button that opens it, which pushed its own content off the bottom on a short scene — and covering the gear is fine, because the panel carries its own close box.
The scene panel updates when pause state changes elsewhere. The transport row picks its label and icon from
paused, but nothing repainted the panel when that flipped — so resuming from the pause dialog left the panel still offering Play. It only looked right before because the common path is pressing the panel's own button, which reopens the panel afterwards; a second route to the same state had no reason to.The
surfacedemo's debug readout is a real 3D popup, not an overlay painted into the opener's own SVG — so in the scene it is a panel with depth that can sit in front of the surface and be dragged in world space, rather than a picture of one stuck to the plane. The flat view keeps the in-panel form, because in a document a popup is a box drawn over another box.buttonActiveText— the label colour while a clickable is held or selected. Separate fromtextbecause the background under it has changed: a theme whosebuttonActiveis a strong accent needs a light label on it, and baking that intotextwould force every label in the panel to follow a decision that is only about buttons. A heldbutton3dand a selected icon both take it."button" means any CLICKABLE, and now says so in the source.
buttonBg/buttonHover/buttonActive/buttonActiveTextstyle every widget you can press — buttons, icon buttons, toggles, list rows, select cyclers.widget*would be vaguer (a label is a widget and has no press state) andclickable*is what they mean but longer than anyone will type.iconBar3dcolours like a button. It usedbuttonActive— the press colour — for the selected item, so a selected icon looked permanently held and pressing one showed nothing new. Now three escalating states (buttonBg→buttonHover→buttonActivewhile held) plusselectedBgfor "this one is on", which is a different axis and why the theme has a separate token for it. Release clears the pressed look before the handler runs, so a handler that rebuilds the panel cannot leave a button stuck looking held; and a press that drifts off its button no longer fires it.strokeWidthreaches icons.iconGlyphhardcoded2, so the token affected nothing but the text caret — a themed panel could not make its icons match its own line weight. It now defaults tow3dTheme.strokeWidth, and an explicit option still wins (an icon used as a mark may want its own weight).Web fonts now reach in-scene panels —
registerSvgFont(family, url)fetches the face, base64s it, and injects an@font-faceinto the serialised SVG, which is the only place a rasterised copy can find it.A serialised SVG is its own document: it inherits neither the page's font faces nor its custom properties (the second half is why
w3d-themebakes literals). The symptom was quiet — a scene panel in the fallback family while the identical flat panel rendered correctly.Only faces the markup actually mentions are injected, because the payload is re-parsed on every rasterisation; a woff2 is 20–100 KB and base64 adds a third, so carrying every registered font on every texture would be the real cost. Cached per URL, and a failed fetch is not cached as the answer.
withTheme(partial, build)— one default, per-panel overrides.setW3dThemesets the default; this builds something under an override and restores it afterwards (in afinally, so a throw cannot leave the palette changed — a theme that silently persists after an error makes the next widget look wrong for no visible reason).It works because of the property that makes a single global table safe: a widget reads the theme when it is built. So a scope is just set-build- restore — no plumbing, no second table, and a widget built inside keeps its colours forever, since they were baked into its attributes.
Why not
panel3d({ theme }): a panel's children are constructed as arguments, so they already exist by the time the panel function runs. An option on the panel could only recolour the panel's own background while its contents kept the default — worse than not offering it. Wrapping the construction puts the children inside the scope, because that is when they evaluate.themeEditor()— the palette editor is a component, not demo code. An adopter theming an app wants exactly this UI, and copying it out of a doc comment is how it drifts from the palette it edits. Its own module, so it tree-shakes out of a build that never imports it.Every metric has a slider and a number field, kept in sync — a slider alone hides the value you are setting, and you cannot drag an exact 0.05. The colour control is injected: alpha matters (
panelBg,rowHoverandselectedBgarergba()) and<input type="color">silently drops it, but tosijs-ui is deliberately not a dependency — so pass itscolorInput, or take the native fallback knowing what it costs.⚠️ The theme now actually reaches the widgets. Every
--w3d-*value was captured in a module-levelconstat import time, sosetW3dThemecould never affect anything — colours, fonts and metrics alike. OnlyroundedRadiusappeared to work, because it happened to be read inline. Reads are now live (getters), so a widget takes the theme in force when it is built, which is what the docs always claimed.The SVG UI is themeable — 21 tokens, up from 17. Added
focus,selectedBg,disabledBg,disabledText,strokeWidth,roundedRadius,spacing,lineHeight,codeFontFamily,codeFontWeight, plusoverlay,divider,placeholderandcaret.setW3dTheme(partial)overrides at runtime. The--w3d-*variables are read once at load and deliberately so — an SVG destined for a texture is serialised away from the document, wherevar(--w3d-text)resolves against nothing and paints black. This is the way back in, and what a theme editor needs. Widgets read the theme when they are built, so rebuild them after calling it; a widget that re-read its colours would have to re-resolve them per rasterised texture, which is the cost this design exists to avoid.The four interaction states (
rowHover,focus,selectedBg,disabledBg) are separate tokens because they must stay tellable apart —focusis a stroke rather than a fill, since a focus ring has to be visible on a hovered row and a selected one. Live demo on thew3d-themepage.20 more icons, for the ensemble editor:
mousePointer,refreshCcw/Cw,move,copy,delete,trash/trash2,plus/plusCircle,rotateCw/Ccw, and the completecorner*family (8). 61 total.fieldGroup.attach(target?)wires realkeydownevents to the focused field and returns a detacher. Opt-in — the library still never grabs the document by itself — but without it every flat host writes the same six lines, and a field you can click into that then refuses every character is a bad first impression. It was one: the theme demo shipped with an unusable field.It maps the event's modifier flags explicitly rather than passing the event, so
keyIntentkeeps taking a plain shape and stays testable without a DOM.⚠️
fieldGroupmoved underui.*(ui.fieldGroup), alongsideui.inputFieldandui.keyboard— it is part of the same SVG UI surface and was inconsistently top-level. Unreleased, so nothing external moves.fieldGroup— one keyboard, many fields. Three chores that always travel together and were hand-rolled in every host (tosijs-3d#37, items 1 and 7): exclusivity (focusing one un-focuses the rest — two lit fields both claiming the keyboard is worse than none), commit on leave (so a half-typed1.never survives the move), and layout (the incoming field'stypepicks the keyboard mode, which is the point of having a type).handleKey(key, mods)takes a key NAME rather than an event, so the same routing serves a DOM listener, a synthetic source or a test — and it returns whether the key was consumed, so a host knows when topreventDefault. Attaching a real listener stays the host's choice; this library never grabs the document.keyIntent— pure DOM-key → field-intent mapping. Named keys (Tab,Escape,F5) and anything with Ctrl/Cmd/Alt are not consumed: swallowing a shortcut because a field happens to have focus is worse than handling no keys at all.inputField.onFocusis settable on the object (mirroringonChange), so a manager learns about focus it did not initiate. Without it a tap and a programmatic focus disagree about who is active, and keys go to the wrong field — silently, and only sometimes.inputFieldhas atype—'text' | 'number' | 'integer' | 'email' | 'url' | 'tel', HTML'sinputmodeidea. One property, three jobs: the host raises the matching keyboard layout on focus (field.keyboardMode→keyboard.setMode),commit()normalises or refuses the value, andisValid()answers without the host knowing the field's kind. This is why there is no separatenumberField— a number field is this one, configured (tosijs-3d#37, item 2).The layouts already existed (
numpad,dial,email,url) and nothing chose between them, so focusing a numeric field raisedalphaand left you to find the numpad — two deliberate taps per field, and worse in a headset where there is no physical keyboard to fall back on.Validity while typing and validity as an answer are different questions, and conflating them is what makes typed fields either unusable or liars:
-and1.are legitimate things to have typed and illegitimate things to have meant. SoisValid()accepts them andcommit()does not — and a refused commit restores the last good value rather than writingNaNinto the document. Enter commits beforeonEnterfires, so no handler has to re-do the parse.slider3dcan show its value permanently —showValue: 'peek' | 'always' | 'never'(default'peek', the existing behaviour) plus aformathook for units. ensemble's coordinates were unreadable because a handle position is not a number (tosijs-3d#37, item 3).'always'reserves width for the widest value in the range (measured throughformat, so units and precision count) and shortens the track to clear it. Sizing to the current value would make the track twitch mid-drag, which reads as the slider fighting you.row3d— lay widgets side by side. A panel only stacks, so a label-and-field pair cost two rows and eight fields became sixteen rows of mostly whitespace (tosijs-3d#37, item 5).weightsgive the usual label/field split; children are middle-aligned by default, since top-aligning a short label beside a taller control makes it look detached from what it names.Pointer routing is by column, delegating in each child's own coordinates — a widget cannot know it has been put in a row, so it must still receive
(0,0)at its own top-left. Hit-testing follows the same path, which keeps "grab between the controls to scroll" working inside a row.panel3dsizes itself:height: 'fit'is the new default, withmaxHeightto cap it (past the cap it scrolls rather than growing — fitting and scrolling are the same mechanism seen from either side of a limit).panel.measure()→{content, viewport, overflow, fits}. The panel has always known this (stackLayoutreturns the total) and simply never said, so every consumer sizing a panel was guessing at a number the panel could have told them.Both exist because clipping is silent: a panel too short for its content looks exactly like a panel missing its last control. The ensemble editor got three heights wrong in one sitting — a command hidden behind another panel, an option cut in half, a list showing five of eight rows — and noticed none of them at the time (tosijs-3d#37, item 6).
The ordering is the whole trick: widgets measure against the inner WIDTH, which is known from
widthalone, so the stack can be measured before the panel has a height and the height derived from it. The previous code fixed the height first and had no way to discover it was wrong.Behaviour change: a
panel3dwith no explicitheightused to be 480 and now fits its content. All nine call sites in this repo pass a height, so nothing here changed; an adopter relying on the old default should passheight: 480.
0.7.3
Added
⚠️ The biped uses the GTA V control layout. Left stick moves and strafes; right stick turns the body and pitches. It was tank-controlled (left stick X turned), which nobody has muscle memory for any more and which left the right stick doing camera work that could not steer. Turning the body with the right stick makes swim direction and body facing one thing rather than two that can disagree — the thing that made look-directed swimming fiddly. The camera now sits straight behind the body and has no yaw of its own, so it cannot end up pointing somewhere the character is not. Measured: strafe moves 4.65 m sideways with the yaw unchanged; turn rotates 34° with zero translation.
The biped's right stick is a LOOK control, and jump and sneak exist. Tonio spotted that look-directed swimming had nothing to aim with on a flat screen: the right stick was bound to
cameraZoom(Y) and a snap-back peek (X), so a character had no aim at all. NowlookX/lookY, persistent rather than sprung — a character's camera is how you look around, where the aircraft's springs back because there it is a glance off the flight path. AFollowCamerahas no pitch of its own, so pitch is height: raise the camera and it looks down at its locked target. Zoom moves to the d-pad, which sneak vacated.- Swim aim now reads that look rather than integrating the stick separately, so "swim where you are looking" is literally true flat and is the same rule the headset already followed with your head.
- Jump — right bumper only.
Abriefly aliased it, and Tonio removed the alias: the face buttons are reserved for actions (they are primary and secondary fire on the aircraft), and a control vocabulary that changes meaning per vehicle is one you have to relearn. The ground snap swallowed the first version whole — the impulse lifted the body ~7 cm, the probe still saw ground 0.6 m below, and the snap put it straight back, a jump that rose exactly 0.00 m. The snap now yields while you are rising. - Standing jumps brace; running jumps fire on the press. A running jump's
anticipation is the run — the character is already loaded and moving, so a
wind-up can only read as a stumble. A standing jump has no run-up to borrow
from, so holding scrubs the clip into the braced pose and parks there,
and releasing launches. The hold is not a delay tax: it scales the jump
(
jumpMinScale0.45 → full), so it is a choice rather than a wait. Measured: full brace 1.13 m, short hold 0.45 m, nothing at all while held. - The animation is retimed to the jump, not the jump to the animation.
Physics stays fixed at
jumpSpeed, and the clip'sspeedRatiois set so it lasts exactly the flight time. Measured:running-jumpplays at 1.02× (0.93 s clip over 0.92 s of flight). The first attempt had this backwards — sizing the launch from the clip length — which made the jump a consequence of whatever the animator exported. - The jump clip holds for the whole airborne period, and the clip is chosen at the press and never re-picked, so a standing jump cannot switch to the running clip in mid-air.
- Sneak — left bumper, a toggle on land and a held control in water: a stance you adopt for a while versus a thing you do continuously, and a toggle whose state you must remember with your head underwater is worse than useless.
⚠️ Jumping is instantaneous again, and animated in three phases. The crouch-on-press/launch-on-release model was an adaptation to the stock rig's single one-shot clip that happened to open with a crouch. Quaternius ships
Jump_Start/Jump_Loop/Jump_Land, where start is a takeoff, not a wind-up — Tonio: "it's basically designed for instantaneous jumps where once you jump you enter the jump state and that's it." That is also the platform-jumper contract inMOBILITY-DESIGN.md: the character does exactly what you pressed, now; anticipation belongs to the intent model, where the character decides before you ask.jumpWindupandjumpMinScaleare gone, and so is thespeedRatioretiming — each clip now plays for as long as it is actually true instead of being stretched to fit a flight it could not know.b3d-librarysurfaces the glb's own catalogue (#45).getManifest()returns{count, categories, items:[{name, category, tags, size}]}built from the loaded nodes;getInfo(name)answers{category, tags, clips}without instantiating, which is what a clip picker needs (metadata.animationGroupsrequires an instance to exist first).getNames()now narrows to the declared items when a catalogue is present — the data-driven twin of the.modelconvention, and a packed kit badly over-reports without it (measured: 559 raw nodes vs 131 declared items).getRootNames()/getHierarchy()still expose everything, so sub-part targets keep working, andinstantiate()carries the extras onto what it returns — including thecanonical: truewrapper, which was the actual reason the data looked unreachable.glb-manifest— the pure half: category/tag/size queries over a library catalogue, plus GLB JSON-chunk parsing for the case where you want a library's contents without loading it into a scene. Note Babylon surfaces per-node extras and drops per-scene ones, somanifestFromNodesis the path that needs no parsing at all.Mantling — the biped climbs onto a ledge too high to step onto. No button: push into it and the character solves it, which is the intent model rather than the platform jumper.
mantle.tsis the pure half (Babylon-free, 13 tests):canMantle(every clause a way of NOT being a ledge — too low is a step, too high a wall, no landing is a fence you would be stranded on),mantlePath(an ARC — rise leads translation, because a straight line drives the body through the lip) andmantleClip(picksClimbUp_1m/_2mby MEASURING the ledge, since Quaternius indexes those clips by height).It arrived as "when you swim to the water's edge, you just pop instantly to the surface onto the land", but it is not a water feature — climbing out of a pond is mantling a lip of height h, so the bank, the low wall and the crate are one verb. A swimmer's reach is measured from the waterline, not the dangling feet: your hands are at the surface, and the feet reading made every bank in the demo 3 m tall.
New attribute
climbReach(2.2 m). The probe is throttled to 12 Hz — see MOBILITY-DESIGN → "Probes: one budgeted read of the surroundings".The wade↔swim transition is verified — it had been the one untestable case, because no water in the 0.2–0.9 m band existed anywhere in the demo. With the canal's variable depth it now does: walking deep → shelf → deep across a HARD step (4.24 m → 0.37 m → 4.56 m, the worst case for flicker) produces exactly two state changes over 304 samples, one at each edge.
wadeDepthis a biped attribute (0.45= fraction of standing height, ~0.8 m on a 1.83 m rig). Below it you wade; above it buoyancy takes over. Tonio set the band — "about 0.4-0.5 (it's hard to swim in water less than waist deep)" — which is the real constraint: swimming shallower than that is not a choice a person gets to make, the bottom is in the way. A fraction rather than metres because it is a fact about the body, so a smaller character starts swimming sooner in the same pond for free.Camera zoom-IN works.
cameraZoomwasMath.max(0, dpadUp - dpadDown), which discarded the whole zoom-in half — down produced0, not−1, so the camera could only retreat. The unit test asserted the broken value, having been written beside the broken line.Vertical look moves the chase rig in XR. Flat,
lookYtilts theFollowCameraviaheightOffset; in a headset there is noFollowCamera, so it did nothing. A regression from moving camera zoom onto the D-pad — XR controllers have no D-pad, so the headset lost both the zoom and the tilt in one move. Same formula as flat, clamped, so the two feel alike.Known gap: camera zoom is now unreachable in VR. It needs a home that exists on an XR controller — most likely a slider on the in-headset scene panel.
Strafing is a toggle,
strafing="off"by default. Both sticks then turn you — the left while moving or not, the right without moving — and the sidestep clips never play. Off on principle: sidestepping is a shooter idiom that reads oddly on a character meant to move like a person. That it also avoids the weakest clips in the Quaternius set is a bonus, not the reason.The pose is averaged over the cycle, not sampled once — the fix for "I seem to porpoise out of the water". A swim cycle is not a fixed shape: over
Swim_Fwd_Loop's 1.33 s the body's lowest point swings from −1.26 to −0.28 as the legs kick. A single sample landed wherever the settle timer fell, and the shallow end of that swing drove the derived buoyancy to its clamp and fired the swimmer out of the water — intermittently, because it depended on the phase.buoyancyis METRES, not a multiplier (0default = waterline at the head;0.1floats ten centimetres higher). A multiplier is not authorable — its effect depends on how tall the pose happens to be, so the same number means different things for a tread and a crawl. Tonio: "we can keep buoyancy as a strict z offset for a given figure in water."The waterline is anchored at the swimmer's HEAD. Which is what swimming is — you keep your head at the surface, and you do it by swimming rather than by floating — so it is a fact about the activity, not about a clip, and it holds for any humanoid rig without per-animation tuning. Treading now sits with the water just above the neck and the head clear; a front crawl breaks the surface. Both from one rule.
Swimmers float where the ANIMATION says, not where a constant says. The swim clips are authored with the root at the waterline — measured on the Quaternius rig,
Swim_Idle_Loopspans −1.37…+0.50 about the root andSwim_Fwd_Loop−0.60…+0.31, so floating the root on the surface gives a tread with head and shoulders out and a crawl with the head just breaking. The equilibrium is now READ from the pose rather than tuned, so any standard animation set floats correctly untouched.The root itself is not a usable anchor: it means different things in different clips — feet when standing, roughly waterline when swimming — and taking it literally floated this rig at armpit height, since its root sits 73% up the treading pose. It is still what tells us a pose is a swim pose (the body hangs below it), just not where the water goes.
Three bugs fell out of the old assumption that the root is at the feet, which is true only while standing: the real head sat ~1.2 m under while treading; the legs were buried in the seabed (the floor clamped the root, not the body's lowest point); and
buoyancyappeared to do nothing, because clearing that head needed a submersion of 0.14 —buoyancy ≈ 7.buoyancyis a biped attribute — now a trim on the animation's waterline (1= as authored, higher rides higher), because the absolute is not ours to choose: the clip already encodes it, and a number here would be a second opinion that goes stale with the next animation set.A clip change no longer briefly makes a swimmer stand. A newly-started clip cannot be measured until its pose settles, and falling back to the standing assumption for those frames put the root back at the feet — so a swimmer floating at the waterline read as barely submerged and walked off across it. Moving is exactly what changes the clip, which is why it showed as "when you move he tends to jump up to the surface and walk". An unmeasured clip now inherits the last swim pose,
height / -bottomis clamped so a mid-blend reading cannot launch anyone, and no floor under you means you cannot stand — full stop, rather than a number derived from where the body drifted to.The swim/stand test no longer feeds back on itself. It asks how deep the water is at your feet on the floor, which is stable across the switch — the previous form measured the current pose, so raising buoyancy raised the body, flipped the pose upright, and the upright reading locked the flip in: at
buoyancy1.3 the character corked out and stood on the surface.Neutral buoyancy starts at 1.5 m of head depth, not 0.5 m. Holding depth is for DIVING; at half a metre a swimmer whose head dipped barely under went neutral and stayed there, with the up-thrust throttled near the surface too — Tonio: "still treading water with head underwater and it's hard to swim up."
Sidestepping uses the lateral clips rather than the forward walk — the slide is gone. UAL ships a full eight-way set (Fwd, Fwd_L/R, Left, Right, Bwd, Bwd_L/R) for jog, crouch and crawl; the biped now picks the lateral one when sideways motion dominates, crouched or not, and falls back to walking on a rig that has no such clip. Sneaking at rest holds
Crouch_Idle_Loop.⚠️ The right stick's Y is inverted by default (
invertLookY: 'off'to restore) — pushing away tips the view down, the way a camera head works.The follow camera can no longer go underground. Pitch drives its HEIGHT, so looking up walked it downward until the world vanished;
cameraMinHeight(0.5 m) floors it.ualAnimationStates()— the clip-name map for Quaternius UAL rigs, so adopting that library is one line rather than twelve hand-written states. It retires two fakes:walkBackwardsbecomes a realJog_Bwd_Loopinstead of the walk cycle played in reverse, andsneakgets aCrouch_Idle_Loopthat holds at rest.Jump_Start/Jump_Loop/Jump_Landare mapped separately, which is what will let the jump's brace genuinely hold rather than scrubbing a one-shot — addressable now, not yet wired.⚠️ The
tosi-b3ddemo uses a 1.83 m rig from the CDN (quaternius/UAL1_core.glb) instead ofomnidude.glb, which measured 0.88 m — half human scale. Camera framing roughly doubled to match (cameraHeightOffset,cameraTargetHeight, follow distances): that is the one part of the tuning genuinely scale-bound, because it is in metres. Everything else — run speed, step height, jump, the collision ellipsoid — was already human-scale and is now correct rather than needing adjustment. A project using its own small rig will want the camera numbers back down.The biped swims. Water was already a medium rather than a boundary line (underwater fog with a continuous crossing); the biped now treats it as one too. Tonio: "can we change the rate at which the biped falls in water. And while we are at it the biped could learn to tread water and swim." Both animations were already in the standard set, so this is one equation plus wiring.
buoyancy.ts— pure, tested. A body is slightly less dense than water, so it is pushed up in proportion to how much of it is submerged and rests where that balances its weight. Everything readable falls out of that one equation instead of being special-cased: plunge-and-bob when you drop in, a head that ends up above the surface (equilibrium is partial submersion — nothing targets a head height), and wading that does nothing until the water is deep enough to lift you. Sinking is ~1.5 m/s against ~20 m/s in air, and drag blends by submersion rather than switching at the waterline, so crossing the surface does not read as bouncing off it.- Swimming is deep enough AND not resting on the floor — resting on, not within reach of. The first version asked "is there ground below me?" and left the character standing on the seabed under six metres of water. The floor stops you sinking; it does not hold you down.
swimwhile moving,tread-waterwhile holding station.- Fixed after report: swimming broke when you pitched and turned at once.
The body's yaw was read back out of the pitched matrix, and
atan2(forward.x, forward.z)is ill-conditioned there — at 70° the horizontal part is scaled bycos 70° = 0.34, so x and z collapse toward zero, the recovered yaw gets noisy, and writing it straight back compounds the noise into a body that wanders. The yaw is now captured once from the level matrix and integrated from the turn input thereafter, so the only reading happens while well conditioned. It hid because pitching and turning together is one stick on a controller and two hands on a keyboard. - Also fixed: the mouse wheel still fed
rightStickY, which had becomelookY— so a scroll tipped the swimmer, since the swim aim is the look pitch. It feeds the zoom axis now, so "the wheel zooms" survives remapping. - Look-directed swimming (
swim-aim.ts, pure + tested): the BODY pitches, and the stroke follows for free — the biped already swims along its own forward vector, so there is no separate vertical term and no way for aim and motion to disagree. Aim comes from your head in a headset, and from the right stick flat, because the biped'sFollowCamerahas a fixed pitch and there is nothing to read. The stick integrates rather than mapping to an absolute angle, so releasing it holds the descent instead of springing back to level. Leaving the water unwinds the pitch on its own. - You can dive.
sneakdown,jumpup — crouch-to-descend matches the GTA-V control vocabulary this project follows and leaves the triggers free. Thrust competes with buoyancy rather than replacing it, so letting go hands the vertical back to physics instead of pinning you. - Released, you HOLD depth and drift up slowly rather than corking to the
surface (Tonio's call). Once your head is properly under, buoyancy blends
toward neutral — but stays just above 1, so you surface if you stop paying
attention. You also glide a little deeper after letting go: that is
momentum, and letting go is not a brake. Measured live in 12 m of water:
dive to the seabed, release, drift up at 0.2 m/s; hold
jumpand it becomes 0.65 m/s. - Verified live: released on a seabed under 6 m of water the biped rose and settled at 0.866 submersion against a predicted 0.870, and a walk downhill waded in and began swimming on its own.
Changed
Toolchain bumped —
tosijs1.7.8 → 1.8.0,tosijs-ui1.9.8 → 1.12.3,haltija1.11.2 → 1.12.5, andtjs-lang0.13.6 added explicitly: it is a peer of tosijs-ui and was not installed here at all, which nothing warned about. The site built anyway, so "works" and "works properly" were indistinguishable — installing it changed the build output (tjs-lang bundles served same-origin at /tjs/is new).peerDependenciesis deliberately unchanged:tosijs ^1.7.8already admits 1.8.0, so developing against the newer one costs consumers nothing and this stays a patch.Fallout, all fixed: an unused variable in
wreck-fallfailed lint, whichbun startruns first — so the dev server would not start, and becausebun formatiseslint && prettier, prettier had been silently skipped on every file since. Also:bun run typecheckis green again (the standingmodel-transform.test.ts:449red is gone)..haltija.jsonpins agent commands to this project's origin. A shared haltija server holds tabs from several repos and commands followed browser FOCUS, so a command run from this directory could silently drive another project's page — which happened repeatedly. New in haltija 1.12.
Fixed
Entering water flipped your heading 180°. With a mirrored glTF root (
scaling.z = −1) world forward is−(R·ẑ), so reading yaw back givesθ + πand writing it produces−F. I had reasoned this round-tripped; it does not. On land nothing rewrites the quaternion, so it only showed the instant pitch engaged — which is the instant you start swimming.You could wade on top of the water. Holding surface-thrust lifted you until submersion fell under the swim threshold, at which point you were classed as standing and got a walk cycle while still afloat. Up-thrust is now gated by head depth, exactly as the upward aim is: you cannot push yourself out of water, and surfacing is buoyancy's job anyway.
The swim/stand switch twitched at the waterline. Walking down a ramp, submersion hovers around the threshold and the mode flickered.
isSwimmingnow enters at 0.5 and holds until 0.35.setAnimationStatecould pick the wrong clip. The lookup wasgroup.name.endsWith(animation), and a suffix match is ambiguous the moment one clip's name ends with another's. Asking a Quaternius rig forIdle_LoopplayedCrouch_Idle_Loop— but it was already latent in the stock set, whererunning-jumpends withjump, sosetAnimationState('jump')could match the running jump depending on array order. Now an exact name match (after stripping Babylon'sClone ofprefix), with the suffix behaviour kept only as a fallback for rigs whose exporter adds some other prefix.A
_collideCylinderchild of a_collideMeshparent built a cylinder around the PARENT's whole subtree. Reported as "I put a collideMesh on the hull and collideCylinders on each mast but the ship just seems to have a single giant squat cylinder" — and the effect was a 34.4 m wide, 26 m tall invisible barrier you could not walk within twenty metres of. Two independent faults, both in how a collider's bounds are chosen:- The root-walk climbed while the parent had any collide annotation, so
each mast climbed onto the hull. The shape came from the leaf and the bounds
from the root, so it built the mast's cylinder around the hull's subtree
— and one
processedentry then swallowed the other two masts, which is why there was exactly one. It now climbs only across the same annotation. (The tell was the generated mesh's name:Hull_collideMesh_collidershould be impossible, because_collideMeshnever builds a primitive.) - Bounds combined the annotated node's children even when the node had its own geometry. A mast is a Mesh 0.55 × 20.6 × 0.64 carrying the Crow's Nest, Flag, Spars and SquareSails, so combining gave a 10.2 m cylinder around each mast — the deck still walled off. Children are combined only when the annotated node has no geometry of its own, which is the GLB TransformNode case the behaviour was written for.
Ship colliders now measure 0.58–0.64 m across at mast height, and the hull keeps its mesh collider.
- The root-walk climbed while the parent had any collide annotation, so
each mast climbed onto the hull. The shape came from the leaf and the bounds
from the root, so it built the mast's cylinder around the hull's subtree
— and one
Ambient particles spawned on the wrong side of the water — leaves below it, bubbles above, and a lot of them. Reported while swimming, which is what made it constant: third person parks the camera right at the surface. Intensity was never the problem; it already ramps correctly on the camera's depth. The spawn box was: a cube of side 2·radius centred on the eye, so with the eye near the surface half of it sits in the wrong medium. Intensity says how much to emit and cannot say where, so no amount of ramping could have fixed it. The box is now clipped at the waterline — measured with the camera on the surface,
leaves/abovespawns from −0.20 up and bothunderwaterpresets from −0.20 down, against a surface at −0.20. Same lesson as the biped's plane-vs-volume submersion test, one layer along: a medium with a boundary needs a volume with one too.World-placed dialogs rendered MIRRORED — the death/respawn panel read "NWOD", and so did the pause panel in any scene using
placement="world". Reported from a headset, but not headset-specific: it was mirrored flat too, and had been since 0.7.2. A Babylon plane's visible face is local −Z, so aimingatan2(dx, dz)at the eye — which points local +Z at it — shows you the panel's back, and adoubleSidedback reuses the front's UVs rather than vanishing. Hence a mirror instead of a missing dialog, which is why it survived a release. Nowdialog-placement.facingYawDeg, tested on the property that matters: turn the plane's visible face by this yaw and it points at the eye. The same change fixes touch, which was mirrored with it — every right-aligned control on a world dialog was mapping to the left.The pause/continue panel is DOM on a flat screen, and stays an in-scene plane in VR — the scene panel's rule (one widget list, two presentations) applied to the modal that most needed it. Tonio: "in flat 3d the continue should be presented in the dom like the scene panel." On a monitor the DOM overlay is better on every axis that has bitten this panel: it cannot be occluded, cannot be put somewhere odd by a line-of-sight cast, needs no raycast to be clicked, and is exactly where you are already looking. All that machinery exists because a headset has no DOM, which is the only place it earns its cost. The same
panel3dSVG serves both — its widgets' listeners work natively in the DOM, andhandlePointerserves the in-scene path.Death and world-placed dialogs now survive a floating-origin rebase. Neither had opted in, so in a terrain scene — the only kind big enough to rebase, which is why it hid — a shift mid-death slid the spectate camera, the fire emitters and the dialog away from the crash while the world moved out from under them. That reads exactly like the origin-teleport fixed above ("the wreck is way off, I am looking at nothing"), and two causes producing one description is how a fixed bug looks unfixed. Both use an origin LISTENER rather than
registerWorldRoot, because both hold world coordinates in JS and the element owns the transform (shifting the node would be undone next render). The falling wreck needed nothing — it re-reads its position from the node every frame, so a shift is absorbed.Leaving VR now pauses in EVERY scene, not only those with
enterXrOnResume. Taking the headset off is a departure, not a view change. The gate existed on the reasoning that a flat scene which merely visited XR shouldn't acquire a pause panel on the way out; superseded by the symmetry, which is the stronger argument (Tonio: "just as entering VR should unpause, exiting VR should probably pause") — one gesture means resume, its inverse means stop, and a rule you opt into is not a pair.enterXrOnResumestill owns resume → enter.The biped got stuck on slopes — the cost of the fix below, found and fixed in the same session. Snapping the feet onto the surface put the collision ellipsoid's bottom exactly ON it, so moving into rising ground embedded it and Babylon refused the move. The old 15 cm float had been acting as the clearance. The body now starts a step above the feet (
STEP_OFFSET, which is Unity's Step Offset): anything lower than a step is walked over, the probe stands you on it afterwards, and a wall is still a wall.The biped floated above or sank into slopes, and never corrected. Tonio, on a long-standing one: "you often end up a little offset from the ground … this never really corrects. It just gets randomly messed up again when you navigate another slope." Not random — the ground check was a dead band with no corrective term: it probed 0.15 m down from just above the feet and, if it found anything, did nothing. So the biped fell until the probe happened to see ground and then stopped wherever in that 0.15 m window it landed, and any error inside the band was permanent.
Slopes made it worse both ways. Going up,
moveWithCollisionsslides the body up the ellipsoid and leaves it high in the band. Going down — "especially down" — gravity moved at mostmin(0.1, 9.81·dt), a hard 0.1 m per frame clamp, so a brisk descent outran it and it floated the whole way.Now it probes a step up and a step down and puts the feet on the surface, and a real fall accumulates velocity instead of moving a fixed amount per frame (with the probe extended by the frame's fall so it cannot step over the ground between frames). Measured on the b3d demo scene: 390 consecutive frames walking a slope at offset 0.00000, where before the error wandered freely inside a 15 cm band. The probe also goes through
collidable()now, so a UI panel is no longer something you can stand on.Dying in VR teleported you to the world origin. Tonio: "I collided with wreckage high up and respawned at the origin or starting point with the wrecked plane hanging in mid-air off in the distance." He was not moved away from the wreck — the wreck stayed exactly where he died (measured: a corpse drifts 0.05 m in 10 s); he was moved.
releaseFocus()nulls the focused entity, so the next XR frame finds nothing piloted and falls through to free locomotion, which didrig.parent = null. That KEEPS THE LOCAL POSE and reinterprets it as world, so a rig at local(0, 2, −5)behind its parent lands at(0, 2, −5)in the world. Nowrig.setParent(null), which preserves the world transform.It had been latent since 0.7.0 as the cockpit-only oddity in
TODO.md("the aircraft was moved way away from me"); parenting the chase rig — the jitter fix above — made it reachable from the view people actually fly in, which is how a five-month-old note finally got diagnosed. Both spellings are now pinned inbabylon-orientation.test.ts, because they look interchangeable and the wrong one is shorter.B3dControllable.halt()— tell an entity it is dead, rather than relying onreleaseFocus()nulling its input provider and_updateshort-circuiting on that.b3d-deathcalls it. Belt and braces for the focus-managed case; the only thing that stops adie()handed an entity the focus manager never held.A wreck hung in the air where it died. Tonio, from a headset: "I collided with wreckage high up … the wrecked plane hanging in mid-air (it should really tumble to the ground)." Hanging wreckage is worse than untidy — it is a solid object in the sky, so the debris of one death becomes the cause of the next. A wreck now falls: a tumbling ballistic descent on the velocity it died with, one bad bounce, a skid, and a rest. The fires and the spectate camera go down with it (a smoke column left at the kill point with the wreck 100 m below it is worse than no fire at all). Rules are pure and tested in
wreck-fall; the spin is DERIVED from the velocity, never random, so the same crash looks the same twice.- How you died decides how far it goes. Flying into something is an
inelastic collision that eats most of the energy (
carry: 0.25); being shot down leaves you with nearly all of it (0.7). At full carry a 90 m/s crash from 130 m travelled ~450 m before landing — a glide, not a crash, and it dragged the spectate camera across that much terrain. Tonio: "The plane went flying off into the distance … pretty funny but not as expected." - Drag settled at
0.005(≈44 m/s terminal).0.02was ten times too much (a wreck shed its speed in half a second);0.002is the figure for a STREAMLINED body, and a broken airframe tumbles broadside. Each was plausible in isolation; only a real crash told them apart. - New seam
B3dControllable.getWorldVelocity().b3d-aircraft's ownvelocityfield reads ZERO in wing-borne flight — the fly-by-wire path moves the node directly — so a wreck launched from it dropped straight down out of a 60 m/s dive. - A wreck with nothing under it (the edge of a finite ground, a kill over open water) is abandoned after 1500 m rather than falling forever with the camera chasing it down. Found by killing outside a 600 m ground plane: it reached y = −25 and kept going.
- How you died decides how far it goes. Flying into something is an
inelastic collision that eats most of the energy (
The VR chase camera jittered and drifted with speed — Tonio, in a headset: "throttle up the aircraft gets further away, throttle down it gets closer", and it jitters climbing, diving and turning. Three faults, all in the XR rig and none in the flat one (which measures rigid to 0.2 mm):
- It was not parented. The XR rig runs in
onXRFrameObservable, which fires BEFOREscene.render(), while an entity moves inregisterBeforeRender, which fires inside it — so the rig was positioned from last frame's aircraft position, every frame, and a variable frame time turns that fixed lag into jitter. - It eased toward a world-space target, and a first-order tracker sits
v/kbehind — ~6.8 m at 61 m/s, proportional to speed. Hence the throttle symptom. - The easing used
lerp(a, b, k*dt), which is not frame-rate independent (1 - exp(-k*dt)is).
The rig is now a CHILD of a level position+heading anchor the entity updates in the same tick it moves, at a fixed local offset, with no easing at all — rigid by construction rather than by good timing. New seam:
B3dControllable.getChaseAnchor(),nullby default;b3d-aircraftprovides one. Entities without an anchor keep the eased path, now with the correct exponential form.- It was not parented. The XR rig runs in
All three panel sites now aim themselves the same way, through
dialog-placement.faceViewer.frame-paneland the XR settings panel used to face you with their BACK and cancel the resulting mirror withtex.uScale = -1— the settings panel also flipping1 - uv.xon every pick, two compensations for one avoidable cause. Both are gone. No visible change (they were correct on screen and under the finger); it removes the trap that produced the world-dialog bug above, since the knowledge lived in a comment in two files and comments do not travel.rollDegkeeps its meaning: turning a panel around reverses how a roll reads, sofaceViewernegates it. Worth stating because reasoning said the flips cancelled and they do not — the only roll in the library is180°, which is symmetric and agrees with the wrong answer.
Added
<tosi-b3d-interactive>— a mesh you can touch (#36), the substrate doors, knobs, switches, levers, consoles and lamps all stand on.b3d-buttonis a floating Babylon GUI widget; this is the other thing — world geometry you reach out to. Requested by tosijs-3d-ensemble, whose framing was fair and lands: tosijs-3d has almost nothing for building a PLACE, as opposed to a battle. Point at a mesh (or a named sub-mesh — the knob, not the door), reach it, use it;hover/unhover/activate/refusedarrive as bubbling events orwhenXcallbacks.- One implementation, flat and immersive. Babylon routes XR controller rays
through
scene.onPointerObservable, the same observable a mouse feeds, so there is no XR branch and no second path to keep in step. - Composition, not a god-feature.
vetoesis the seam ensemble asked for: alockablepushes one,interactivenever learns what a lock is, and the refusal NAMES the refuser — the difference between a locked door and a broken one. Vetoes run at activation, not at hover, so a locked door still highlights and still reports being tried; silence is a bug report. - It never touches the transform. An element that manages a node owns its transform (the rule #35 was a violation of), so this reads the scene and never writes to it. A door that opens moves its ELEMENT.
- One implementation, flat and immersive. Babylon routes XR controller rays
through
interaction.ts— the pure, Babylon-free rules (interactStep,activationVeto,withinReach), unit tested, so what counts as "you used it" can be argued about without a scene. Activation is press-then-release on the thing, because a press you drag off and release elsewhere is how anyone recovers from touching the wrong thing — and aim wanders more in a headset, not less.InteractiveBehavior— the attachable form, for anything that already owns a mesh (a loader, a biped, a vehicle), plusnearestInteractive/useNearestfor the "walk up and press E" control that wires toControlInput.interact.
0.7.2
Fixed
placement="world"left dialogs at the WORLD ORIGIN (#35) — so the pause and respawn panels were unreachable in any scene not centred on(0,0,0), and astartPausedscene opened with no visible way out. The panel is created parented to the camera and then un-parented, leaving the camera-LOCAL offset(0, 0, 2.2)behind as a world position. Worse, it was re-applied every render:AbstractMeshrewritesmesh.positionfrom the element'sx/y/z(and rotation from yaw/pitch/roll), so anything that moves the MESH is silently undone. Both the placement and the easing now write the ELEMENT, which is what owns the transform.
Added
destroyable="off"on<tosi-b3d-destroyable>— place a library mesh without enrolling it in combat. Same knob and spellingb3d-loaderalready had. This element is the only way to place a library mesh BY NAME, so scenery had no way out: an ensemble is mostly structure, and every wall and floor was getting a combat record whether or not anything could shoot it. Requested by tosijs-3d-ensemble, whose stopgap wasarmor: 100_000— buying "cannot be killed" by paying for a combatant.
0.7.1
Fixed
Modal dialogs are WORLD-PLACED and follow you if you look away. The respawn and pause panels sit at a spot with clear line of sight, face you, and ease to a fresh spot after ~2s outside a generous cone.
placement="world"on<tosi-b3d-svg-plane>; the rules are pure and tested indialog-placement.This replaces three compensations that were each fixing a symptom: head-locking (jittered with a jostling death-cam), riding the body frame (put it at knee height), and rendering on top (painted in front but became untouchable, because picking is geometric). It also fixes the case none of them could — dying in cockpit view with the camera inside a hillside, where nothing "just in front of you" helps.
turnRateDegreturns exactly what you set. Converting through radians is lossy —30derived back as29.999999999999996, which is what a slider readout or a saved scene would show. It now remembers the value you supplied together with the radians it produced, so a later write toturnRateis detected exactly and the view derives fresh. Still one stored value.
Added
flightStage()— a setting an aircraft can actually operate in: ground, sky and fog at flight scale, a library, a sun configured for the ground it lights, and a death panel that respawns you. Takes a plane FACTORY, not an instance, because respawn has to build a new one — passing an instance is what made a crash unrecoverable. Returns{ elements, aircraft }rather than an array, since the current aircraft changes on respawn and a captured reference goes stale. The clouds demo lost 22 lines converting to it.tosijs-3d/demo-utils— the scene-setup helpers every live example uses are now published, so code copied off a doc page resolves in your project. Previously 38 examples across 33 files imported a baredemo-utilsthat no consumer could resolve, and since the demo goes first on every page it was the first thing anyone copied.They ship with an explicit stability disclaimer: dev helpers, not API. They can change or vanish in a patch, they are tuned to flatter a doc page rather than your game, and production code should compose
b3dSun/b3dSkybox/b3dGrounddirectly.Vetted rather than published wholesale — the module was not shippable as-is:
orbitCamtakesalphaDeg/betaDeg; the radianalpha/betastill work and are converted, so existing snippets keep running.- Options are typed — the two
Record<string, unknown>signatures are gone. - Ground helpers take a
textureURL instead of exporting root-absolute asset paths that resolve only on this site. demoSunuses the auto shadow-size sentinel rather than hard-wiring 2048 over the device-tier budget.volumetricDemo,impactMarkerand the asset constants stay private — they are page-specific, not API.
0.7.0
Prereleases, and why the numbering looks backwards.
0.7.0-rc.1was cut first, then work continued as-beta.1…beta.6. Semver sorts beta BELOW rc, sobun add tosijs-3d@nextwrote^0.7.0-beta.6and RESOLVED BACKWARDS to the older rc.1 — silently, andbun updateeven reported the downgrade as an upgrade. Publishing 0.7.0 is what un-inverts the channel, since it sorts above every prerelease.The rule taken from it: a prerelease channel never moves backwards — the successor to
-rc.Nis-rc.N+1, never a beta. If you pinned any^0.7.0-beta.*or0.7.0-rc.1, move to0.7.0.
- 0.7.0-beta.6 (2026-08-17) — carved landforms (the sdf-lattice page, the volcano cutaway, strata, 3D volcanic veins), portal math, and four manta-reported fixes: the explode+library crash that seized the scene (#24), mutual death eating the panel (#25), blasts blind to library destroyables (#28), and pitch authority becoming configurable and asymmetric (#26).
- 0.7.0-beta.5 (2026-08-15) —
MediumOptics+fogLayerFor(⚠️ experimental, not load-bearing: water publishes its optics but still computes its own fog layer). Otherwise identical to beta.4 — the rest is design notes.- 0.7.0-beta.4 (2026-08-15) — the
mediumprimitive (space/air/water/ mercury, plane or sphere), medium-aware projectiles closing #13, and pause marked EXPERIMENTAL pending a headset.- 0.7.0-beta.3 (2026-08-15) — pause (backgrounding, start-paused, the VR entry gesture, a centred panel), the stick-sign contract test +
kind, and four more manta reports: #17/#18 ambient spawn bias, #19 authored projectile meshes, #22 destroyable library models. Plus the HUD circle's portrait sizing and both panels' camera/viewport fixes.- 0.7.0-beta.2 (2026-08-15) — adds #13's missile-guide composition, the gear gadget for the glass gamepad, and the live
fade. #14 investigated and NOT changed: the glass source measures correct on desktop from four independent angles, so no sign was flipped (see the issue).- 0.7.0-beta.1 (2026-08-14) — everything below, including the four manta-recon issues fixed that day: #9 (crashed aircraft eats input) and the
b3d-deathlatching bug behind it, #10 (aircraftMappingignored its config), #11 (probe and ambient watchdog measuring during load), #12 (the sky ignored the water you were under).
Aircraft that fly like aircraft, and the substrate for volumetric terrain. No peer-dependency changes, but this is a behaviour release: the throttle, the right stick and the on-screen gamepad all work differently, and one attribute was renamed. Read the ⚠️ Breaking block before upgrading a scene that was tuned around the old feel.
⚠️ Breaking
canonicalizenow drops the CONTENT node's scene transform, so a model placed away from the origin in its source file lands differently. The contract always said it "drops the node's SCENE transform" and it only ever zeroed the node it was handed — on the glTF path that is__root__, the loader's own wrapper — while the authored object underneath kept its scene placement.Who is affected: anyone whose model sits somewhere other than the origin inside its
.glbAND who compensated for the resulting offset. The compensation is now doubled.How it shows up: the control node used to sit a fixed distance from the airframe it steers (1.7 m for our own scout), so collision rays started beside the model and a
_centerOfGravitymarker measured against the control node folded that offset into the PIVOT. The perverse consequence: a marker authored dead on the centreline — the documented way — installed a pivot 1.75 m out, while an odd-looking hand-tuned marker silently netted out correct. Our scout was re-exported "properly" and started crashing on every bank.Migration: author content in the model's LOCAL frame, as
CLAUDE.mdhas always said, and delete compensating offsets. If a vehicle now flies or pivots oddly, look for a marker or a placement that was tuned against the old behaviour before assuming a regression. Models authored at the origin (the common case) are unaffected.Angles in the AUTHORING surface are DEGREES. "We should use degrees everywhere. Not even mathematicians visualize radians." Two APIs took radians and now take degrees:
landform'sgulley/coverheading, and the library rotation below. Migration: multiply by180 / Math.PI. Pure math modules (spatial-transform,guidance,portal-transform,fly-by-wire's internals) keep radians deliberately — that is mathematics, not a dial an author turns. Andb3d-skybox'sazimuthis neither: it is Babylon's 0–1 fraction of a turn, passed straight through, now documented as such rather than left to be guessed at.⚠️
library.instantiate()/library.make.*rotation: WAS IGNORED, now works — and is DEGREES. The single most disruptive change in this release, because it is the only one that can move a scene which previously looked correct. Everything else here either errors or looks obviously wrong.Two things happened to one option:
- It never applied on the default path.
instantiate()wroteresult.rotation, but Babylon's glTF loader always assigns arotationQuaternion, and aTransformNodeignores.rotationwhile one is present. Measured on a real model:ry: 0,ry: 140andry: -90gave bit-identical orientations — you got the GLB's own baked rotation whatever you asked for.positionworked, which is precisely what made it look wired up. (canonical: truewas unaffected throughout.) - It is now degrees, matching
AbstractMesh'srx/ry/rzandel.make.*. The library was the only surface in the framework that disagreed, and a bare number is valid in either unit — so it failed by producing a different orientation rather than an error. It got past us in this repo's own collision demo, wherery: 140meant 140 radians.
Migration. If you passed rotation and it appeared to work, it was not your value doing it — something else was (baked rotation in the model, a parent transform, a compensating tweak downstream), and that compensation is now doubled up. Check every placement before calling it a regression. If you passed rotation and gave up because nothing happened, it works now: your values are radians, so multiply by
180 / Math.PI. If you never passed rotation, nothing changes. Quickest triage: grep forinstantiate(andmake.with anr[xyz]key — no hits means this does not apply to you.See
Migration.md, which ships inside the package.- It never applied on the default path.
b3dPatch/B3dPatchare removed. The volumetric-patch element stitched an SDF extraction into the heightfield and never worked: two surfaces meeting at a grazing angle have no well-defined boundary, so every fix aimed at one crossing created another elsewhere. It shipped EXPERIMENTAL and undemoed, and the design that replaces it (TUNNEL-DESIGN.md) removes the boundary rather than reconciling it. The pure modules it was built on —carve.*,sdf-lattice,patch-field— are kept: they are the substrate the replacement uses.hudChase→hudChaseOff, with the polarity inverted. The old attribute is gone (no alias — HTML boolean semantics can't express a default-true flag, which is why the negative name exists). It shipped in 0.6.2'sdist, and because tosijs props end in an index signature a stalehudChasecompiles and is silently ignored, so this will not error for you.hudChase: true→ delete it; the chase HUD is now the default.hudChase: false→hudChaseOff: true.- Chase view now shows the HUD without the artificial horizon (which only tells the truth from inside the cockpit).
The glass gamepad hides itself once a keyboard or hardware gamepad is used, returning after
idleSeconds(10) of silence. The POINTER never fades it — not touch, not mouse, not trackpad: the pad is operable by pointer, so fading on pointer movement made it vanish exactly as you reached for it. Setfade="off"to pin it — worth knowing if you screenshot or demo.The right stick is the camera, not aux roll.
strafestill sums into roll, so a custom mapping can restore a dedicated roll axis.carveis a NAMESPACE, not 13 bare exports. New in this cycle, so this affects only the handful of people who took0.7.0-rc.1off thenexttag: that tarball exportedapplyCarve,sphere,capsule,tube,box,union,smoothUnion,flange,subtract,intersect,roughen,warpandshaftat top level; they now live oncarve.*. Coming from the rc you get a link error, which is the intended loud failure —boxin particular shadowed the UI container of the same name.import { sphere, tube } from 'tosijs-3d'→import { carve } from 'tosijs-3d', thencarve.sphere(…),carve.tube(…).- Types stay top-level:
Carve,NoiseOptions,Vec3Like.
getNames()returns different strings. Behaviour suffixes no longer leak into the public name, soHull_collideMesh.modellists asHullandinstantiate('Hull')resolves it (fixes #7, reported by manta-recon). Exact and.modelmatches still win first, so existing calls that used the raw name keep working — but code that PARSED the returned string, or compared it to an authored name with its suffix, needs updating.
Added
Re-seat is now a two-step, confirmed gesture — and it finally works. Re-seating takes your CURRENT head yaw as forward, but you had to LOOK AT the button to press it, so a panel that had drifted left re-seated you facing left: the gesture defeated itself exactly when you needed it. Pressing Re-seat now raises a prompt pinned to the face frame (head-locked, so it stays readable WHILE you turn your head — the one case that earns face-locking): 1. Look comfortably ahead. 2. Pull right trigger to reseat. Or, pull left trigger to cancel. The yaw is captured on the trigger, from wherever you are looking THEN.
reseatFreeze(new<tosi-b3d>attribute,'on' | 'off', default'on') freezes the clock while the prompt is up, so you are not shot mid re-seat. Set'off'for multiplayer — freezing is a decision a local world can make and a networked one cannot honour. The dialog always gates YOUR input (b3d.suppressInput), which is the half that works in any topology, so the trigger that confirms never also fires your gun.b3d.freeze(on)andb3d.suppressInput(on)are public: a modal that borrows a control you also play with can use them.freeze()stops the clock without raising the pause panel and without pause's resume semantics — a scene paused underneath stays paused.
The package is licensed — Apache-2.0, with a LICENSE file, plus
description,homepageandbugs. Every release since 0.2.1 shipped with no licence at all, which legally means all-rights-reserved: SCA tooling flags an unknown-licence dependency, and adopters consuming this as afile:dep had nothing to point at. Apache-2.0 rather than MIT/BSD for the explicit patent grant and retaliation clause.A
startPausedscene renders its own fog, not Babylon's default (#31). A paused frame still RENDERS — that is the point, the panel has to be visible — so everything the render reads has to be correct._updateFogsat past the paused branch's early return andstartPausedpauses in the same synchronous setup block, so a scene that booted paused had never run it once.Entering VR unpauses a paused scene. Putting the headset on is the resume — and a world you deliberately stepped into has no reason to be frozen. It also removed a broken state: entering while paused showed no pause panel in-session, so you arrived in a stopped world with no visible way out. You can still pause in VR from the scene panel's transport toggle.
Camera-relative dialogs are no longer buried by the world. The respawn and pause panels could end up inside terrain — you die on a hillside and the panel offering you a way out is in the hill. They now render in group 1 (drawn after the scene, depth auto-cleared between groups), so a modal is always readable. World-anchored panels and popups keep normal depth sorting.
VR altitude on the BUMPERS — left down, right up, analog (squeeze pressure, so you can ease onto a height). Same hand logic as brake/accelerate on the triggers. The right stick's vertical still flies, but it doubles as panel scroll, so in any scene with a scrollable panel open — most demos — the stick scrolled and the rig did not move, and altitude appeared to be missing entirely. Two dedicated buttons cannot be stolen by a panel.
Pause/resume in the standard scene panel. Pause was a one-way door: the pause panel offers Continue, so it was a state you could LEAVE and never ENTER — nothing in the panel stopped a running scene, and
startPausedis an initial condition, not a control. The icon bar now carries a transport toggle (playwhen paused,pausewhen running — it shows what pressing it DOES, not what the scene currently is). It is a panel GADGET, so it appears in the flat overlay and the in-VR panel from one definition, and in a headset it is the only way to stop the world at all.Status surfaces in the theme —
--w3d-info,--w3d-warning,--w3d-error(w3dTheme.info/.warning/.error). Backgrounds, not text colours: a status panel in a dark theme is a tinted SURFACE thattextstill reads on, and the bright hue you would put on a label is unreadable behind one. Opaque on purpose, so the panel underneath cannot show through the message interrupting you.markUiMesh()/isNoCollide()(b3d-utils) — mark a mesh as UI: pointer-pickable, but invisible to COLLISION. See the phantom-collision fix below for why those are different questions. The first of the collision GROUPS described in the newCOLLISION-DESIGN.md.xrFrameon<tosi-b3d-svg-plane>— whencameraRelativeand in a headset, ride an XR reference frame instead of the head camera.'body'(damped yaw) keeps a panel in front of you without jittering on every head movement; leave it unset for a HUD that should stay head-locked.A diagnostic ring buffer —
b3d.logDebug(tag, event),b3d.debugCapture(tag),b3d.debugLog. There is no console in a headset andwindow.rAFis suspended in-session, so this records structured events IN the page for readback afterwards. Plus always-on Perf-panel rows: aircraft ground (what the ground ray is calling ground, and a full crash report) and xr input (per-hand thumbstick axes, rig pose, movement scale).Popups are their own SURFACES —
el.openPopup()(popup-surface). A menu, a dropdown's list or a debug readout spawns a second plane above its opener rather than being laid out inside its SVG. Owned popups travel and die with their opener;tearOff()promotes one to world space preserving pose, after which it stays where you put it — and dragging an owned popup tears it off, like pulling a tab out of a window. Click-to-front,modal(blocks pointer access to what's behind it, never the camera), a title-bar grip so the rest of the panel can stay interactive, and injected move/close glyphs.This retired a constraint we had recorded as absolute: a dropdown "MUST grow the panel's layout … because a popover won't rasterize into the VR texture". True of a popover inside ONE texture; the escape is another texture on another plane. See
UI-DESIGN-NOTES.md.rounded-rect+cornerRadius/transparentonb3d-svg-plane— rounded corners as GEOMETRY so a UI panel can be opaque. A transparent mesh is not depth-written, so Babylon re-sorts it per frame by camera distance and near-coplanar panels flip order as you orbit: it looks exactly like z-fighting and no amount of correct depth ordering fixes it. Three quads plus four quarter-disc fans, 22 triangles at 4 segments. The general rule: in 3D, express shape as geometry and reserve alpha for things that are genuinely see-through.el.make.*(make-mesh) — Babylon primitives with the forgettable parts done: material fromcolor/glow,register()so the sun and reflection probes see it, andcomputeWorldMatrix(a mesh positioned but never rendered has no world matrix, so a ray cast this frame finds it AT THE ORIGIN and answers confidently and wrongly). A Proxy forwarding toMeshBuilder, so all 26 shapes are reachable plus whatever Babylon adds later.library.make.<name>()— a library's contents as callable names,lib.make.scout({ y: 1 }). Same option vocabulary asel.make.*.glow/glowColoronb3dBox/b3dSphere— self-illumination as a fraction ofcolor. Both primitives now share oneprimitiveMaterial, which is how the sphere came to be missingglowninety seconds after the box got it.B3d.BABYLON(static) andBABYLONre-exported from the barrel. Babylon is a PEER dependency, so a consumer importing@babylonjs/coreseparately can end up with a second copy — twoVector3classes that failinstanceofagainst each other. Taking it from the library that already holds one is the guarantee.shadowNormalBias/shadowBiasonb3dSun, defaulting to0.05. Babylon's CSM shipsnormalBias: 0, which is not a tuned value for a large receiver: a 40×40 ground self-shadowed into a dense stipple across its whole surface. Measured at 0 / 0.02 / 0.05 on a live scene.b3d-collisionsgains its first live demo — hail on the scout, impacts found by a SWEPT ray, each marked with a disc lying on the surface plus a stalk along the normal. Ground hits count too, in a cooler colour.b3d-aircraftgainschasePitchFollow/chasePitchLag— how much of the nose's pitch the chase camera inherits.0(default) keeps the level pivot;1is as if the camera were bolted to the airframe. A dial rather than a reparent, because the flat pivot is what fixed the jittery chase, and the lag term is what passes the intention while dropping the per-frame wobble.b3d-aircraftgainsmaxPitch/maxDive(#26). Pitch authority was a module constant at a symmetric 35° — a gentle airliner descent, unreachable by any adopter, and unable to express a craft that should fall out of the sky readily and climb reluctantly.maxDivedefaults tomaxPitch, so nothing changes unless asked.⚠️ EXPERIMENTAL —
portalTransform: see-through portal math (virtual camera pose, the oblique clip plane, a step-test crossing, and recursion budgeted by a falloff CURVE rather than a hard depth limit — linear, geometric or accelerating, so first-bounce quality and pass count stop being welded together).Sedimentary strata in the volcanic rock (
strata,strataScale,strataTilt), visible on any cut face — only possible because the plate noise is now sampled in 3D, since bedding means nothing to a shader that knows only x/z.Carved landforms — the
sdf-latticepage is now a real doc page with the theory (SDF composition, marching cubes vs dual contouring vs surface nets, and why this uses surface nets), a volcano in cross-section with lava tubes and a sweeping cutaway, and a volumetric-vs-heightfield comparison with a bore punched through an authored ridge.demo-utils.volumetricDemois the shared fixture behind both.⚠️ EXPERIMENTAL —
portalTransform, the pure math for see-through portals:portalCamera(where the render-target camera goes so a doorway shows another place),clipPlaneFor(the oblique near plane, without which the far room leaks into the doorway),crossedPortal(a step test — a jeep is past a doorway within one frame, and a context swap one frame late is one frame of the wrong world), anddepthLimit/attenuationAtfor recursion.Recursion terminates the way real mirrors do — each traversal is slightly lossy, so the tunnel darkens instead of hitting a hard cutoff that pops. Calibration worth knowing: real glass is ~95% transmissive, which is why mirror corridors look infinite — at 0.95 it takes 77 bounces to fall below 2%. So the physics gives the right look and a useless budget; the device cap is load-bearing, and a portal that should visibly fade out has to be markedly dirtier than glass (~0.5–0.7). That trade is documented rather than hidden behind a physical-sounding parameter.
The falloff is a curve, not a ratio —
geometricFalloff,linearFalloff(state the level count instead of solving for it:0.1means exactly ten) andacceleratingFalloff(the loss grows with depth — dirtier where nobody looks). That decoupling is the point: geometric-at-0.95 and accelerating-from-0.05 keep an identical 95% at the first bounce, the one anyone actually inspects, but cost 77 levels versus 5.No renderer yet: applying the clip plane, the passes and stencil framing are deliberately out of scope for a pure module.
⚠️ EXPERIMENTAL —
MediumOptics+fogLayerFor(MEDIUM-DESIGN.md §3). A medium can describe how it LOOKS (colour, density, murk-with-depth, visibility floor), and one derivation turns that into a fog layer for the compositor that already exists. Weight issubmergence, so the fog and anything else keyed off the same medium cannot disagree about where the surface is — that disagreement is what produced the fogged-sky vs transparent-window conflict.Not load-bearing yet, on purpose.
b3d-waterpublishes its optics but still computes its own fog layer, because that path is verified and swapping it is a visual change that wants an eye on it. What's proven so far is the falsifier from the design doc: one derivation reproduces water's numbers (including thickening with depth), a cloud bank's soft edge, and vacuum contributing nothing. Three hand-rolled contributions collapse onto it in the tests; collapsing them in the code is the next step.Projectiles understand medium (#13 — ⚠️ EXPERIMENTAL, unflown). A round now picks up drag from whatever it is passing through (
dragAt, blended across the band so entry is a ramp), and gains three options that turn the same round into four weapons:whenCrossing(kind, medium, at)— the entry splash, the breakout plume, the audio. Yours to dress; the engine only tells you it happened.detonateDepth— a depth charge: a fuse on depth rather than impact.stayIn: 'water'— a torpedo, for which the surface is a ceiling. Held just inside rather than reflected: a round that bounces off water reads as a skipping stone, which is a different weapon.
A sub-launched missile needs no option at all — it is a
whenCrossinghandler that swaps its own behaviour on'exited'. That the four fall out of three small options is the argument for the primitive being shared rather than per-weapon.medium — the substance you're moving through, pure and Babylon-free (#13, and the seam #3/#15/#16 were all converging on). It answers three questions and owns nothing else: how deep am I (
depthIn, signed — depth and altitude are one measurement), how much of me is in it (submergence, smoothed across a band), and did I just cross the surface (crossing, a step test, because at 200 m/s a round is above the water one frame and 3 m under it the next).- Two geometries:
plane(a sea, a fog bank, the top of a mercury vat) andsphere(a planet's ocean or atmosphere, the edge of space). A plane is NOT a very large sphere — at a 6371 km radius the arithmetic loses the centimetres that decide whether you're above or below a wave. innermostresolves space → air → water the way you'd say it aloud, and vacuum is simply the absence of a match.dragAtblends a projectile's drag coefficient across the band, which is why depth charges and torpedoes need no new integrator.b3d.media/b3d.addMedium(), andb3d-waterregisters itself — so nothing has to re-derive where the surface is. Two subsystems deriving that separately is exactly how the fogged sky and the transparent window ended up disagreeing (#12/#15).
- Two geometries:
b3d-ambientspawns where you're LOOKING and where you're GOING (#17, #18):lookAhead(0.35 ofradius, along the view) andlead(0.25s of travel, along motion), capped byspeedCap. A camera sees a frustum, not a sphere, so a box centred on the eye births most of its particles behind and beside you — they live and are culled unseen, paying full budget for a fraction of the result — and at speed you outrun the box entirely, emptying the view ahead exactly when you want it fullest. Velocity is measured from the camera's own displacement, so nothing has to be plumbed in; in a chase view that's the right signal anyway. Set either to 0 for the old centred box.b3d-destroyabletakeslibrary+meshName(#22) — a static, destroyable thing that uses a library mesh is most of what populates a level, and there was no route to it:b3d-loadertakes aurl(which also loses the canonical frame),b3d-aircraftgets the frame right but flies. Absentlibrary, you still get the placeholder cube, so nothing existing changes. The model is left pickable on purpose — damage resolves from the warhead gathering destroyables near the detonation, not from which mesh the ray hit, so the model can simply be the target and the hidden-cube-plus-skin dance isn't needed.spawnProjectile/spawnMissiletake amesh(#19) — draw the round as an authored model, with the engine still owning motion, collision, lifetime and disposal. It is also oriented along velocity, which a sphere never needed: the engine moved a sphere, a sphere has no facing, so a modelled missile flew sideways. Yaw and pitch only — a round has no reason to bank. Supplied meshes and their children are made non-pickable, like the default.panelFitWidth(fov, aspect, z, want, fill)— how wide a camera-relative panel may be and stay on screen. Shared because there are two such panels and a constant copied into both is a constant that will disagree with itself.PAUSE — ⚠️ EXPERIMENTAL. The flat path is confirmed on a real browser (start paused, continue, background, return, repeat) but the VR path — the Continue tap carrying the user gesture into
enterXRAsync, and removing the headset pausing — is unvalidated, and it is the reason the feature exists. Treat the API as unsettled until it's been through a headset; nothing else depends on it.b3d.pause(reason)/.resume()/.togglePause()/.paused, a centred in-scene pause panel, and the attributes around them:pauseWhenHidden(default on) — backgrounding the tab holds the scene, so a player returns to a held frame and a panel rather than to a world that carried on without them. Suppressed while immersive, since some browsers report the page hidden during an XR session.startPaused— come up paused: the "press Start" shape.enterXrOnResume— resume enters VR, and leaving VR pauses. This is why starting paused matters:enterXRAsyncrequires a user gesture, so a scene that tried to enter XR on load would simply be refused. The Continue tap is the gesture, which makes "leave it paused, put the headset on, press Continue" the only shape that reliably works.pausePanel(host, resume)— replace the default rows (a title and Continue) with your own: a title screen, settings, a "you were away" summary.resumeis handed in, because a pause panel with no way out is the failure this feature exists to prevent.pause/resume/orientationevents.pausecarries areason('user' | 'hidden' | 'xr' | 'start') so a game can tell "the player asked" from "the tab went away".orientationis derived from the viewport (innerWidth/innerHeightswapping), not fromscreen.orientation— that API is patchy on iOS, so a scene depending on it hears nothing on the devices that rotate most; it's still read fortype/anglewhen present, as detail rather than trigger. Fires only when the classification actually flips, so dragging a desktop window edge isn't a rotation.
Rendering continues while paused — the panel has to be drawn and picked — but nothing advances: no combat tick, no fog, no
updatehook, and controllables read empty input, so a held stick can't steer a stopped world. Orientation changes are reported, not acted on: a rotating phone is sometimes a pause and sometimes nothing, and only the game knows which. (Tilt is a separate, permissioned API on iOS and deliberately untouched.)The stick sign contract is now a test (
stick-sign.test.ts) — every source must report up/forward as positive, and all of them must agree. This was documented in three places and enforced in none, which is what let #14 run for a day: a source that disagrees is internally consistent, so only a player with two devices notices, and what they report is "the framework is broken". Verified by deliberately flipping the touch and hardware signs in turn and watching it fail.STICK_UP_IS_POSITIVEnames the convention.GamepadSource.kind— a stable'keyboard' | 'hardware' | 'touch' | 'xr' | 'glass'marker, because the obvious alternative fails silently: an adopter matched our glass pad withconstructor.name, their bundler mangled it, the lookup never matched, and every experiment built on it was a no-op that looked like a result. Class names are not API.A gamepad gadget in the gear panel — one tap to pin the glass pad visible or hand it back to the auto-hide. The fade is production-correct and development-hostile: once a mouse or trackpad is present the pad goes away and doesn't come back, so checking it on a laptop meant switching Chrome into responsive mode. It sits in the same icon bar as the debug tools, so it works in a headset too.
B3dGamepad.setFade()/.hiddenare the API behind it.b3d.sceneBusyandterrain.busy— is the world still building? Any frame-rate judgement taken while these are true is a measurement of the loading, not of the device.b3d.ambientPoolScaleis now readable AND settable, so a game can say "that was the loading screen, try again" instead of reaching into two privates (#11).aircraftMappinghonours its config — it accepted one and had zero references to it.AircraftMappingConfigaddsinvertPitch(the arcade pitch convention),invertRollandinvertCameraY. Meaning belongs in the mapping: every source passes through it, so one setting can't leave the keyboard inverted and the glass gamepad not (#10, manta-recon).The pure flight model is exported:
flyByWireStep,regime,targetVelocity,chaseVelocity,equilibriumSpeedand theFlyByWireConfig/Command/Statetypes.equilibriumSpeedis what the HUD's new set-point mark is drawn from — a mission planner wants the same number, and until now it was unreachable from the package.isIgnored(name)— one matcher for the-ignoreconvention, used by the loader, the library andpublicName.carve — the cave vocabulary, exported as the
carve.*namespace (see ⚠️ Breaking):carve.sphere,carve.capsule,carve.tube,carve.box,carve.smoothUnion(fillets a junction so a passage flares into a chamber),carve.subtract,carve.intersect, plus the two perturbations that stop a carve looking carved:carve.roughen(texture the wall, silhouette stays) andcarve.warp(bend the space so nothing reads as a primitive).sdf-lattice — surface-nets extraction over ONE global hash-jittered lattice, so chunks weld bit-identically and cross-tile/cross-LOD seams are unrepresentable rather than stitched. Includes the chunk-weld proof as a test.
patch-field —
landform's volumetric sibling:(x, y, z, d) => d'carving composed against the terrain's own hooked height sampler.landformgainsgulley(a height FORCING function ending in a predictable cliff),cover(forced ground over a tunnel so it stays buried), andpadfrom 0.6.2's family.b3d-terrain:patchMask/patcheshooks,biomeLapseRate(the lapse is coupled to vertical scale — a 340m world on the small-world default renders entirely as snow), and a publicheightSampler().Landing gear, found by NAME — AnimationGroups mentioning "gear" and "retract" are cycled from height above ground, with hysteresis and an optional
gearSound. The animation is SCRUBBED, not played, because glTF animations arrive with a cyclic loop mode and a played group can snap back to frame 0.arcDashArray+hud.setMeterMarks— reference marks on any meter, drawn on the meter's own arc. Used for the throttle set point, sea level, and the ground beneath you.equilibriumSpeed(cfg, throttle, afterburner)— where a lever will settle.sceneDelta(scene)is now actually exported. 0.6.2 announced it and shipped it unreachable —dist/index.d.tshad no such symbol — so anyone following those notes hit a link error. It's the correct frame delta for anything ticking inside a scene observer (see 0.6.2's note ongetDeltaTime), and the six doc demos that still taught the broken idiom now use it.
Changed
tosijs-ui devDependency → ^1.9.8, for serve-time gzip on the dev server: it was sending the 10.7 MB bundle uncompressed even to clients asking for gzip, so over a thin link a doc page showed its pre-rendered HTML and then never hydrated — the bundle never finished arriving. 2.56 MB now. Doc-site tooling only; nothing in the published library changes.
⚠️ The aircraft throttle is a LEVER, not a speed setpoint. It commands an equilibrium: a climb settles at a new lower speed instead of stalling, and lowering the nose returns you to the speed you had, untouched. Full lever is MILITARY thrust; afterburner lights only while the trigger is held past a detent and drops back when released. Releasing the trigger holds the setting, so speed no longer self-sustains at idle — set the lever where you want to cruise.
⚠️ The right stick is the CAMERA, not aux roll: it orbits the chase camera around the aircraft and turns the pilot's head in the cockpit, springing back on release. (
strafestill sums into roll for anyone who maps a dedicated axis.)Hover gained a brake (the trigger's negative half sheds speed to a stop) and slow reverse (nose-up lean), with the trigger staying purely vertical so stopping never fights altitude.
hoverCeiling50 → 140.Speed gauges read 0..100% of the FULL envelope, afterburner included.
Terrain defaults: bigger gross features carrying less gross amplitude, with the detail layer doing real work — the old defaults read as pudding.
worldVdefaults to 0.25:CylinderSamplerreflects v, soworldV = 0put every scene ON a mirror plane with a seam running to the horizon.
Fixed
Phantom collisions: an aircraft "collided with something it was nowhere near". Flying high, banking in cockpit view, you died instantly — in any scene, terrain or not. The impact sweep was hitting the spatial UI panel floating in front of the cockpit and treating it as terrain, and that sweep crashes on ANY hit above
crashSpeedwith no slope test. It only bit in cockpit view (that is where the camera, and the panels riding its reference frames, sit inside a ~2m sweep) and fired on bank (which swings the velocity vector into the panel). A panel must stayisPickable— that is how a controller ray targets it — so the fix separates the two questions:markUiMeshmarks it collision-invisible, and both aircraft predicates filter it.Collision rays fired from
node.position. Under a_centerOfGravitypivot that is the STANCE ORIGIN, not where the airframe is — the model swings about the CoG under attitude, so banking moved the aircraft out from under its own rays. Both rays now go through the world matrix, which is bit-identical tonode.positionwhen there is no pivot.canonicalizedid not drop the CONTENT node's scene transform — see ⚠️ Breaking above. This is also why the scout's centre of gravity appeared wrong.Ambient effects could never recover from a pool of zero. The watchdog bailed one line before the recovery path written for exactly that state, so a scene that shed its garnish to nothing stayed dead for the session — no leaves, bubbles or motes on hardware that had been drawing them a second earlier.
VR: exiting dropped the flat camera's altitude. Babylon carries the walked headset pose back into the non-XR camera, which for an orbit camera recomputes a low angle — you left VR looking at the scene from the floor. The orbit state is now snapshotted on entry and restored on exit; free/walkable cameras keep the carry-back.
VR: orbit demos dropped you in facing a random direction. Both piloted paths seeded the rig's yaw against your head; free locomotion never did, so a demo with no piloted entity left the panel behind you and the subject out of sight. Now seeded on the first frame with a real viewer pose, facing what the flat camera was looking at.
VR: re-seat did nothing in an aircraft's chase view. Three camera paths capture yaw separately and re-seat only re-armed two; toggling cockpit↔chase appeared to "fix" it because the view-change path re-armed the third.
VR: locomotion was scale-blind. Free-fly walked at a fixed 2.5 m/s, which in a 512-unit landscape is ~0.5% of the scene per second — indistinguishable from a broken stick, while turning (angular, therefore scale-free) felt fine. Speed now scales with the orbit camera's radius; small demos are unchanged.
VR: the grid floor sat at the world origin, so in any scene whose subject is elsewhere it read as a mystery grid off in the distance, visible only in VR. It now sits under the subject.
VR: the respawn panel was pinned to your face and jittered with every head movement. It rides the
bodyframe now.bun run typecheckwas red at the release commit — a one-argument call to a three-argumentcanonicalizein a test, invisible tobun test(which strips types) and to the build tsconfig (which excludes test files).Shadow acne on large receivers — see
shadowNormalBiasabove. It was also why scenes looked dull: the stipple darkened roughly half the ground.b3d-svg-planecleared a parent it never set. ItscameraRelativesync ranmesh.parent = nullunconditionally whenever the flag was off, so anything parented to a plane was silently torn off a frame later, with no error. Same shape as the pause bug below: a guard must record what you DID, not observe what state the object is in.The glass gamepad no longer fades when you move the mouse. It is operable by pointer —
pointerdowndrives the sticks — so it vanished exactly as you reached for it. Only a keypress or a physical gamepad fades it now.publicNameleaked loader and Blender artifacts. The library picker showedbuilding_collideCylinder_primitive0;_primitiveNis the glTF loader splitting a multi-material mesh, and Blender's.001sat AFTER the behaviour suffix so noendsWithmatched it..001is preserved (it is a different object) while the annotation is removed.ryis DEGREES, and now says so.AbstractMesh.render()multiplies rx/ry/rz byDEG_TO_RAD; nothing documented it, and the b3d pause demo had been adding radians per second for its whole life — about one revolution per ten minutes, which read as "the cube doesn't spin".Impacts now report the surface NORMAL, not just the point (#29) —
spawnProjectile/spawnMissiletakewhenImpact({ point, normal, mesh }). The swept ray already computed the normal and the launcher was throwing it away, so anything that wanted an oriented effect (a scorch mark, a dent, a ricochet) had to re-cast the same ray to get it back.normal/mesharenullwhen there is genuinely no surface — a depth fuse in open water, a timed round in mid-air. The oldonImpact(point)still fires with a one-shot deprecation warning; it was renamed off theon*prefix because such a key becomes anaddEventListenercall the moment the shape is lifted onto an element, and then silently never runs.Pause didn't pause (#30) — an adopter measured a player travelling 66.58 m during a 3-second pause with
paused === truethroughout. Two clocks leaked, not one.sceneDeltaaccepted a published frame delta only when> 0, so the one value meaning "stopped" was the one value it discarded, falling back to the live engine clock — everything on the render observable (projectiles, water, ambient) kept running. AndB3dControllableruns its ownDate.nowclock that a stopped scene delta cannot reach: it was fed empty input, but with no input an aircraft COASTS, which is indistinguishable from cruising. A paused scene now publishes a delta of zero and controllables halt outright.Blasts could not see library-backed destroyables (#28) — a third consequence of
library.detonateWarheadresolved targets withscene.getMeshByName, which searches meshes only, so alibrarydestroyable's TransformNode root was filtered out of every AOE. manta measured it at 4 of 4 targets. It now uses the element's own node, which was always right there.library+explodeon a destroyable was a guaranteed crash (#24) — and a nasty one. The exploder read vertex buffers off whatever it was handed, and a library instance's root is a geometry-less transform node. Worse than a throw: it happened inside a render observer, so every observer after it was skipped — camera follow, flight integration, all of it — and the scene stopped advancing while input kept responding. It presents as "the game seized but the controls still work".explodeMeshnow takes aTransformNodeand shatters the descendants that have geometry (a multi-part model comes apart properly instead of one piece shattering while the rest hangs in the air), warns once rather than throwing when there is nothing to shatter, and the death path wraps the call because a cosmetic must never be able to stop the frame loop.Mutual death produced no death panel (#25). The already-dying recovery added in 0.7.0 ran before the "only OUR death matters" filter, so any other entity dying while the player was dead tore the panel down — ram an enemy and you both die, and you got nothing. Relevance is checked first now.
Volcanic veins were absent from every vertical surface (3D Worley), the rock stopped warming at dark brown so a fully volcanic face read as black, verticals could never exceed stage 2 so the top of the ladder was unreachable on a cliff or cut face, and the glow pulse touched only the molten channel — a fraction of a fraction, which is invisible in practice.
Volcanic veins were missing from every vertical surface. The plate pattern was sampled from world XZ only, so a cliff or a cut face barely moves through the noise — its xz coordinate is near-constant across the surface — and you got vertical streaks instead of a vein web. Now sampled with a true 3D Worley, which costs the same neighbourhood as blending three 2D projections (27 cells either way, a 3-wide hash instead of 2-wide) and is strictly better: no projection seams, no ghosting where planes blend, and a cut face shows the veins' real cross-sections because the veins are genuinely three-dimensional. Only the volcanism branch reaches it, so non-volcanic surfaces pay nothing.
The HUD circle dominated a portrait viewport. It was already sized off the smaller dimension — that part was right — but a single percentage of the small side means 36% of the width in landscape and 70% of it in portrait, the same rule with twice the visual weight. It's now capped at half that fraction of the long side as well, so turning the device doesn't change how much of the screen the circle eats: measured 490px (was 499) at 1400x713, and 265px (was 350) at 500x757.
hudSizePxinhud-math.ts, unit-tested.b3d-death's Respawn panel had both of the pause panel's problems, and at a worse moment. Its spectator camera was attached to the canvas, so a tap meant for Respawn also drove the camera (Tonio, on a phone: "the view pivots"), and it used the same fixed 1.1-unit width that overflows a portrait viewport. The camera is now set with{ attach: false }— the orbit still runs, it's driven by an observable, you just can't wrestle it — and the width comes from the camera's FOV and aspect via the sharedpanelFitWidth.The pause panel fought the camera for your tap. It lives in the scene, so a press on it is also a press on the canvas, and the camera's input got it too: on a phone the first attempt to press Continue read as a pinch-zoom, which moved the camera through the panel and hid it, so the button had to be un-zoomed back into reach. The camera is now detached while the panel is up (which is also what "paused" ought to mean) and restored on resume.
The pause panel was wider than a phone's viewport. Sized at a fixed 1.1 world units, it fits a 16:9 monitor and overflows a portrait phone — at the default FOV only ~0.86 units are visible across, so the edges and the button sat off-screen. It now derives its width from the camera's real FOV and aspect.
panel-fit.test.tspins it, including the portrait case that broke.B3dGamepad.fadewas read once at connect, so toggling it at runtime did nothing in either direction — including from ascenePaneltoggle, which is this repo's own recommended way to expose a tweakable. A settings control that silently does nothing is worse than no control. Now evaluated live, andsetFade()reconciles on the tap rather than on the next pointer move.A guided missile consumed the caller's
guidehook (#13), so a game could give medium behaviour — water drag, a depth floor — to a dumb shell but not to a missile. The seeker is now a named function the caller's hook composes with, and the caller runs last so it can constrain what homing asked for: the seeker wants to go fast, the water says no.MissileOpts.guideis new.The sky ignored the water you were under (#12, manta-recon: "can't see anything underwater except for the skybox"). A skybox is built
applyFog = falsewithinfiniteDistance = true— right in air, where a long fog layer would swallow it — so submerged, the fog worked on everything except the thing filling most of the screen.b3d-waternow turns the sky's fog on while the camera is under it, keyed off the same band weight as the fog layer so the two can't disagree about where the surface is, and hands it back on the way out. Note the trap it set: turningunderwaterFogDOWN made it worse, because the murk had been the only thing disguising an unfogged sky.The device probe measured the machine while it was still loading, then cached the verdict for 30 days (#11, manta-recon). It went out on
setTimeout(…, 0)— one task, not one idle moment — so the benchmark ran during terrain build and shader compile. It times work against a fixed reference and calls anything under ~1.67× the medium baseline low, so the machine that loads the biggest scene contends the most and tiers worst: an M5 Max holding 120fps was being given low-tier budgets for terrain, shadows, reflections and ambient at once. It now waits for ~1s of settled frames (sceneBusy), gives up after 30s, and caches a measurement it had to take under load with a short life so the next visit re-measures.Ambient was shed during the loading screen and never came back (#11). The watchdog's 10s warmup was a fixed timer where the question is "has the scene settled" — a streaming world loads for longer, so the one-way ratchet fired on frames that said nothing about the hardware and ambient stayed at zero for the session. The warmup clock now runs only while the scene is quiet.
b3d-deathlatched after the first death if the game respawned by its own route._dyingis cleared only byresume(), which only the panel's Respawn button calls — and that button doesn't exist without arespawncallback. So a scene that respawns from its owncrashlistener (a documented pattern) left the component stuck, and it then swallowed every later death: no panel, no focus release, welded to the wreck. That is the exact failure this component exists to prevent, one level up. A death from an entity that isn't the wreck it's holding now tears down and handles the new one, and a panel built with no way out says so in the console. (Found by manta-recon crashing a respawned aircraft.)Foo_ignorewas half-honoured: the loader disposed only the hyphen form whilepublicNamestripped only the underscore one, so an underscore-form node survived the load AND collapsed toFoo— colliding with a realFoo, which madeinstantiate('Foo')resolve to whichever came first. Both separators now go throughisIgnored.spinner()in the demo helpers still usedengine.getDeltaTime(), so on any page importing it (the water demo among them) crates spun ~4× slow at the defaultframeRate: 30— the one call site thesceneDeltasweep missed.A crashed aircraft releases input focus so a respawn can take over (#9). A wreck keeps input focus and ignores it, so with no
<tosi-b3d-death>in the scene the player held a dead controller — which reads as broken controls rather than as dying. The crash now releases focus if nothing else handled the event.recenter()resetworldVto 0, which is theCylinderSamplermirror plane — undoing the new default and putting a seam through the world, hours into a session where nobody would connect the two.Tile skirts hung 344m through tunnels — depth came from world amplitude rather than the tile's own relief.
_centerOfGravitymarkers, cave shading (interioras a depth ramp, flooding bywaterTable,noWater), volcanism confined to its caldera.
0.7.0-rc.1
Published to npm under the next tag on 2026-08-13, superseded by 0.7.0.
Everything in 0.7.0 above applies, with two differences that matter if you
installed it:
- It exported the 13
carvefunctions as bare top-level names; 0.7.0 moves them tocarve.*(see ⚠️ Breaking). - It did not export
sceneDelta, despite the notes promising it — thegetDeltaTime-in-a-scene-observer fix was unusable from the package.
It was tagged before the pre-release review ran, deliberately, to get it into an adopter's hands sooner. The review found no blocker in the shipped behaviour; what it found were the two release-artifact gaps above.
0.6.2
Terrain gets volcanism and authored landforms, clouds get real shapes, and a systemic timing bug that made half the engine run in slow motion is fixed. Additive only — no API removals, no peer-dependency changes. (The full terrain-system upgrade lands as 0.7.0 once its remaining pieces are in.)
Added
- Volcanism intensity ladder (biome-plugin) —
the
volcanismdial now climbs stages rather than scaling one look: near-black basalt with dark-brown voronoi seams → dark-brown rock with glowing seams → patchy open lava. Verticals lag one stage behind horizontals (lava pools flat; cliff faces drain and crust over), veins widen as the ladder climbs so pools form by veins merging, and the glow is subtly animated (spatially-phased pulse + drifting 3D-noise churn — never a global blink). Water pushes the ladder down half a stage and mutes the glow with depth instead of cancelling it: smouldering veins under shallow water. volcanicPalette+LAVA_PALETTE/CRYOVOLCANIC_PALETTE— the seven ladder colours are data, so a frozen world venting molten water (pale ice rock, teal veins, glowing blue-white melt) is a palette swap, not a new shader.- Local volcanic provinces —
b3dTerrain.provinceField = (x, z) => 0..1marks this island volcanic independently of the global dial. Sampled per tile vertex in origin-stable coordinates and carried to the shader in the colour buffer's (visually inert) alpha channel. - landform — authored landforms FORCED through
the terrain noise via the new
b3dTerrain.landformhook, where slope-profiles only remap it.volcano()andimpactCrater()each return a matched pair — the height shape and the volcanism province that makes it glow — so a volcano goes anywhere and an explosion leaves a glowing crater.pad()claims flat ground for cities and bases;composeLandforms/mergeProvinceschain them. Pure, deterministic, unit-tested. _centerOfGravitymodel convention — a child node with that suffix declares where a vehicle pivots in flight, while the root origin stays its on-ground stance point, so one model conveys both how it flies and how it plants.findCenterOfGravity/applyCenterOfGravityare exported;b3d-aircraftapplies it on both load paths.b3dClouds({ model })— an authored GLB mesh becomes the cloud lobe (normalized on load, bottom-aligned), and clouds are now built as clusters of 2–4 overlapping lobes laid out by size rank (biggest low, smallest on top straddling the pair) with aligned bases, since cumulus condense at a level.B3d.frameDelta+sceneDelta(scene)— the authoritative inter-render delta (see Fixed).
Fixed
- Everything ticking in a scene observer ran in slow motion. Babylon's
getDeltaTime()measures the engine's rAF tick, but scene observers only fire whenscene.render()runs — and<tosi-b3d>throttles rendering toframeRate. AtframeRate: 60on a 120Hz display that's half speed; at the default 30, a quarter. Measured live: a dropped bomb inherited exactly 50% of the aircraft's velocity. B3d now publishes the real delta and 14 call sites use it — projectiles, missiles, turret, radar, warhead FX, spawner, clouds, ambient, death orbit, exploder, star / star-system / planet / black-hole. canonicalizeapplied a spurious 180° yaw, flying correctly-authored (Blender −Y-forward) models backwards. Babylon reads node-local data raw — the handedness flip lives only on the discarded__root__— so the collapse now applies no rotation, it only cleans. Pinned against real content inmodel-frame.test.ts.- Weapons inherited a phantom velocity.
this.velocityis only the aircraft's hover/ground integrator and reads zero in wing-borne flight; weapons now inherit the true world velocity, so bombs fall with you and cannon rounds lead correctly. - Weapon muzzles are computed through the world matrix, so shots leave the visible airframe under any attitude or centre-of-gravity pivot.
- Flying into a cliff face passed through it — the ground check was a single downward ray, which reports the valley floor while a wall fills the windscreen. Airborne frames now also sweep along the velocity; steep contacts crash.
- Death could strand you in the wreck. Charring the wreck cloned a material carrying an unregistered plugin, Babylon threw, and the sequence aborted after the explosion but before releasing input focus — no spectate camera, no respawn panel. Material plugins are now registered for cloning, and death's exit is shielded so cosmetics can never block it again.
- Volcano calderas are flat-floored basins with the melt confined to the floor and a crusted rim (a smoothed shading normal can't make the rim read as open lava).
- Library instances carry their animations, retargeted onto the instance.
0.6.1
First consumer-reported fixes — both filed by manta-recon (tosijs-3d's first external adopter) with root-cause analyses read from the 0.6.0 dist. No API changes.
Fixed
- Aircraft chase camera never created when focus adoption lands after a fast
mesh load (#1). Both
sides deferred to the other:
setupMeshskipped the camera wheninputProviderwasn't set yet, andinputFocusearly-returned for any self-managed entity — an ordering hole between them left the player flying blind ondefault-camera. The focus side now nudges entities using the deferral pattern (idempotentsetupFollowCamera), so whichever side runs last completes the setup. - fly-by-wire: zero-speed deadlock above
hoverCeiling(#2). At full nose-up, pitch decay (diveBoost·sin(pitch)) could exceed thrust, pinning speed at the 0 clamp with the throttle held — a craft frozen mid-air. Held thrust is now floored to make progress belowvtolSpeed(a nose-high climb-out labors instead of freezing); the throttle-released zoom-climb stall — the deliberate "hard way into high-altitude hover" — is untouched.
0.6.0
Cut as rc.1 → rc.3 (each reviewed; rc consumers see the per-rc sections in git history), finalized after the nine-lens review's follow-up fixes landed.
The SVG UI surface release: a first-class, VR-ready UI substrate — container, overlay, widgets seam, table, keyboard — plus the interaction contracts that make the same surface work identically in the DOM, on a 3D plane, and under an XR controller ray. No peer-dependency changes.
Added
box— the flow container: blocks + inline items + wrapped text, background/ border/radius, resize-with-reflow, scroll regions, a coordinate-based event model (press/activate, raw capturinghandlePointerfor drags), and D-pad focus traversal with a visible ring.surface— content box + overlay layer: cascade menus and persistent draggable/closable panels,placePopupflip/clamp positioning.widget-box— the seam that letswidgets3dcontrols (sliders, toggles…) live inside abox/surfaceunchanged.table/table-layout— sticky header, virtualized body, drag-to-scroll, icon selection (seeselection), D-pad row traversal with the escape contract.keyboard/key-layout/text-edit— the on-screen typing surface for a headset: long-press accent strips (sticky on lift, generous ray-tolerant taps), spacebar caret-trackpad (slow taps still type), pressed-key tint, hold-signifier glyphs (▾ accents, ↔ caret drag), per-key D-pad focus;inputFieldwith code-point-correct editing and the receiver-caret model (lit = where text lands, dim = not;setActive/onFocusfor host exclusivity).key-layoutgrewnumpad/dial/email/urlmodes, gap-absorbing multi-unit keys (equal units ⇒ equal width), and vertical key spans (the numpad's tall enter).gamepad-focus— the D-pad → focus wire: edge-triggered with typematic repeat (pure, testablecreateFocusPulse), menu/A activates, andclaimscopes one physical pad to the last-touched UI when several live on a page.- Inner-focus protocol on
BoxChild/Widget3d—focusMove(dx, dy) → boolean(false = focus escaped, host moves on),focusActivate,focusClear,setStatereflection — so a composite widget (the keyboard) is one child but many focus stops. panelScene— dual-presentation in two lines: textured plane + orbit camera- pick routing (mouse AND XR-controller ray) →
handlePointer, with one gesture contract: aclaim(x, y)policy for orbit-vs-UI, gestures collected on a stable catcher quad via the pick ray (screen coordinates don't exist in a headset), mapped through the gesture-start frame so a target that rescales its own plane stays stable, camera-yield during claimed presses, off-plane release ends the gesture.
- pick routing (mouse AND XR-controller ray) →
svgIcons.resizeglyph (from tosijs-ui), used by the resizable-box demo's in-corner grip.
Changed
b3dSvgPlaneroutes the texture's alpha to the mesh (opacityTexture) — transparent svg regions (outside rounded corners) are transparent on the plane, not an opaque black substrate.- Pick-forwarded events (
pointerEvents: 'on') carry a synthetic pointerId, never the physical pointer's — a listener'ssetPointerCaptureon a forwarded event can no longer hijack the real mouse/ray stream. - Panel demos are side-by-side DOM + 3D (50/50) throughout, built on
panelScene.
Fixed
- The lost-pointerup chain: demos passing
pointerEvents: falsewere silently running with forwarding ON (tosijs discards wrong-typed prop writes — filed as tosijs#24); the forwarded events carried the real pointerId, so the flat mirror captured the physical mouse and the canvas stopped receiving pointerups. Fixed at every layer, and routing is now self-healing: a fresh down while a gesture is outstanding flushes the stale gesture (keyboard/box/surface), so a lost up costs nothing instead of wedging the session. SvgTexture: a failed rasterize no longer freezes the plane for the session — the busy latch always releases and the frame retries on the next tick.- Keyboard: accent strips stay inside the keyboard's rect (top row opens below the key); a strip tap can't fall through to the key behind it; releasing on the held key with jitter goes sticky instead of inserting a random accent; focus follows the click (ring on the tapped key); Space presses the focused key.
- Arrow keys no longer orbit demo cameras (
ArcRotateCameraKeyboardMoveInputremoved — arrows drive the UI).
Added (post-rc, from the review follow-ups)
ui.*namespace — the SVG UI family's value exports live on one container (ui.box,ui.table,ui.keyboard, …): common nouns don't leak from the barrel. Types stay top-level. Migration from any rc:import { ui } from 'tosijs-3d'and destructure.Box.interactiveAt/Surface.interactiveAt, andpanelScene's default claim policy asks them — pressing a button/panel claims the gesture, pressing static prose orbits the camera, consistently across every demo.panelGesture+uvToViewBox+planeLocalToViewBoxexported pure (the gesture contract is unit-pinned); named option types (TableOptions,KeyboardOptions,InputFieldOptions,PanelSceneOptions,GamepadFocusOptions);panelScenetakesupdateInterval;w3dTheme(one place reads the--w3d-*variables).
Fixed (post-rc)
table.focusMovespeaks the protocol's(dx, dy)(rc.1 trapped D-pad focus in a hosted table); menu leaf-select no longer destroys persistent panels (Surface.closeMenus).- box hover no longer sticks on raw children; a claimless
gamepadFocusinstance is no longer starved (click-away releases the claim). - Table scrolling within the window is transform-only and hover restyles
surgically (the virtualized body no longer rebuilds per drag-move);
textBlockcaches its wrap per width. SvgTexturewarns once per instance when rasterizes fail (self-healing had made it silent); deploy/tunnel host readsTOSIJS_DEPLOY_HOSTinstead of a committed address.
⚠️ Changed
B3d.onResize()/B3dHud.onResize()→handleResize()(theon*prefix is reserved by the element-creator's listener sugar). A 0.5.x consumer callingonResize()must callhandleResize().
0.5.2
⚠️ Breaking — peer dependency (tosijs ≥ 1.7.8)
tosijspeer range^1.6.9→^1.7.8. Consumers must be ontosijs≥ 1.7.8; a project on1.6.xwill hitERESOLVE. (1.7.6/1.7.7 are deprecated on npm — aparts-resolution regression; 1.7.8 fixes it, and tosijs-ui 1.7.4 requires it.) As with 0.5.1, this ships under a patch deliberately —0.xnarrative versioning, with0.5.0reserved for the playable-game cut — so it's flagged here rather than forced to a minor.- Dev/build only, no consumer action:
tosijs-ui1.6.23→1.7.4, andchokidaradded todevDependencies(tosijs-ui 1.7.x's shipped doc-site pipeline imports it at runtime but declares it dev-only — filed upstream).
Added
svgIcons(svg-icons.ts) — an SVG icon proxy (svgIcons.<name>()→ an SVGElementCreator) over a focused, generated icon set (icons/{color,stroked,filled}/*.svg→icon-data.tsvia tosijs-ui'stosijs-make-icons;bun run icons). A composition-suffix subset (rotate/flip/scale/translate/opacity/stroke-width/colour) + the generator's directional redirects + hand-authored aliases (iconAliases, e.g.moreHorizontal). ~30 icons incl.tosijs3d,xrColor,tosiXr, keyboard, checkbox/stats/status marks.iconGlyph— a texture-safe icon primitive (explicit-colour SVG<g>) for embedding icons in in-scenewidgets3d/SvgTextureUI, wherecurrentColorand CSS vars don't resolve in the raster.flowLayout(flow-layout.ts) — a pure CSS block/inline-block flow layout core: the substrate for a first-class SVG UI surface that lives in the DOM and on a 3D texture. Babylon/DOM-free, unit-tested.b3d.snapshot()— capture the current view as a PNG data URL (resolution-independent, via Babylon's render-target screenshot; works flat, including in-scene panels).icon-name.ts— the pure composition-suffix parser behindsvgIcons.
Changed
- Scene chrome → icon lozenge. The top-left toolbar is now one rounded pill
with two
svgIconsbuttons (settings +xrColorEnter-VR); the scene-panel×close is acloseicon. The doc-browser header logo =tosiXrand the favicon = the teal cube, both via tosijs-ui 1.7.4's newlogooption. - README gained a 280px logo and its stale
xinjs-3dlinks were fixed.
Fixed
- keyboard icon rendering — the generator strips
fill-rulefrom non-coloricons, so its evenodd key-holes broke; moved tocolor/withcurrentColor(filed upstream).iconGlyphbakescurrentColorto an explicit colour so it renders in the texture raster too. - Lozenge visible without
:has()— it defaulted todisplay:noneand revealed via:has(), so a browser lacking:has()(older Firefox) never showed the toolbar. Now default-visible;:has()only suppresses the empty-pill flash. - The svg-icons "composition suffixes" snippet is a static code block (was an erroring live example).
0.5.1
⚠️ Breaking — renamed public methods (on* → add*Listener / handle*)
No peer-dependency changes this release. The break is a method rename: tosijs's
elementCreator treats any on<Event>-named member as addEventListener sugar and
shadows a component method of the same name (it now fires a live warning, and it was a
latent bug — the handler silently never ran). So the multi-listener subscribe methods and the
lifecycle hooks moved off the on* prefix. The new names are also more honest: the subscribe
methods push to a listener list (add/remove semantics), not a single-handler setter.
| Old (0.5.0) | New (0.5.1) | On |
|---|---|---|
onSceneAddition / offSceneAddition |
addSceneListener / removeSceneListener |
B3d (scene owner) |
onOriginShift / offOriginShift |
addOriginListener / removeOriginListener |
B3d (scene owner) |
onGainFocus / onLoseFocus |
handleGainFocus / handleLoseFocus |
B3dControllable |
onButton |
handleButton |
TouchGamepadSource, B3dGamepad |
What a consumer must do: if you wrote a custom scene child that called
owner.onSceneAddition(cb) / owner.onOriginShift(cb), or subclassed B3dControllable and
overrode onGainFocus/onLoseFocus, or a gamepad source with an onButton callback, rename to
the new members. It's a pure rename — signatures are unchanged. Built-in components are all
migrated; this only affects code you wrote against these surfaces.
Added
- Coordinate-free
MinSimApi(world-contract.ts§8) — a world-simulation boundary where coordinates never cross the membrane: a driver (an AI narrative engine, a scripted demo) sees topology + a qualitative distance ladder, neverx/y/z. Places, portals, a 7-rung proximity ladder (same-spot…present,elsewherefor a different place), aSchematicView("where am I", exits, contents-with-rung), androute().WorldStorenow implements it alongside its existing surface — the sim keeps real geometry sim-private and answers only in qualities. world-topology.ts— the pure, Babylon-free, deterministic spatial math behind the surface:proximityRung(distance, extent)(a distance in, an adjective out; bands scale with a place'sextent),rungNominal(the inverse, for placement),routePortals(cheapest portal path, bidirectional, locked portals impassable — Dijkstra, deterministic tie-break),containmentPath(root→here breadcrumb). Unit-tested without a store or an engine.min-sim-conformance.ts— a framework-agnostic shared conformance kit (runMinSimConformance(makeApi, harness)) that pins the contract behaviour identically for anyMinSimApiimplementation. It imports no test runner (so it ships in the library) and takes thedescribe/test/expectharness as an argument — the same kit runs in this repo and in a driver's repo (the Ariosto use case), proving both stores behave the same at the seam.- Clouds drift with wind — the cloud layer now moves with the wind vector, so the sky is alive rather than a still image.
Changed
- Demo cameras migrated to a shared
demo-utilsorbitCamhelper (tilt-clamped so an orbit can't dip below the horizon) instead of 24 hand-rolled per-demo cameras. Authoring-only — no library-API impact.
Fixed
- Cloud whiteout now behaves correctly: it ramps to full earlier (less "snaps on at the moment of entry", denser once inside), blots out the sky (not just scene geometry), and mutes projected cloud shadows under the whiteout (fogged fragments no longer darken through the fog).
b3d-controllerplayernow defaults tofalse. tosijs began throwing on aninitAttributesboolean that defaults totrue(an absent HTML boolean attribute isfalse, so atruedefault can never turn on) — the oldplayer: truedefault made the controller throw at construction and silently never wire input.
0.5.0
⚠️ Breaking — peer dependencies
- Babylon.js 8 → 9. Peer range for
@babylonjs/core,@babylonjs/gui,@babylonjs/loaders,@babylonjs/materialsmoved^8→^9. Consumers must upgrade their Babylon to9.x. A project still on Babylon 8 will hit anERESOLVEon install. - Jolt physics
^1.0→^1.1(Jolt 5.6.0 — the friction model moved). Only relevant if you use<tosi-b3d-physics>; retune friction if grip/slide changed. - Packaging fix:
@babylonjs/*andjolt-physicsare now peer-only (they were previously declared in bothdependenciesandpeerDependencies). A hard Babylon dependency would nest a second engine copy in a consumer holding a direct Babylon dep — silently breakinginstanceof, engine singletons, and plugin registration. No action needed by consumers; this just makes install resolution correct.
Added
- Clouds (
<tosi-b3d-clouds>) — an opaque blob cloud layer you can fly into and lose the world inside (a fog whiteout, not a texture). Acoverageweather dial from wisps to thunderheads, andinsideCloudso a cloud is a tactic (break a radar lock, shake a pursuer). - Projected cloud shadows (
cloud-shadows.ts) — the field painted top-down into one texture, sampled by world position in a material plugin, so shadows conform to terrain and fall on elevated receivers (the aircraft) too. Reusable. - Shadow decals (
shadow-decal.ts) — a reusable soft blob-shadow for a single caster (character, vehicle, dropped item). - Death (
<tosi-b3d-death>) — death's exit: explode + burning wreckage, release input, a third-person spectate shot, then a respawn panel. Flat and VR. - Encounters —
<tosi-b3d-spawner>(seeded, "same seed, same battles"),formations.ts(pure placement math), and a namedprefabregistry withspawnPrefabfor the set-dressing a death or spawn drops. - Ambient effects (
<tosi-b3d-ambient>) — device-budgeted garnish (motes, bubbles, tumbling leaves) that competes for one pool and switches off rather than thinning into a lie. Pure budget allocator inambient-budget.ts. - Atmosphere (
atmosphere.ts) — pure fog compositing so underwater / cloud / space whiteouts layer over the base fog instead of fighting it. setGameplayCamera— an XR-safe viewpoint affordance (move the rig in a headset, swap the camera when flat), so chase/death/vehicle transitions stop breaking in VR.PLATFORM.md— the platform bet (stay on the web, don't abstract the renderer; the real risk of the Android XR / Vision Pro shift is input).
Changed
- Aircraft — the stick is dead on the ground (only the throttle lifts off); you can't crash-land until you've actually cleared the pad; chase camera banks smoothly and no longer jitters on throttle.
- Water follows the camera as an endless sea; terrain auto-centers so a sea
at
y=0floods the valleys. - HUD survives respawn.
- Babylon
8.56 → 9.16, Jolt1.0 → 1.1, tosijs-ui1.6.22 → 1.6.23, tosijs1.6.8 → 1.6.9(dev-server/haltija reliability), haltija→ 1.4.0.
Fixed
- Death explosion/wreck FX land at the real wreck; a respawn is no longer charred (char a cloned material, not the shared library one).
- The aircraft ground-ray no longer picks a cloud blob as "ground" (mid-air "crash"); cloud whiteout is opaque under LINEAR fog and builds from every direction; the live-debug timer is cleared on scene disconnect (no leak).