Early docs These pages are very early. Structure and tone are still being worked out, so anything here may change drastically.
Skip to content
Rendered from 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:

  1. SceneState is the only state. No parallel Map<id, matrix> in TypeScript, no shadow transforms in mesh::transforms.
  2. SceneOp is the only mutation. Every CLI flag and every UI gesture becomes a SceneOp before anything moves.
  3. Bake once, at the slicer boundary. apply_transform(&Mesh, &Transform) runs exactly once per object, immediately before process_mesh. Never mid-pipeline, never on the way into the renderer.

Anatomy of a scene ​

A few things worth knowing:

  • ObjectId is monotonic and never reused. Once obj#7 is removed, nothing else will ever be obj#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.
  • Transform uses Quat internally. 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 a SceneObject is cheap; transforming it doesn't copy a single vertex. Duplicate leans on this: twenty copies of a million-triangle model cost twenty pointers, not twenty meshes.
  • source_id remembers 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_part says 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 reason source_id does.

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:

RuntimeEntry point
CLIcli/commands/slice.rs
WS serverserver/ws_session.rs
Browser slicerscene/wasm.rs
Desktop bridgeui-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_aabb is 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 ​

OpDoesInverse stored in receipt
AddLoads bytes; one object per part (3MF), identity poseRemoveMany of everything added
RemoveDrops the objectSetTransform stub (see note)
RemoveManyDrops several objects at onceBatchSetTransform stub
DuplicateClones an object (shared mesh + source ref), offsetRemove of the copy
TranslateAdds delta to translationSetTransform to previous
SetTransformReplaces the entire transformSetTransform to previous
RotateComposes a rotation, pivoting on the object's own centreSetTransform to previous
ScaleMultiplies per-axis scale, about the object's own centreSetTransform to previous
CenterOnBedXY-centers the world AABB on the bed; preserves ZSetTransform to previous
DropToFloorTranslates so world AABB min.z = 0SetTransform to previous
PlaceFaceOnFloorTurns picked face to -Z in place, then drops to floorSetTransform to previous
AutoOrientRotates to minimise overhangs, then dropsSetTransform to previous
ArrangeOnBedNests the listed objects by shape and height, then centersBatchSetTransform
BatchSetTransformRestores many transforms atomicallyBatchSetTransform 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-Add the 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:

MethodSignatureReturns
getRenderBuffer(id: u64) → RenderBufferFlat position, normal and index arrays for uploading to a BufferGeometry
getFaceGroups(id: u64, angle_threshold_deg: f32) → Uint32ArrayOne 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::inverse is 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:: and ws_protocol. The scene only knows SceneOp.
  • No second placement path. The temptation to "just translate this mesh real quick" in mesh::transforms is exactly what this module was built to eliminate. The CLI's --center / --drop-to-floor survive only as aliases that log a deprecation warning and dispatch the equivalent SceneOp; do not add new flags that bypass the engine.

See also ​