Skip to content

Types: reconcile the 2D/3D position vector split across Renderable and the shape chain #1510

Open

Description

@obiot

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:

  1. ObservableVector3d extends ObservableVector2d (add z + 3D method overloads), so a 3D vector is-a 2D vector. Cleanest if the method signatures (set, add, clone, copy, return-this types, the Observable callback plumbing) can be made compatible. Same for Vector3d/Vector2d.
  2. Keep them separate but make the 3D set/members a superset signature that's structurally assignable to 2D (e.g. set(x?, y?, z?), z optional where the base expects 2D) — verify TS treats it as assignable.
  3. 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/ObservableVector3d and every signature that returns this or takes a vector.
  • Must not change runtime behavior (pos is already 3D at runtime); this is a type-system correctness change.
  • Watch pool typings (vector2dPool / vector3dPool), Matrix*.apply(v) overloads, and anything doing "z" in v narrowing.
  • Verify no regression in the full type-check + test suite; check that 2D-only call sites still infer ObservableVector2d where intended.

Acceptance criteria

  • ObservableVector3d is assignable to ObservableVector2d (and Vector3d to Vector2d).
  • Camera3d (and ideally any 3D renderable) accepts pos.set(x, y, z) and pos.z with no error.
  • The pos.set(x, y) + depth = z workarounds in the glTF / night-city examples can be reverted to pos.set(x, y, z).
  • pnpm type-check + full test suite green; no runtime behavior change.

References

  • src/math/observableVector3d.ts (set(x = 0, y = 0, z = 0)), src/math/observableVector2d.ts
  • src/math/vector3d.ts, src/math/vector2d.ts
  • src/renderable/renderable.js (this.pos = new ObservableVector3d(...), @type {ObservableVector3d}; depth ↔ pos.z)
  • src/camera/camera3d.ts (the declare pos override that currently fails; lookAt)
  • Workaround sites: packages/examples/src/examples/gltf/ExampleGltf.tsx, packages/examples/src/examples/nightcity/ExampleNightCity.tsx

Activity

  1. obiot commented on Aug 28, 2026

    @obiot
    MemberAuthor

    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 Camera3d

    The mismatch runs across the whole shape chain, not only the 3D camera:

    type declared pos
    Ellipse Vector2d
    Polygon Vector2d
    Line (← Polygon) Vector2d
    Rect (← Polygon) Vector2d
    Renderable (← Rect) ObservableVector3d at runtime

    Two points from that issue worth preserving:

    • It has already blocked unrelated cleanup. Attempting to simplify the Renderable implementation 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.
    • The 3D component may not be used consistently. Collision detection in particular may ignore z, and arguably all shapes should carry at least a Vector3d position even if only Renderable needs 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 — pos is an ObservableVector3d at runtime that TypeScript sees as a Vector2d:

    That 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.pos declared as ObservableVector3d (it already is one at runtime), not only Camera3d
    • the shape chain's pos reconciled — at minimum consistent, ideally Vector3d
    • the casts listed above removed, and the #817 comment in the WebGPU quad batcher updated
    • confirm whether collision detection should consider z, or document that it deliberately does not
  2. changed the title [-]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[/+] on Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions