transform: std/transform 2D: Transform2, GlobalTransform2 and propagation (step 3a of #89) #779

Open
opened 2026-11-14 04:01:48 +00:00 by orion · 2 comments
Collaborator

Context

Step 3 of 3 of #89 (ECS slice 1), first sub-step. The specification is ECS.md section 7 ("Hierarchy"), section 11 ("The package cut", std/transform) and the owner's decision in section 15 q7 (GlobalTransform2 is its own component, not a field of Transform2). Steps 1 and 2 (#453, #763) are merged: std/ecs has Column, Store, query/pairs, Parent and depthOrder. std/linear exists on main (Vector2, Angle, Matrix2, Matrix3, generic over the scalar).

The whole of step 3 (2D and 3D) is more than one PR, so it is split into two issues, filed just in time: this one is 3a, the 2D half; 3b (Transform3, GlobalTransform3, toMatrix4, 3D propagation) is filed when 3a has landed.

Goal

A new package std/transform with Transform2 and GlobalTransform2 as separate components over the scalar, their arithmetic, and the one propagation system over depthOrder, generic over the stores it reads and writes.

Depends on

#453 (merged), std/linear on main. Independent of #763 (merged). No seed impact: no syntax, no native, no driver flag, no std name the compiler looks up by string (a new package, nothing renamed).

Requirements

  • R1. The package std/transform (std/transform/project.trb with runtime dependencies std/linear and std/ecs; std/ecs itself stays free of std/linear, ECS.md section 11) shall provide public type Transform2<Scalar: Real = Float> with translation: Vector2<Scalar>, rotation: Angle<Scalar>, scale: Vector2<Scalar>, Equals, Show, and static identity (zero translation, zero rotation, scale one).
  • R2. Transform2 shall provide composed(with: Self): Self (the child transform written in this transform's space, so parent.composed(with: child) is the child's transform in the parent's parent space), inverse(): Self? (None when a scale component is zero), interpolated(toward: Self, by: Scalar): Self (translation and scale linear, rotation by the shortest Angle difference) and toMatrix3(): Matrix3<Scalar> (affine, composed from Matrix2.rotation, Matrix2.scaling and the translation). composed must agree with toMatrix3 multiplication (tested on non-uniform scale with a rotation, which is the case a decomposed form cannot always represent: see the decision below).
  • R3. The package shall provide public type GlobalTransform2<Scalar: Real = Float> as its own component (q7) holding the world-space Matrix3<Scalar> (matrix), with Equals, Show, identity, transformedPoint(point) and transformedDirection(direction) delegating to the matrix.
  • R4. The package shall provide the propagation as a function generic over the stores it reads and writes, in the style of query: public fn propagate<Space, Place, Parents, Scalar>(...) taking the Store<Transform2<Scalar>> to read, the Store<GlobalTransform2<Scalar>> to write, and the Column<Parent> plus the entities order from depthOrder, so a program's own group supplies the stores. For each entity with a Transform2 in depthOrder, its GlobalTransform2 is the parent's global matrix times its own toMatrix3() (an entity without a Parent, or whose parent has no global, uses its own matrix). One pass is enough because every parent precedes its children. The exact signature is the implementer's, within these semantics; no World type is shipped (ECS.md q3).
  • R5. The package shall work over Float (the default) and Fixed: the generics are bounded by Real as std/linear is, and a test runs the propagation over Fixed and shows the same global as an exact hand computation.
  • R6. Every public item carries a doc comment with an # Examples block; the package is pure value arithmetic and does not import anything but std/core, std/number, std/linear, std/ecs.

Decisions (by the loop, refinement)

  • Split into 3a (2D) and 3b (3D). The 3D half repeats the same shape with Quaternion and Matrix4; the first PR proves the package, the generics and the propagation form once, the second is mechanical. Each lands as one PR with two Conventional Commits.
  • GlobalTransform2 holds a Matrix3, not a decomposed Transform2. A parent with non-uniform scale and a rotated child produces a shear that a translation/rotation/scale triple cannot represent, so a decomposed global would be wrong exactly where it matters (Unity and Bevy hold a matrix for the same reason). Transform2 stays decomposed because that is what an author sets and what interpolates. Transform2.composed is for the cases a triple can hold and is documented as exact for uniform parent scale; the global path always goes through matrices. Both are tested against each other.
  • Package, not a new group in std/ecs: ECS.md section 11 puts it between slices 1 and 2 as its own package that owns the arithmetic while std/ecs owns the order.
  • Component keys are not part of this step: a program's group writes its own ComponentKey("std/transform/Transform2") strings for the access sets, as step 1 and 2 do; componentKey<T>() is #90.

Acceptance criteria

  • std/transform exists with Transform2, GlobalTransform2 and propagate as above; torb check std/transform and torb format --check are clean; tools/check-commits.sh clean.
  • Arithmetic tests in std/transform/tests/: identity is neutral for composed on both sides; inverse composed with the transform gives identity within a tolerance, and None for a zero scale; interpolated at 0, 1 and 0.5 (including a rotation across the pi boundary takes the short way); toMatrix3 of a translation-only, rotation-only and scale-only transform equals the expected matrix.
  • Propagation tests on a program-declared group (as std/ecs/tests/groups.trb does): a three-level chain (root, child, grandchild) given in reverse order yields the expected globals in one pass; a root with non-uniform scale and a rotated child gives the matrix product, not a decomposed approximation; an entity without Parent has global equal to its own matrix; a parent cycle panics through depthOrder (existing message).
  • The same chain over Fixed equals the hand-computed values exactly.
  • A conformance program tests/conformance/transform-propagate.trb steps a small hierarchy through a Schedule (from std/ecs) for several frames with a system that rotates the root and a propagation system, printing the globals each frame; tier B runs it in the VM and natively and both must give transform-propagate.expected byte for byte; the .allocations budget is taken from the first forge CI run; a leak run is clean.
  • docs/standard-library/transform.md (new page, with the pattern of a propagation system registered in a schedule as the program's own code), skills/ regenerated (torb docs skill docs skills, --check is a tier A gate); ECS.md section 11/13 notes std/transform 2D landed with #89 and this issue; #89 step 3 line updated by the PR text (Refs #89).
  • Tier A and B green in forge CI.

Seed impact

None (see "Depends on").

Size and model

One PR, two Conventional Commits: feat(std/transform): Transform2, GlobalTransform2 and propagation (package, tests, conformance program) and docs(std/transform): standard library page, skills and the design record. Implementer: sonnet, medium (std/ package, no compiler change).

Part of #89 (Refs #89, not Closes).

## Context Step 3 of 3 of #89 (ECS slice 1), first sub-step. The specification is [ECS.md section 7](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/ECS.md) ("Hierarchy"), [section 11](https://git.torb.dev/torbscript/language/src/branch/main/docs/design/ECS.md) ("The package cut", `std/transform`) and the owner's decision in section 15 q7 (`GlobalTransform2` is its own component, not a field of `Transform2`). Steps 1 and 2 (#453, #763) are merged: `std/ecs` has `Column`, `Store`, `query`/`pairs`, `Parent` and `depthOrder`. `std/linear` exists on `main` (`Vector2`, `Angle`, `Matrix2`, `Matrix3`, generic over the scalar). The whole of step 3 (2D and 3D) is more than one PR, so it is split into two issues, filed just in time: this one is **3a, the 2D half**; **3b** (`Transform3`, `GlobalTransform3`, `toMatrix4`, 3D propagation) is filed when 3a has landed. ## Goal A new package `std/transform` with `Transform2` and `GlobalTransform2` as separate components over the scalar, their arithmetic, and the one propagation system over `depthOrder`, generic over the stores it reads and writes. ## Depends on #453 (merged), `std/linear` on `main`. Independent of #763 (merged). **No seed impact**: no syntax, no native, no driver flag, no std name the compiler looks up by string (a new package, nothing renamed). ## Requirements - R1. The package `std/transform` (`std/transform/project.trb` with runtime dependencies `std/linear` and `std/ecs`; `std/ecs` itself stays free of `std/linear`, ECS.md section 11) shall provide `public type Transform2<Scalar: Real = Float>` with `translation: Vector2<Scalar>`, `rotation: Angle<Scalar>`, `scale: Vector2<Scalar>`, `Equals, Show`, and `static identity` (zero translation, zero rotation, scale one). - R2. `Transform2` shall provide `composed(with: Self): Self` (the child transform written in this transform's space, so `parent.composed(with: child)` is the child's transform in the parent's parent space), `inverse(): Self?` (`None` when a scale component is zero), `interpolated(toward: Self, by: Scalar): Self` (translation and scale linear, rotation by the shortest `Angle` difference) and `toMatrix3(): Matrix3<Scalar>` (affine, composed from `Matrix2.rotation`, `Matrix2.scaling` and the translation). `composed` must agree with `toMatrix3` multiplication (tested on non-uniform scale with a rotation, which is the case a decomposed form cannot always represent: see the decision below). - R3. The package shall provide `public type GlobalTransform2<Scalar: Real = Float>` as its own component (q7) holding the world-space `Matrix3<Scalar>` (`matrix`), with `Equals, Show`, `identity`, `transformedPoint(point)` and `transformedDirection(direction)` delegating to the matrix. - R4. The package shall provide the propagation as a function generic over the stores it reads and writes, in the style of `query`: `public fn propagate<Space, Place, Parents, Scalar>(...)` taking the `Store<Transform2<Scalar>>` to read, the `Store<GlobalTransform2<Scalar>>` to write, and the `Column<Parent>` plus the entities order from `depthOrder`, so a program's own group supplies the stores. For each entity with a `Transform2` in `depthOrder`, its `GlobalTransform2` is the parent's global matrix times its own `toMatrix3()` (an entity without a `Parent`, or whose parent has no global, uses its own matrix). One pass is enough because every parent precedes its children. The exact signature is the implementer's, within these semantics; no `World` type is shipped (ECS.md q3). - R5. The package shall work over `Float` (the default) and `Fixed`: the generics are bounded by `Real` as `std/linear` is, and a test runs the propagation over `Fixed` and shows the same global as an exact hand computation. - R6. Every public item carries a doc comment with an `# Examples` block; the package is pure value arithmetic and does not import anything but `std/core`, `std/number`, `std/linear`, `std/ecs`. ## Decisions (by the loop, refinement) - **Split into 3a (2D) and 3b (3D).** The 3D half repeats the same shape with `Quaternion` and `Matrix4`; the first PR proves the package, the generics and the propagation form once, the second is mechanical. Each lands as one PR with two Conventional Commits. - **`GlobalTransform2` holds a `Matrix3`, not a decomposed `Transform2`.** A parent with non-uniform scale and a rotated child produces a shear that a translation/rotation/scale triple cannot represent, so a decomposed global would be wrong exactly where it matters (Unity and Bevy hold a matrix for the same reason). `Transform2` stays decomposed because that is what an author sets and what interpolates. `Transform2.composed` is for the cases a triple can hold and is documented as exact for uniform parent scale; the global path always goes through matrices. Both are tested against each other. - **Package, not a new group in `std/ecs`**: ECS.md section 11 puts it between slices 1 and 2 as its own package that owns the arithmetic while `std/ecs` owns the order. - **Component keys are not part of this step**: a program's group writes its own `ComponentKey("std/transform/Transform2")` strings for the access sets, as step 1 and 2 do; `componentKey<T>()` is #90. ## Acceptance criteria - [ ] `std/transform` exists with `Transform2`, `GlobalTransform2` and `propagate` as above; `torb check std/transform` and `torb format --check` are clean; `tools/check-commits.sh` clean. - [ ] Arithmetic tests in `std/transform/tests/`: identity is neutral for `composed` on both sides; `inverse` composed with the transform gives identity within a tolerance, and `None` for a zero scale; `interpolated` at 0, 1 and 0.5 (including a rotation across the pi boundary takes the short way); `toMatrix3` of a translation-only, rotation-only and scale-only transform equals the expected matrix. - [ ] Propagation tests on a program-declared group (as `std/ecs/tests/groups.trb` does): a three-level chain (root, child, grandchild) given in reverse order yields the expected globals in one pass; a root with non-uniform scale and a rotated child gives the matrix product, not a decomposed approximation; an entity without `Parent` has global equal to its own matrix; a parent cycle panics through `depthOrder` (existing message). - [ ] The same chain over `Fixed` equals the hand-computed values exactly. - [ ] A conformance program `tests/conformance/transform-propagate.trb` steps a small hierarchy through a `Schedule` (from `std/ecs`) for several frames with a system that rotates the root and a propagation system, printing the globals each frame; tier B runs it in the VM and natively and both must give `transform-propagate.expected` byte for byte; the `.allocations` budget is taken from the first forge CI run; a leak run is clean. - [ ] `docs/standard-library/transform.md` (new page, with the pattern of a propagation system registered in a schedule as the program's own code), `skills/` regenerated (`torb docs skill docs skills`, `--check` is a tier A gate); ECS.md section 11/13 notes `std/transform` 2D landed with #89 and this issue; #89 step 3 line updated by the PR text (`Refs #89`). - [ ] Tier A and B green in forge CI. ## Seed impact None (see "Depends on"). ## Size and model One PR, two Conventional Commits: `feat(std/transform): Transform2, GlobalTransform2 and propagation` (package, tests, conformance program) and `docs(std/transform): standard library page, skills and the design record`. Implementer: sonnet, medium (`std/` package, no compiler change). Part of #89 (`Refs #89`, not `Closes`).
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261114 until 2026-11-14T10:10Z

[loop:torbscript] claim implement by session:loop-torbscript-20261114 until 2026-11-14T10:10Z
Author
Collaborator

[loop:torbscript] implement: PR #780 opened (head e234aeab); std/transform 2D (Transform2, GlobalTransform2, propagate) with 26 tests, two conformance programs, docs and skills. Native leg and .allocations for transform-propagate come from the first CI run; review requested from argus.

[loop:torbscript] implement: PR #780 opened (head e234aeab); std/transform 2D (Transform2, GlobalTransform2, propagate) with 26 tests, two conformance programs, docs and skills. Native leg and .allocations for transform-propagate come from the first CI run; review requested from argus.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#779
No description provided.