Source:
src/scene/README.md— this page is rendered directly from the file in the repository. Edit it there.
Scene Engine — The Single Source of Truth for Object Placement
The scene engine answers one question, and one question only:
Where is each mesh, rotated how, scaled how — right now?
The same answer is owned, byte-for-byte, by the CLI, the WS server, and the Angular UI (via WASM). There is no second copy.
Why it exists
Before issue #51, "just translate this mesh real quick" lived in three places: the CLI flag handler baked transforms into mesh vertices, the UI tracked its own Three.js matrices, and the WS server… kind of guessed. Three placement paths meant three sets of bugs and three subtly different answers to "what will actually get sliced?".
The scene engine collapses all of that into one Rust module compiled twice — once natively (CLI / WS server) and once to WebAssembly (UI). One mental model, one undo log, one source of truth.
The SSOT contract
Three rules, no exceptions:
SceneStateis the only state. No parallelMap<id, matrix>in TypeScript, no shadow transforms inmesh::transforms.SceneOpis the only mutation. Every CLI flag and every UI gesture becomes aSceneOpbefore anything moves.- Bake once, at the slicer boundary.
apply_transform(&Mesh, &Transform)runs exactly once per object, immediately beforeprocess_mesh. Never mid-pipeline, never on the way into the renderer.
Anatomy of a scene
A few things worth knowing:
ObjectIdis monotonic and never reused. Onceobj#7is removed, nothing else will ever beobj#7. This is what makes IDs safe to send over the wire — a stale reference is always a clear "not found", never an accidental hit on a new object.TransformusesQuatinternally. Euler-XYZ degrees only appear at protocol and CLI boundaries (Transform::from_euler_xyz_deg/to_euler_xyz_deg). Quaternions everywhere else means no gimbal lock and no surprises when ops compose.- Meshes are
Arc<Mesh>. Cloning aSceneObjectis cheap; transforming it doesn't copy a single vertex.
The op catalog
| Op | Does | Inverse stored in receipt |
|---|---|---|
Add | Loads bytes, assigns a new ObjectId, identity transform | Remove |
Remove | Drops the object | SetTransform stub (see note) |
Translate | Adds delta to translation | SetTransform to previous |
SetTransform | Replaces the entire transform | SetTransform to previous |
Rotate | Composes Quat::from_axis_angle onto current rotation | SetTransform to previous |
Scale | Multiplies per-axis scale factors | SetTransform to previous |
CenterOnBed | XY-centers the world AABB on the bed; preserves Z | SetTransform to previous |
DropToFloor | Translates so world AABB min.z = 0 | SetTransform to previous |
AlignFaceToFloor | Rotates picked face's normal to -Z, then drops | SetTransform to previous |
Note on
Remove: the inverse can't fully restore the mesh bytes from the current state alone. The receipt records the last transform so a future history layer can re-Addthe bytes and replay the transform.
Role in the UI layer
Angular owns interaction. The WASM SceneHandle owns truth.
Three.js never invents transforms. It reads getMatrix(id) after every op and copies the result onto its own Object3D. The renderer is a view of the scene; it isn't allowed to mutate it.
WASM query methods
Beyond applyOp / getMatrix, SceneHandle exposes two read-only query methods that the UI uses for display purposes — they do not mutate scene state:
| Method | Signature | Returns |
|---|---|---|
getRenderBuffer | (id: u64) → RenderBuffer | Flat position, normal and index arrays for uploading to a BufferGeometry |
getFaceGroups | (id: u64, angle_threshold_deg: f32) → Uint32Array | One group id per triangle; used by the pull-to-floor face-highlight overlay |
getFaceGroups delegates to mesh::analysis::compute_coplanar_groups. The returned array has one entry per triangle; triangles with the same id are coplanar and edge-adjacent. The viewer uses this to highlight an entire flat face (potentially many triangles) as the cursor hovers over it in pullToFloor mode before the user clicks.
Why both ends must agree on the scene
This is the part that justifies the whole module. When slicing happens remotely, the only thing the UI sends to the server is a list of ops:
Notice what crosses the network:
- Once: raw mesh bytes.
- Per edit: a tiny JSON op.
- Per slice: a list of
ObjectIds.
If the UI's idea of Rotate and the server's idea of Rotate ever drifted — different axis convention, different composition order, anything — the preview and the sliced result would silently disagree. That's why the TypeScript side never reimplements scene math. Both ends import the exact same Rust functions; one as a static lib, one as .wasm.
Lifecycle of a remote slice (end to end)
The local preview path and the remote slice path differ only in whereprocess_mesh runs. The scene state going into it is, by construction, identical.
What this module deliberately does not do
- No persistence. Server scenes are ephemeral per WS connection. Anything long-lived is the UI's problem (or a future history layer).
- No undo stack.
OpReceipt::inverseis the building block; the stack itself isn't here yet. - No mesh editing. The scene engine moves meshes around. Editing geometry is
mesh::*'s job. - No protocol concerns. Wire framing, auth, upload tokens — all of that lives in
server::andws_protocol. The scene only knowsSceneOp.
See also
- ops.rs —
SceneOp,OpReceipt,SceneError, theapplymatch arm - state.rs —
SceneState,SceneObject,ObjectId - transform.rs —
Transform,apply_transform, Euler helpers - wasm.rs —
SceneHandleexposed to the Angular UI (applyOp,getMatrix,getFaceGroups) - ../../AGENTS.md — "Scene Engine — SSOT Contract" section
- ../../ui/src/app/components/viewer/README.md — gizmo system and object-manipulation modes
- issue #51 — original motivation and design discussion