Repository navigation
Types: reconcile the 2D/3D position vector split across Renderable and the shape chain #1510
Description
Summary
Make ObservableVector3d (and Vector3d) assignable to ObservableVector2d (Vector2d) so 3D renderables — notably Camera3d — can re-declare pos as 3D and accept pos.set(x, y, z) / pos.z in TypeScript, matching what already happens at runtime.
Problem
Renderable.pos is constructed as an ObservableVector3d at runtime:
// src/renderable/renderable.js
this.pos = new ObservableVector3d(x, y, 0, () => { ... }); // @type {ObservableVector3d}…so pos.set(x, y, z) and pos.z are correct at runtime (and Renderable.depth is literally a proxy for pos.z). But the declared type of pos resolves to the 2D ObservableVector2d (inherited via the Rect → Polygon base chain), so TypeScript rejects the 3D form:
camera.pos.set(x, y, z);
// ts(2554): Expected 2 arguments, but got 3.Consumers currently have to work around it with camera.pos.set(x, y); camera.depth = z; even though pos.set(x, y, z) is the natural, correct call for a 3D camera. (Came up while writing the glTF / night-city examples — Camera3d positioning.)
Why the obvious fix doesn't work
Re-declaring the property on the 3D camera:
// src/camera/camera3d.ts
declare pos: ObservableVector3d;fails to compile:
error TS2416: Property 'pos' in type 'Camera3d' is not assignable to the same
property in base type 'Camera2d'.
Types of property 'pos' are incompatible.
ObservableVector3d and ObservableVector2d are independent sibling classes — 3D is not assignable to 2D — so the override breaks the Liskov relationship with Camera2d (and cascades into lookAt). The fix has to happen at the vector-type level, not the camera.
Proposal
Restructure the observable (and plain) vector types so the 3D variant is a structural superset of the 2D one — i.e. ObservableVector3d is assignable to ObservableVector2d. Options to evaluate:
ObservableVector3d extends ObservableVector2d(addz+ 3D method overloads), so a 3D vector is-a 2D vector. Cleanest if the method signatures (set,add,clone,copy, return-thistypes, the Observable callback plumbing) can be made compatible. Same forVector3d/Vector2d.- Keep them separate but make the 3D
set/members a superset signature that's structurally assignable to 2D (e.g.set(x?, y?, z?),zoptional where the base expects 2D) — verify TS treats it as assignable. - A shared base interface (
Vector2dLike) that both implement and that APIs accept, if full assignability proves too invasive.
Then re-declare Camera3d.pos: ObservableVector3d (and consider Renderable.pos typed 3D generally, since it is 3D at runtime) and drop the pos.set(x,y) + depth = z workarounds.
Scope / risk
- Touches the core math types used everywhere — needs a careful pass over
Vector2d/Vector3d/ObservableVector2d/ObservableVector3dand every signature that returnsthisor takes a vector. - Must not change runtime behavior (pos is already 3D at runtime); this is a type-system correctness change.
- Watch
pooltypings (vector2dPool/vector3dPool),Matrix*.apply(v)overloads, and anything doing"z" in vnarrowing. - Verify no regression in the full type-check + test suite; check that 2D-only call sites still infer
ObservableVector2dwhere intended.
Acceptance criteria
ObservableVector3dis assignable toObservableVector2d(andVector3dtoVector2d).Camera3d(and ideally any 3D renderable) acceptspos.set(x, y, z)andpos.zwith no error.- The
pos.set(x, y) + depth = zworkarounds in the glTF / night-city examples can be reverted topos.set(x, y, z). pnpmtype-check + full test suite green; no runtime behavior change.
References
src/math/observableVector3d.ts(set(x = 0, y = 0, z = 0)),src/math/observableVector2d.tssrc/math/vector3d.ts,src/math/vector2d.tssrc/renderable/renderable.js(this.pos = new ObservableVector3d(...),@type {ObservableVector3d};depth↔pos.z)src/camera/camera3d.ts(thedeclare posoverride that currently fails;lookAt)- Workaround sites:
packages/examples/src/examples/gltf/ExampleGltf.tsx,packages/examples/src/examples/nightcity/ExampleNightCity.tsx
Activity
example: procedural night-city flythrough (emissive showcase)
feat(gltf): material features Tier 2 + mesh-batcher perf + texture-fi…
obiot commentedon Aug 28, 2026
Folding #817 into this one — it is the same root cause stated as a problem, where this issue states the solution. Closing it as superseded, and carrying over the context it holds that this issue does not.
From #817: the wider hierarchy, not just
Camera3dThe mismatch runs across the whole shape chain, not only the 3D camera:
posEllipseVector2dPolygonVector2dLine(←Polygon)Vector2dRect(←Polygon)Vector2dRenderable(←Rect)ObservableVector3dat runtimeTwo points from that issue worth preserving:
Renderableimplementation and remove unnecessary bounds objects ran into the type mismatch and stalled. So the cost is not only ergonomic at call sites; it has prevented refactoring.z, and arguably all shapes should carry at least aVector3dposition even if onlyRenderableneeds the observable variant.Evidence this is still costing us
Three casts were needed in a single session's work (2026-08-28), all for the same reason —
posis anObservableVector3dat runtime that TypeScript sees as aVector2d:particle.tsneededthis.depthinstead ofpos.zwhen building a particle's absolute positionpos.setMuted(x, y, z)was unreachable from TypeScript while exploring a particle optimisationpos.setCallback(...)shipped in #1608 behind a cast, with an in-code comment pointing at Inconsistent position vector type between me.Renderable and base shape objects #817That last one is now a permanent marker in
src/video/webgpu/batchers/quad_batcher.js— worth updating to reference this issue once #817 closes.Suggested scope for this issue
The acceptance criteria above stand. Adding, from #817:
Renderable.posdeclared asObservableVector3d(it already is one at runtime), not onlyCamera3dposreconciled — at minimum consistent, ideallyVector3d#817comment in the WebGPU quad batcher updatedz, or document that it deliberately does not[-]Types: make ObservableVector3d/Vector3d assignable to 2D so Camera3d accepts pos.set(x, y, z)[/-][+]Types: reconcile the 2D/3D position vector split across Renderable and the shape chain[/+]