src/scene/README.md 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 this module existed, "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.Duplicateleans on this: twenty copies of a million-triangle model cost twenty pointers, not twenty meshes. source_idremembers where the bytes came from. The scene never interprets it — it just carries the caller's handle (the WS upload UUID, a CLI path) so an object can be traced back to its file. Without it a caller holding N objects and M uploads has to pair them by position, which slices the wrong mesh as soon as the two lists disagree. Duplicates inherit it, so every instance of a model resolves to the same bytes.source_partsays which object inside that file. A 3MF is a scene, not a model: one upload can back several plate objects. Re-loading the file at slice time hands back every part, so the index picks the right one — otherwise a two-part file prints both parts twice. It survives duplication for the same reasonsource_iddoes.
Every slice entry point resolves objects and parts — all four of them
A 3MF's build-item transform is baked into each part's vertices, so a merged load returns the file exactly as its author assembled it: parts stacked, geometry floating well above the bed. Slicing that instead of the plate silently ignores every placement the user made, and it looks like the slicer "prints the file, not the scene".
load_bytes_multi / load_path_multi are the splitting loaders; load_bytes / load_path still merge and are what the slicer sees after a part is picked. All four runtimes load with load_*_multi, pick parts[source_part], and bake that object's own transform:
| Runtime | Entry point |
|---|---|
| CLI | cli/commands/slice.rs |
| WS server | server/ws_session.rs |
| Browser slicer | scene/wasm.rs |
| Desktop bridge | ui-desktop/src-tauri/src/bridge/runtime_bridge.rs |
Never reach for load_path / load_bytes in a slice path. The merging loaders exist for callers that genuinely want one mesh; a runtime that uses one loses both the part split and every transform but the first. An out-of-range source_part is an error, never a fallback to part 0 — guessing slices the wrong geometry without saying so.
Is this plate printable?
SceneState::placement_report() answers that for every object at once, so the CLI, the server and the viewer all warn on the same rules instead of each inventing their own:
out_of_bounds— the object's world AABB escapes the build volume.BedConfig::contains_aabbis shape-aware: a rectangular bed checks the extents, a circular one checks that the farthest footprint corner still falls inside the disk.collides— the object's XY footprint overlaps another's. Footprint, not volume: two parts stacked at different heights still collide, because the nozzle has to travel through the lower one. Touching edges are not an overlap, so a freshly packed plate never warns about its own spacing.
The tolerance is 1e-3 mm rather than something tighter. STL stores coordinates as f32, so a model dropped exactly onto the bed lands a few 1e-6 mm below zero — a stricter epsilon would flag half the models on a plate as hanging off it.
The op catalog
| Op | Does | Inverse stored in receipt |
|---|---|---|
Add | Loads bytes; one object per part (3MF), identity pose | RemoveMany of everything added |
Remove | Drops the object | SetTransform stub (see note) |
RemoveMany | Drops several objects at once | BatchSetTransform stub |
Duplicate | Clones an object (shared mesh + source ref), offset | Remove of the copy |
Translate | Adds delta to translation | SetTransform to previous |
SetTransform | Replaces the entire transform | SetTransform to previous |
Rotate | Composes a rotation, pivoting on the object's own centre | SetTransform to previous |
Scale | Multiplies per-axis scale, about the object's own centre | 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 |
PlaceFaceOnFloor | Turns picked face to -Z in place, then drops to floor | SetTransform to previous |
AutoOrient | Rotates to minimise overhangs, then drops | SetTransform to previous |
ArrangeOnBed | Nests the listed objects by shape and height, then centers | BatchSetTransform |
BatchSetTransform | Restores many transforms atomically | BatchSetTransform to previous |
Placement reads the real outline. world_aabb() transforms every vertex, never the eight corners of the local box: under an oblique rotation those corners sit well outside the part, so drop, centre, arrange and the bounds check all worked against a box that was not there — an auto-oriented part floated above the bed. Rotations pivot on the part, not its origin.SceneObject::pivot() is the centre of the mesh's own box; a CAD export's origin can sit far outside the geometry, and turning about it swings the part across the plate while the gizmo sits still.
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. - No second placement path. The temptation to "just translate this mesh real quick" in
mesh::transformsis exactly what this module was built to eliminate. The CLI's--center/--drop-to-floorsurvive only as aliases that log a deprecation warning and dispatch the equivalentSceneOp; do not add new flags that bypass the engine.
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 — the repo map
- ../../ui/src/app/components/viewer/README.md — gizmo system and object-manipulation modes