ENGINE + DECAY

Physics architecture

Status: accepted implementation direction for the physics feature track.

Sindri exposes Sindri physics. Rapier is a backend implementation detail and is never part of the scene format, editor contract, Decay language surface, or game API.

Goals

Crate boundary

Create one sindri-physics crate at a real dependency boundary:

sindri-core                 sindri-grid
     ^                           ^
     |                           |
sindri-physics -----------------+
     |
     +-- rapier2d
     +-- rapier3d

sindri-scene -> sindri-core + sindri-grid + sindri-render + sindri-physics
sindri-decay -> sindri-core + sindri-grid + sindri-platform + sindri-physics
editor       -> ... + sindri-physics
Gather       -> ... + sindri-physics

sindri-physics owns the public physics model and the Rapier adapters. A second *-rapier crate is not justified while there is one backend. Rapier types may appear inside private implementation modules only. If another backend ever becomes real, the adapter can be split without changing the public contract.

The crate depends on sindri-core because bodies are synchronized to runtime entities, and on sindri-grid only if a concrete physics/navigation integration requires it. Do not add the grid dependency speculatively. It has no renderer, window, browser, editor, or Decay-language dependency.

Both rapier2d and rapier3d must satisfy the repository MSRV, licence policy, cargo deny, native builds, and wasm32-unknown-unknown before either is merged.

The adapters use Rapier 0.36. The 2D overlap adapter reads the backend intersection flag while keeping sub-shape IDs private. The event collector also owns a soft-body tear channel; Sindri currently authors only rigid bodies and continues to publish collision and sensor events only.

Public model

The concepts are intentionally parallel rather than generic over dimensionality. Dimension-neutral configuration can be shared internally, but public APIs use ordinary explicit types.

Bodies

RigidBodyKind is shared:

The scene components are distinct:

They carry Sindri-owned configuration such as body kind, gravity scale, damping, and whether rotation is locked. They never serialize Rapier handles, activation state, solver state, or backend-specific flags.

Continuous collision

RigidBody2d.continuous_collision defaults to false, including old payloads that omit it. It opts a dynamic body into swept solid collision. The live continuous_collision getter and set_continuous_collision setter preserve velocity, attached joints and body identity. The setter rejects non-dynamic or missing bodies before mutation.

The backend automatically sweeps dynamic bodies against fixed geometry; the opt-in regression therefore uses a thin velocity-kinematic wall. Sensors remain discrete, and bullet-versus-bullet collision is not guaranteed. The generic command-backed inspector authors the flag on the body. CCD-only payload edits toggle the live backend without rebuilding its body, contacts or joints; ordinary collider/kind edits retain their existing rebuild lifecycle. Physics.continuous_collision(entity) and Physics.set_continuous_collision(entity, enabled) expose the same control in Decay. The setter requires an authored dynamic body, updates both the live backend and runtime payload, and works before a spawned body is synchronized. The getter uses live state when present and authored state during that window. Missing authored bodies and hosts without physics report errors. The platformer hero opts in, with its existing run-to-flag regression exercising the setting.

One-way platforms

OneWay2d is a validated support-side policy: local normal defaults to [0, 1], angle defaults to pi/4 radians and is bounded to 0..=pi/2. Normals must have finite, nonzero length. sindri.physics2d.one_way applies it to every solid piece on the entity, including generated tilemap boxes. The normal follows each piece's rotation and the body's rotation. Sensors retain ordinary overlap and enter/exit behavior. Omitting the component means ordinary solid collision.

The backend filters contact pairs, including CCD sweeps, to reject relative ascent above 0.1 units/s and bodies not yet above the support plane. Supporting contacts must face the configured cone. Projection uses each convex piece's actual geometry; a 0.05 world-unit penetration allowance stabilizes resting contacts. A body starting deeply inside must clear the supporting face before it can land. Fast descent still requires CCD to avoid discrete tunneling. Policies are live controls: editing or undoing only the policy preserves velocity and joints.

PhysicsWorld2d::drop_through(entity, seconds) and typed Physics.drop_through(entity, seconds) ignore only one-way solid contacts for a dynamic body. Durations are finite and nonnegative: zero cancels, a new request replaces the remaining time. Timers advance after each fixed step, so any positive remainder covers the whole next step. Pause does not expire them. Removal and structural body/collider rebuilds clear policy/timer state. The host validates the authored body; requests before synchronization start when the spawned body materializes. Abandoned requests are discarded at the end of synchronization.

Geometric rays/overlaps/casts still see the pieces from either side and during drop-through. They do not themselves grant grounding. The platformer uses a downward support normal, clearance and descending velocity in Decay, suppresses jump permission while dropping, and keeps sensors for coins/the flag. Its visible plank tilemap exercises ascent, landing, Down/S/d-pad drop to an ordinary floor, and another landing after expiration. Engine regressions also exercise rotated support normals, kinematic platforms, fast CCD ascent and cancellation.

Forces and rotation

PhysicsWorld2d adds world-space force and torque, angular velocity in radians per second, angular impulses and impulses at world points. Positive torque and angular velocity turn counterclockwise. Forces and torques accumulate for the next valid fixed step and are cleared immediately after it, even when a host runs several fixed steps per frame. Invalid timesteps do not consume them. Pause keeps them pending. Impulses change velocity immediately, without a dt factor; off-centre impulses also turn the body according to its mass/inertia.

Forces and impulses require dynamic bodies. Velocity setters also support velocity-kinematic bodies. Every request validates finite values and body kind before mutation; rotation locks prevent angular motion while retaining linear impulses. Collider mass properties are available immediately after insertion, so impulses work before the first step. Existing velocity/impulse APIs remain.

Typed Decay adds Physics.apply_force(entity, Vec2), apply_torque(entity, number), angular_velocity(entity), set_angular_velocity(entity, number), apply_angular_impulse(entity, number) and apply_impulse_at_point(entity, Vec2, Vec2). The point is in world space. An authored body can receive these requests between spawning and synchronization: requests replay in call order when the body materializes, after its collider mass is known. The angular getter in that window returns the latest queued setter or the authored initial value; queued impulses affect it at materialization. Removal, abandoned synchronization and structural rebuilds discard pending work. A host with its own physics driver can control live bodies without scene body components. An authored body is required only to validate requests before materialization. Missing bodies and hosts without physics fail explicitly.

The platformer's wooden crate is pushed by wind and tossed by K or the west controller button. Decay reapplies force each update, applies torque while it is airborne, bounds its angular velocity and gives kicks an off-centre and angular impulse. The native input regression checks its launch and scene rotation writeback. Browser interaction and visual inspector review remain in final integration. Native, WASM and browser CI passed on the forces/rotation head.

Contact snapshots

PhysicsWorld2d::contacts(entity) returns copied Contact2d values from the last valid fixed step. Each value names the other entity and a world-space surface-anchor midpoint, with a unit normal pointing towards the queried body (the direction it is pushed). normal_impulse is nonnegative; signed tangent_impulse acts along [-normal.y, normal.x]. force is their total world impulse divided by that step's seconds. The adapter reads the manifolds actually solved rather than cached geometric-point impulses. Rapier currently disables clustering in 2D; the adapter uses its solver-manifold accessor, which also selects clusters if the backend enables them in the future. Predictive contacts within the solver's contact reach may be present.

Contacts are ordered by other entity, point, normal, normal impulse and tangent impulse using total float ordering. Multiple points/pieces can name the same entity; they are not deduplicated. Sensors are absent. Sleeping support remains present with zero new impulses/force, so resting geometry can grant grounding without replaying an old impact. Snapshots are empty before the first step; missing bodies fail. Invalid steps retain the previous snapshot. Teleports, removal and structural rebuilds invalidate affected contacts immediately. Setting a kinematic target takes effect at the next step. Pausing retains the last snapshot; copies held by a caller survive later changes.

Typed Physics.contacts(entity) -> List<Contact2d> exposes the same values in Decay and omits inactive/despawned other entities immediately. Inactive queried entities and authored colliders awaiting spawn synchronization return an empty list. Active entities with no collider/body and hosts without physics fail. Hosts driving their own physics need no authored component for live bodies. Script updates observe the preceding fixed solve, as they do collision events. Scene transforms synchronize at the next step, so a script teleport is reflected in snapshots after synchronization, not midway through a script pass.

This capability is added for the platformer genre showcase: the wind crate flashes amber on a hard landing based on normal impulse. The hero now uses controller support for jump permission and a geometric ray for HUD clearance. Gameplay policy and thresholds remain in Decay. Browser interaction and editor Play inspection remain in final integration.

Colliders

The scene components are distinct:

The first 2D shapes are box, circle, and capsule. The first 3D shapes are box, sphere, and capsule. Shape enums are Sindri types. Dimensions are expressed in scene/world units and validated as finite and positive.

A collider carries:

Collision masks are Sindri bit masks from the first implementation. They are not Rapier Group values in public APIs or serialized JSON.

A collider may exist without a rigid-body component; it is treated as a static collider owned by the entity.

A 2D collider may be authored in several pieces, because one shape is often a poor description of a thing: a character is a capsule with a circle at each side, a ship a box and two pods. The pieces belong to the one entity and move as one object — a compound is one collider made of parts, not several colliders — and they need no child entities to say so, because a piece already carries its own offset and rotation.

"sindri.physics2d.collider": {
  "pieces": [
    { "shape": { "shape": "capsule", "half_height": 0.4, "radius": 0.22 },
      "offset": [0.0, 0.05], "rotation": 0.0, /* … */ },
    { "shape": { "shape": "circle", "radius": 0.18 },
      "offset": [-0.38, -0.1], "rotation": 0.0, /* … */ }
  ]
}

The single form — a collider written directly, with no pieces — still means exactly what it always did, and is kept rather than migrated: a scene is a file someone wrote, and a format that can only be read after a rewrite is one that breaks their project on upgrade.

Two consequences worth stating, because both are derived rather than authored. Mass properties come from every piece and sum, so a body's centre of mass is decided by the compound and not by its first piece; PhysicsWorld2d::mass exposes the total so that claim can be checked. And validation is per piece and names the index — a compound is a list, so "restitution must be between 0 and 1" without one is a needle in it. Nothing is inserted unless every piece passes, because a half-built body is worse than none.

The standalone 3D engine world accepts a slice of collider pieces, validates all of them before insertion and sums their mass. The 3D scene driver now consumes authored compounds; shared game sessions and native editor Play step both drivers. This general piece boundary is a prerequisite for voxel collision geometry; it does not claim voxel/game proof.

An entity may not participate in both the 2D and 3D physics worlds at once. Validation reports that as an authored configuration error instead of choosing a world silently.

Tilemap collision

sindri.physics2d.tilemap_collider makes the tilemap on the same entity solid, so a painted level collides where it was painted and a wall is repainted rather than re-placed:

"sindri.physics2d.tilemap_collider": {
  "passable": ["grass-tuft", "vine"],   // palette sprites that do not collide
  "layers": { "memberships": 1, "filter": 4294967295 },
  "friction": 0.5,
  "restitution": 0.0
}

Every painted tile is solid unless its sprite is passable. The solid cells are merged, greedily in reading order, into rectangles that cover each once: a floor becomes one box, not one per tile, because a character sliding along separate boxes catches on the seams between them. The rectangles are ordinary collider pieces, scaled with the entity, and join any pieces an authored collider on the same entity has, so a tilemap on a kinematic body is a moving platform. Only an orthogonal map can be solid; an isometric map is a floor seen at an angle and is refused by name.

Gravity

sindri.physics2d.world is the scene's say over the world as a whole:

"sindri.physics2d.world": { "gravity": [0.0, -9.81] }

A scene carries it rather than the host deciding, because a platformer falls and a game seen from above does not, and the editor's Play must run each the way its build will. One per scene, like the Environment. A scene without one keeps the gravity its host constructed ScenePhysics2d with.

The settings that count are the first active ones, for gravity and for the layer names alike. A script reads and turns gravity with Physics.gravity() and Physics.set_gravity(v), which change this component rather than the solver behind it. A change of gravity wakes every dynamic body in both dimensions, because a body asleep at rest would otherwise ignore it while everything in motion fell the new way.

Transform ownership

Transform3D remains the authored and visible transform for both dimensions. Physics does not introduce a second scene transform.

For 2D, physics reads/writes X, Y, and rotation about Z. Existing Z and 3D scale are preserved. For 3D it reads/writes XYZ and quaternion-equivalent scene rotation through the existing transform representation.

Synchronization rules are explicit:

Scale is not simulated. Collider dimensions are authored explicitly. Changing an entity's visual scale must not secretly mutate collision geometry. The one exception is said out loud: a tilemap collider's rectangles are derived from where its tiles are drawn, so they follow the map's scale.

Friction combines as the smaller of the two colliders' values, so a collider authored frictionless is frictionless against everything: a platformer's hero pressed into a wall slides down it rather than clinging with half the wall's friction. Two equal frictions combine to that value, as an average would.

Parented 2D bodies are supported. Synchronization reads the composed world pose and converts the simulated answer back into the parent's local space. Moving a parent carries its bodies through the authored-pose change rule. This is exercised by scene hierarchy regressions and Low Tide's moving deck; it does not create a physical joint between parent and child.

Reusable collision materials

Physics coefficients can be reused as project .profile assets with type physics_material. They use the existing asynchronous profile pipeline, not a new asset kind or backend resource. For example:

{
  "format_version": 1,
  "name": "Wood",
  "type": "physics_material",
  "values": { "friction": 0.3, "restitution": 0.1 }
}

Both coefficients are required numbers. Friction must be finite and non-negative; restitution must be finite in [0, 1]. Unknown coefficient keys are rejected. Other profile types remain unrestricted game data. physics_material_profile at the scene boundary uses the engine-owned PhysicsMaterial::validate rules; asset decoders and core profile documents stay independent of physics.

An entity's sindri.physics2d.material names profile and applies it to every ordinary and generated tilemap collider piece, sensors included. Its 3D counterpart, sindri.physics3d.material, uses exactly the same fields and asset format for primitive compounds, characters, static model triangles, convex model hulls and resident voxel sections. Without a material component, existing per-piece literals are unchanged. An empty profile keeps those literals. A non-empty reference must resolve to a loaded physics profile; a missing or differently typed profile fails the fixed step. The profile replaces both piece coefficients, then explicit override_friction and override_restitution flags select the component's local friction and restitution values. Disabled override values are ignored. A missing reference is still an error even when both overrides are enabled. Friction combines by minimum; restitution retains the existing average rule.

Hosts build PhysicsMaterialSources from their loaded profile documents before stepping. Reloaded coefficients update registered colliders in place, preserving velocity, forces, mass and joints, and waking affected contacts. Changes to shapes or body kinds still use the normal rebuild path. Invalid source sets cannot partly replace a valid source set; backend piece updates validate all values before changing any collider.

The editor offers “New physics material here”, a structured profile editor, profile selection and explicit override controls through checked component commands and undo/redo. Its asynchronous loader watches valid and invalid files; a failed edit reports the asset and retains the previous valid profile. Invalid coefficient edits cannot overwrite the file through the profile editor. Play waits for initial delivery before physics runs. Export walks scene and prefab material references, including inactive entities, and validates physics profiles before producing output; native and browser hosts use the same scene resolver.

Added for the platformer: its wind crate and one-way planks share materials/wood.profile, with an explicit zero-bounce override on the planks. Native and browser delivery expand placed prefabs during scene entry from the original library, retaining root aliases for authored references. Native regressions verify sharing, precedence and a changed crate rebound when the shared restitution changes. Editor loader/reload tests exercise delivery and last-valid retention. Visual inspector review and full browser gameplay remain part of final physics integration.

Scene-authored distance constraints

sindri.physics2d.distance_joint lives on a separate joint entity:

{ "first": "lantern-anchor", "second": "wind-lantern", "max_distance": 2.0 }

The endpoint strings are stable scene entity IDs, not runtime handles. Resolution uses an exact qualified scene ID (a string containing /) before relative namespace lookup when that ID exists inside the loaded scene (its namespace is applied first). Otherwise it tries the owner's namespace before the containing scene. It never binds across loaded scene roots. An inactive local match does not fall through to an external entity with the same name. Placed-prefab sibling IDs resolve within their instance namespace. Original top-level/nested root aliases survive library-based scene loading, namespacing, editor reload, duplication and undo. Canonical paths precede aliases; inactive matches suspend without rebinding. Runtime-spawned prefabs resolve through World::prefab_entity using runtime-only instance identity. Local sibling paths and original top-level and nested root IDs resolve within that spawn; missing or inactive targets never bind another instance or a scene entity. Reparenting does not change ownership. All four authored joint kinds share this path. After stable IDs are assigned, World::to_scene_with_references remaps their registered endpoint fields for save/reopen, preserving settings and unknown fields without editing the live world. All four kinds exercise reopened endpoint isolation, inactivity and removal; platformer proves motor reversal and a fixed axle after reopening its Decay spawn. Flattening into a plain expanded document discards aliases; see prefabs.md.

Constraints synchronize after all bodies, so endpoint collider/body rebuilds reconnect the owned joint in the same fixed step. Missing, empty, inactive or collider-less endpoints suspend it without failing a step; they reconnect when available. This also means a misspelled but valid ID waits rather than producing a runtime diagnostic. Removing/disabling the joint entity or its component drops its constraint. Removing an endpoint clears backend ownership; reappearance can reconnect the authored constraint. Several joint entities may connect one body.

PhysicsWorld2d::set_distance_joint(owner, DistanceJoint2d) validates before replacing an owned constraint; unchanged requests retain the solver joint. Changing endpoints/distance replaces only that constraint, preserving body motion. remove_owned_joint(owner) releases it independently of legacy connect_distance connections. Both distance paths use the existing positive, finite maximum centre distance, free rotation/closer motion and disabled mutual endpoint contacts. Runtime owner IDs are never serialized.

The checked component inspector authors endpoint IDs with a typeable entity picker, including names and an explicit None choice. Missing references and inactive targets are marked; unresolved text remains editable. Choices obey the same scene/prefab scope as runtime resolution. Real picker-click regressions retarget/clear all four joint kinds through commands, then undo restores each constraint and its unknown fields. Visual editor review remains in final integration. This foundation is added for the platformer: a physical lantern hangs from an anchor while Decay wind drives it and Decay stretches/turns its visible cord. The game regression observes movement, bounded distance and tether removal. Additional typed owned-joint controls and complete prefab references remain incomplete; the existing typed Physics.connect_distance keeps its previous semantics.

Reversible authored joint suspension and distance tuning

All four joint components accept enabled, defaulting to true when omitted. False releases the solver constraint at the next fixed synchronization while retaining its owner, endpoint references and settings. Re-enabling reconnects available active endpoints; missing endpoints continue to suspend the constraint. Bodies are not rebuilt, and their motion is not reset. Reconnection and restoring mutual endpoint collision can change motion through the ordinary physics solver. Multiple joint kinds on one active owner remain an error even when suspended. All active owners' settings are validated before ownership changes, including suspended constraints and distance settings whose endpoints are unavailable.

The generic checked inspector exposes the flag. Command suspension, undo/redo, endpoint rebuilds and all four constraint kinds are exercised. Physics.joint_enabled(owner) reads the authored flag, including before the next synchronization; it does not report whether a live constraint currently exists. Physics.set_joint_enabled(owner, enabled) patches that flag. Physics.set_distance(owner, max_distance) tunes an owned maximum-distance constraint, including while suspended or before endpoints materialize. These typed calls require physics and exactly one valid authored joint; distance tuning requires the distance kind and a finite positive maximum. Invalid, missing, conflicting or wrong-kind calls leave payloads unchanged. Unknown fields are preserved. Legacy Physics.connect_distance is unchanged.

Platformer adds these capabilities to let the player reel the hanging lantern in/out with T and release/reconnect it with L. Decay hides the cord while suspended or after its owner is removed. Runtime regressions observe length, free fall, reconnection and return to the original length. Typed Physics.set_joint_endpoints(owner, first, second) retargets every joint kind through scoped authored references at the next fixed synchronization. Null clears either endpoint. Both handles validate before mutation; stale, unstable, out-of-scope or identical endpoints fail. Inactive references remain authored, with the constraint suspended until active. Settings, enabled flags, unknown fields and body motion are preserved. No runtime handles are serialized. Platformer switches its lantern between two hooks with R, including while released. Physics.remove_joint(owner) removes the authored joint component and releases its solver constraint at the next fixed synchronization. The owner, other components and endpoint bodies/motion remain; legacy connect_distance constraints are unaffected. Exactly one valid authored joint and a physics host are required, including while suspended or before initial body synchronization. Subsequent joint controls fail until a joint is authored again. Platformer Z cuts its lantern cord; Decay hides the cord and ignores tether controls until repaired.

Physics.create_distance_joint(owner, first, second, max_distance) authors an enabled maximum-distance component on an existing owner carrying no authored 2D joint of any kind. Validation and scoped endpoint rules match retargeting; null leaves an endpoint unbound, and inactive targets suspend until active. The maximum length must be finite and positive. Physics is required, but endpoint bodies need not be synchronized yet. Next fixed synchronization creates the constraint, preserving other owner components, bodies/motion and legacy connections. Existing joints, invalid handles/references and invalid lengths fail before mutation. Platformer C repairs the cut cord using its selected hook and length. Script-triggered world snapshots remain absent.

Editor scene Save/Save As and subtree prefab authoring now use the active component registry for stable-reference remapping automatically. All entities must already have stable IDs. Invalid local targets/types or unstable endpoints fail before writing/adopting a path, without changing the live world. Tests reopen two isolated instances of all four joint kinds and reuse saved subtrees. The platformer's real Decay-spawned nested windmill reopens through editor Save As, retaining its fixed axle and motor reversal. Save is refused during Play; Decay's number/flag progress store does not save world snapshots. Script-triggered world save/load remains a separate absent capability.

Scene-authored hinges and motors

sindri.physics2d.hinge_joint uses the same separate owner entity, stable endpoint reference resolution and suspend/remove/rebuild lifecycle as distance joints. One entity owns one kind of constraint; active distance and hinge components on one owner fail synchronization rather than silently replacing each other.

{
  "first": "windmill-anchor", "second": "windmill-rotor",
  "first_anchor": [0.0, 0.0], "second_anchor": [0.0, 0.0],
  "limits_enabled": false, "lower_angle": 0.0, "upper_angle": 0.0,
  "motor_enabled": true, "motor_velocity": 2.0, "motor_max_torque": 1.0
}

Each anchor is measured in world units in its body's translated/rotated local frame, without transform scaling. Anchors coincide while the bodies may rotate relative to one another. The default anchors are the centres; default limits and motor are disabled. Connected bodies do not collide with each other. Enabled limits bound the second body's angle relative to the first, in radians; finite bounds must satisfy -pi <= lower_angle <= upper_angle <= pi. They describe relative orientation, not accumulated revolutions.

A velocity motor targets relative angular speed in radians/second; positive turns the second body counterclockwise relative to the first. Its force-based velocity drive has a finite non-negative torque cap, so target speed is not a promise under load. A disabled motor coasts; an enabled zero-speed motor brakes within its torque cap. All numeric fields, including disabled settings, must be finite. Negative torque and invalid enabled limits fail before backend mutation. Settings edits between the same endpoints update the existing solver constraint and wake both bodies without resetting their velocity. Unchanged synchronization retains it; ordinary low-motion sleeping behavior remains applicable.

motor_mode defaults to "velocity", including old payloads that omit it. "position" selects a force-based damped angular target: motor_target_angle is the second body's relative orientation in radians within [-pi, pi], motor_stiffness is torque per radian of error and motor_damping is torque per radian/second. Gains default to zero and must be finite and non-negative, even when disabled. The existing torque cap and enabled angular limits apply; a target beyond enabled limits cannot override them. Targets represent principal relative angles, not accumulated revolutions. Edits retain body motion and solver ownership; suspension and endpoint rebuilds retain the authored mode.

Physics.set_hinge_position_motor(joint, target_angle, stiffness, damping, max_torque) selects position mode, validates the complete settings before mutation and preserves unknown fields. Zero torque disables drive. The next synchronization applies it, including calls before endpoint bodies exist. set_hinge_motor explicitly selects velocity mode again. Platformer P switches both placed and spawned windmills between holding 0.6 radians and their reversing drive; H recreates the placed hinge and asks its script to restore its current mode. Checked command edits, undo/redo and scene serialization exercise the new fields; native backend tests prove holding, retargeting, caps, limits and atomic rejection. Editor save-path integration and native visual review are exercised.

Typed Physics.set_hinge_motor(joint, velocity, max_torque) controls the hinge owner, not a body. It requires a hinge component and a physics host, validates before changing its runtime component, preserves unknown fields and applies at the next fixed synchronization. Its values persist through endpoint rebuilds and the valid spawn-to-synchronization window. Zero torque disables the motor rather than braking; to brake, set zero velocity with positive torque. Typed Physics.remove_joint releases the authored hinge at next synchronization.

Physics.create_hinge_joint(owner, first, second, first_anchor, second_anchor) authors an enabled hinge on an existing owner with no authored 2D joint of any kind. The scoped endpoint/physics contract matches distance creation, including null/unbound or inactive targets and pre-body synchronization. Finite Vec2 anchors use body-local world units without transform scale; angular limits and motor start disabled. All validation precedes mutation. The next fixed step creates the owned constraint while retaining other owner components and bodies. set_hinge_motor can configure drive immediately after creation. Platformer H rebuilds its placed windmill hinge and restores its selected drive while the spawned windmill remains independently owned.

The generic checked inspector authors hinge settings and command undo reverses motor edits. Native visual review is exercised; script-triggered world snapshots remain absent. This general capability is added for the platformer: Decay reverses its powered windmill axle every two seconds while physics keeps the rotor on its anchor. Native tests exercise offset anchors, limits, torque caps, coast/reverse, invalid atomic edits, command undo, endpoint rebuilds and gameplay removal. Script-triggered world snapshots remain a separate absent capability.

Scene-authored sliders and springs

sindri.physics2d.slider_joint constrains perpendicular translation and aligns two body-local axes; it permits signed translation along the first axis. Anchors use the same unscaled local world units as hinges. first_axis and second_axis are finite unit vectors (length tolerance 1e-4), defaulting to [1, 0]. They set the relative orientation the slider maintains, so a rotated first body makes a rotated rail. The defaults have zero anchors, disabled limits and disabled drive. Enabled travel limits require finite lower_distance <= upper_distance; negative travel is allowed. motor_velocity is relative speed in world units/second along the first axis; motor_max_force is a finite non-negative force cap. The drive is force-based: it approaches its target subject to the cap and load. Disabling it coasts; enabled zero speed brakes within the cap.

{
  "first": "trolley-rail", "second": "trolley-body",
  "first_axis": [1.0, 0.0], "second_axis": [1.0, 0.0],
  "limits_enabled": true, "lower_distance": -1.0, "upper_distance": 1.0,
  "motor_enabled": true, "motor_velocity": 0.8, "motor_max_force": 4.0
}

Slider motor_mode defaults to "velocity", including old payloads. "position" uses motor_target_distance: finite signed separation of the two local anchors along the first body's axis, in world units without transform scale. Stiffness is force per unit of position error; damping is force per unit/second. Both fields default to zero and must be finite and non-negative, even when disabled. The existing force cap and enabled travel limits apply. A target outside enabled limits remains valid but cannot override them. Settings preserve body motion and solver ownership, including suspension/rebuild.

Physics.set_slider_position_motor(owner, target_distance, stiffness, damping, max_force) selects position mode and validates before patching the runtime component, retaining unknown fields. Zero force disables drive. It applies at next fixed synchronization, including before bodies exist. set_slider_motor explicitly returns to velocity mode. Platformer O parks its trolley at signed distance 0.5 or resumes reversal; J recreation restores the selected drive and direction. This general capability is added for the platformer. Tests exercise rotated rails with offset anchors, signed retargeting, caps, enabled limits, coasting, atomic rejection, lifecycle, checked command undo/redo and canonical save/reopen.

sindri.physics2d.spring_joint applies radial, force-based spring and damping between two freely rotating local anchors. It pulls extended anchors together and pushes compressed anchors apart; it does not enforce a hard maximum length. rest_length is finite and strictly positive; stiffness and damping are finite and non-negative. Defaults are length 1, stiffness 10, damping 1 and zero anchors. Stiffness is force per unit of extension; damping is force per unit of relative radial speed. Zero stiffness permits damping alone; zero damping permits oscillation. Under gravity the supported weight stretches the spring beyond its rest length; a regression measures this against mass * gravity / stiffness.

{
  "first": "trolley-body", "second": "spring-lantern",
  "rest_length": 1.0, "stiffness": 20.0, "damping": 1.2
}

Both kinds share the joint owner, stable reference resolver and post-body synchronization lifecycle. Connected endpoints do not collide with each other. Invalid settings fail before backend edits; unchanged synchronization retains one constraint. Settings edits on unchanged endpoints update it in place and wake the bodies without resetting their motion. Owner/component removal drops it; inactive or missing endpoints suspend it; endpoint rebuilds reconnect in the same fixed step. The generic checked inspector and command undo author these settings. Native visual review is exercised; script-triggered world snapshots remain absent. All numeric fields are validated even when their toggle is disabled.

Typed Physics.set_slider_motor(owner, velocity, max_force) and Physics.set_spring(owner, rest_length, stiffness, damping) validate and patch only their fields in the runtime component, preserving unknown payload fields. They require the appropriate component and a physics host. Values apply at the next fixed synchronization, including before endpoints are built, and survive rebuilds. Zero force in the slider call disables its motor rather than braking.

Physics.create_spring_joint(owner, first, second, first_anchor, second_anchor, rest_length, stiffness, damping) authors an enabled spring on an empty owner under the same scoped endpoint and physics contract as distance/hinge creation. Finite Vec2 anchors use body-local world units without transform scale; rest length must be finite and positive, stiffness/damping finite and non-negative (zero is valid). All validation precedes mutation. Next fixed synchronization creates the owned constraint, preserving other components, body motion and legacy connections. set_spring can retune the new component before synchronization. Platformer B rebuilds its light's spring at its current rest length and continues tuning while the trolley remains on its independent rail.

Physics.create_slider_joint(owner, first, second, first_anchor, second_anchor, first_axis, second_axis, limits_enabled, lower_distance, upper_distance) authors an enabled slider on an existing empty joint owner, sharing the scoped endpoint and physics contract. Finite anchors are body-local world units without transform scale, and finite axes must be unit vectors. Bounds validate even when disabled; enabled lower distance cannot exceed upper distance. Its motor starts disabled; set_slider_motor can configure the new component before synchronization. Invalid settings fail before mutation. Next fixed synchronization creates the owned constraint while retaining other components, body motion and legacy connections. Platformer J rebuilds its trolley slider, restores its selected drive and keeps the separately owned spring attached. Native visual review is exercised; script-triggered world snapshots remain absent.

These general capabilities are added for the platformer: Decay reverses a powered lantern trolley near its rail ends, retunes its suspended light's spring and draws the cord from solved positions. Its regression observes both travel directions, bounded rail motion, changed light height and independent removal. Runtime regressions exercise rotated rails, travel limits, force caps, coast/brake, spring extension/compression, damping, weight support and atomic invalid edits. Prefab endpoint references, velocity/position motors, editor save paths and native editor interaction are now exercised. The native joint review records numeric Save/reopen, Play controls and Stop restoration. The 2D joint acceptance slice is complete; 3D joints, gameplay world snapshots and final workspace integration remain separate work.

Runtime ownership and stepping

PhysicsWorld2d and PhysicsWorld3d are separate runtime state beside World, not serialized state inside it. Each owns its private backend and maps EntityId to private handles. Shared native/browser game sessions and native editor Play synchronize and step 2D, then 3D, with the same fixed duration before scripts. Each dimension validates its own batch; this is not a transaction across both worlds. Editor fresh Play, Stop and scene replacement discard both solvers; pause/resume retains them, and Stop restores the authored scene snapshot. Despawning an entity removes its body/collider before the next step; generation-checked IDs prevent a reused slot from inheriting old physics state.

The host advances physics exactly once for each engine fixed update using that fixed step duration. Render frames never step Rapier directly. Pause therefore pauses physics automatically, and time-scale/fixed-step semantics remain engine semantics rather than backend semantics.

The fixed-update order for the first slice is:

  1. apply deferred/checkable gameplay writes;
  2. synchronize static/kinematic authored state into physics;
  3. step physics with the engine fixed dt;
  4. apply queued controller sweep/slide against current solved poses;
  5. synchronize dynamic/velocity-driven/controller results back to World;
  6. publish normalized collision/sensor events;
  7. run consumers that are defined to observe post-physics events.

Controller velocity is held at zero and fresh support is seeded after synchronization, before the solve. Controller movement updates its collider pose after the solve, so solid response and discrete sensor events observe that move at the following solve; same-step response and swept controller triggers remain absent. Scenes without controllers retain their existing behavior.

The exact system-ordering API is still an open roadmap item, so physics must not quietly invent a general scheduler. Its ordering is documented as part of the engine fixed-step contract until that scheduler exists.

Events and queries

Backend events are normalized to Sindri events containing EntityIds and Sindri collider semantics only:

Contact manifolds and raw solver data are deliberately not first-slice public API. Add them only for a demonstrated gameplay requirement.

The implemented query is PhysicsWorld2d::raycast(origin, direction, max_distance, RaycastFilter2d) -> Result<Option<RayHit2d>, PhysicsError>. raycast_where additionally accepts an entity predicate for a scene host to exclude inactive/despawned entities immediately. A query-only per-piece BVH selects candidates at current body poses, including offsets and rotations. It updates immediately on insertion, removal and teleports, and after each completed solver step; it does not depend on the solver broad phase being current.

Direction is normalized, distance is non-negative and inclusive, and geometry inputs must be finite. Zero direction is rejected. Normalization accepts even extreme finite directions; an overflowing segment endpoint is rejected. The filter mask matches collider memberships independently of collision pair filters, sensors are excluded by default, and excluding an entity excludes its whole compound. Mask 0 misses. Exact ties prefer the smaller entity handle, then authored piece order. Inside/on hits report distance 0, the origin point, and normal (0, 0). Other hits report the world-space surface normal.

RayHit2d is a snapshot. Scene transform writes and new colliders take effect at the next synchronization. Decay skips inactive/despawned entities even during a script pass. The platformer proves ground clearance against tilemap geometry; the Physics Playground's probe visualizes filtering, hits and normals.

2D overlaps and shape casts are implemented for circles, boxes and capsules. Decay exposes Physics.overlap_circle, overlap_box, cast_circle and cast_box; Orbital's mine blast and Physics Playground exercise them. 2D rays, overlaps, shape casts and controller penetration/sweep/ground phases use the query index. Previous platform support is reconstructed directly from that known entity's pieces. The incremental physics update is tracked in docs/physics-update.md.

The 2D index uses one leaf per registered collider piece. Ray traversal tests bounds against the finite segment; overlaps use probe bounds; casts use the union of start/end bounds at fixed orientation. Candidates are sorted by entity handle and piece order before the unchanged exact geometry/filtering phase. Predicates are called once per candidate entity, rather than for every registered entity; callers must not depend on visits to remote geometry. Masks, sensors, exclusions, inside hits and exact ties retain their previous results. Controller skin support expands probe bounds by contact prediction; controller casts expand start/end bounds by the target separation plus contact allowance. Initial penetration uses the probe bounds. Step, snap and carry collision phases share these paths. Previous-platform verification reads only the known support entity, reconstructing its historical piece poses regardless of where its current index bounds lie.

Only non-static bodies need post-step refresh. Position-kinematic targets remain pending until the solver step, while other body teleports update immediately. Scene collider edits retain their existing remove/reinsert synchronization path. Outward bounds padding protects touching geometry; overflowing bounds take a conservative exact-scan fallback instead of introducing new validation failures. Work is output-dependent: a dense/long query may still select every piece, and sorting costs O(k log k) for k candidates. Refresh is proportional to registered non-static collider pieces, including sleeping bodies. No backend types escape and no new dependency is introduced.

Results contain Sindri entity IDs, hit position/normal in the appropriate vector dimension, and distance. They never expose Rapier collider handles.

The standalone 3D world exposes raycast/raycast_where, overlap/ overlap_where and shape_cast/shape_cast_where using RaycastFilter3d, RayHit3d, ShapeHit3d and quaternion PhysicsPose3d values. Queries reconstruct current body/local poses, independently of the solver cache: inserts and ordinary teleports are visible before stepping; position-kinematic targets remain pending until the solve. A query-only per-piece BVH selects finite ray segments, overlap probe bounds and the union of start/end sweep bounds at fixed orientation. Candidates are sorted by entity handle and authored piece order before exact geometry/filtering. Stable predicates run once per non-excluded candidate entity; hosts must not rely on visits to remote geometry.

The 3D index updates on insertion, removal and ordinary teleports, then refreshes non-static pieces after each completed step, including sleeping bodies. Static terrain needs no post-step refresh. Outward padding protects touching bounds; overflowing piece bounds stay in a separate conservative candidate set and non-finite probe bounds select every piece. Dense/long queries remain output- dependent, and candidate sorting costs O(k log k). No new dependency is needed. The fallback preserves candidates, not numerical stability of backend geometry: shape casts at near-maximum finite coordinates/extents can return non-finite results. Numerical failure reporting for those extreme 3D queries remains a gap.

Membership masks, sensor opt-in and whole-entity exclusion match the 2D contract. Overlaps return sorted unique entity handles. Rays/sweeps normalize finite XYZ directions in f64, accept extreme finite magnitudes, reject zero directions, negative/non-finite distance and overflowing endpoints. Rays include the segment endpoint. Ties prefer entity handle then authored piece order. Inside/on rays return the origin with zero distance/normal; penetrating shape casts return the probe origin with zero distance/normal. Non-penetrating casts report the touched piece's world-space witness and outward normal. Sweeps keep probe rotation fixed; box/sphere dimensions must be positive, and query capsules allow zero half-height with positive radius. Pose validation uses the body's quaternion tolerance.

Native tests exercise these engine APIs and the scene driver exposes its world. Typed Decay queries, shared sessions, editor Play and the Causeway browser fixture exercise them against real voxel geometry (see below).

Editor

The inspector authors the Sindri body/collider components through the existing checked command and undo/redo path. It must provide:

The Scene view draws every 2D collider's outline from the pieces the physics world is given, tilemap rectangles included, and the selected collider's pieces carry drag handles (a box's edges, a circle's radius, a capsule's radius and height), each drag one undo step. The 3D shapes have no gizmos yet.

The editor never links against or imports Rapier types directly; it consumes the public sindri-physics model/schema.

Decay

Decay exposes typed Sindri physics operations, not Rapier terminology. The 2D surface remains Physics; the independent 3D surface is Physics3d.

Physics3d.velocity(entity) and angular_velocity(entity) return copied Vec3 values. set_velocity(entity, velocity: Vec3), set_angular_velocity(entity, velocity: Vec3) and apply_impulse(entity, impulse: Vec3) act on active 3D bodies. Vectors must be finite and fit engine f32 values. Velocity controls accept dynamic and velocity-kinematic bodies; impulses require dynamic bodies. Rotation locking keeps angular velocity zero. Invalid arguments/body kinds fail before mutation. Controls change live simulation, leaving authored starting motion unchanged; structural body/collider rebuilds therefore restore authored settings. Missing 3D host context and stale/inactive handles fail explicitly. Before synchronization, controls require a valid authored 3D body with nonempty primitive colliders or a convex-model reference, compatible ownership and no moving depth lock. Convex schema/material fields and positive composed scale validate before queuing; hull geometry and mass validate at synchronization. The composed transform supplies pose, as it does for scene insertion. Requests replay in call order before the first solve, after actual compound mass is known. Reads return copied last queued setters or authored starting velocities (locked angular velocity is zero); impulses cannot affect reads until materialization. Unconsumed requests expire after a successful synchronization. Live controls retain their runtime semantics without reparsing authored data.

The standalone engine now supplies BodyControl3d linear/angular velocity setters and impulses, apply_control for live bodies and remember_control for unregistered bodies whose authored kind the caller has validated. Requests validate finite values and body-kind rules before queuing, replay in call order only after actual compound mass is known, and never change authored settings. pending_linear_velocity/pending_angular_velocity return the last matching queued setter; impulses cannot affect them until materialization. Ordinary velocity reads still require a live body. validate_insertion checks body, colliders and all pending requests without mutation, so scene reconciliation rejects a conflicting kind across the whole batch before gravity, removal or rebuilding. Failed validation retains requests for a corrected retry. Rotation locking applies at replay. Removal cancels pending requests even when no body exists. finish_synchronize discards requests not materialized by a successful batch; ScenePhysics3d calls it before solving. Standalone hosts must call it after their own validated insertion batch. Four engine and three scene regressions exercise ordering/mass, kind and input rejection, lock/retry, pre-solve replay and expiry. This is the engine foundation toward Causeway. Four typed bridge regressions additionally exercise real prefab spawning, copied pending reads, kinds/locks and malformed authoring. A shared native-session regression and exported Chromium fixture verify actual spawn replay and movement. Game/voxel integration and native editor proof remain open.

Physics3d.collision_started(), collision_stopped(), sensor_entered() and sensor_exited() return sorted unique lists of other active entities whose contact with this script's entity began or ended in the last successful step. Removed/inactive handles are filtered. Lists are copied and non-draining: every script sees the same completed snapshot. Sensor events are discrete, without fast sweep guarantees. Shared native/browser sessions and editor Play supply independent 2D and 3D context. Six native bridge regressions exercise live/persistent controls, kinds, validation, lifecycle and all four event queries; two scripts exercise copied sensor lists. A shared-session Decay driver and Chromium fixture exercise Vec3 controls, rotation locking, copied values and an actual solid landing event. This is host contract evidence toward Causeway, not voxel/game proof.

Physics3d.raycast(origin: Vec3, direction: Vec3, max_distance, mask, include_sensors, exclude: Entity?) and cast_sphere(origin: Vec3, radius, direction: Vec3, max_distance, mask, include_sensors, exclude: Entity?) return copied RayHit3d? snapshots (entity, world Vec3 point/normal, distance). overlap_sphere(centre: Vec3, radius, mask, include_sensors, exclude: Entity?) returns a copied sorted unique entity list. Queries use the engine's indexed current synchronized geometry and active-entity predicate without stepping. Finite f32-range inputs, positive radii, non-negative distance, normalized nonzero direction and a finite endpoint are required. Masks select memberships as whole u32 values, sensors opt in and exclusion skips all pieces of one entity. Inside/on and initial overlaps return zero distance/normal; a sphere's initial point is its origin. Exact ties prefer entity then authored piece order. Six bridge regressions exercise queries, masks/sensors/exclusion, unsynchronized inactivity/despawn, compound uniqueness, boundary/inside hits, invalid input and optional/copied script snapshots. The shared-session driver and Chromium landing fixture query actual floor geometry. Typed box/capsule overlaps and casts also use indexed geometry, the same filters and copied results. Boxes have positive Vec3 half-extents. Capsules lie along local Y before rotation, with non-negative straight-segment half-height and positive radius; zero half-height is a sphere. Orientation is a finite nonzero Vec3 axis (normalized) and finite angle in radians using the right-hand rule; identity uses angle zero. Axes normalize in f64 before unit-quaternion conversion, avoiding length overflow/underflow. Casts keep this orientation fixed during travel. All inputs fit f32 range. Five bridge regressions exercise rotated misses/hits, sensor/exclusion filtering, normalized sweep travel, initial overlaps, invalid inputs and zero-height/extreme-axis validity. Typed and shared-session/browser scripts exercise all four probes on actual geometry. Existing extreme geometry numerical limitations still apply.

Physics3d.layer(name) and mask(names) read the active authored 3D world's labels independently of 2D. The first 32 labels map to mask bits; duplicate labels choose the first nonempty matching name. Empty labels cannot be selected, repeated requests combine with OR and an empty list yields zero. Inactive settings are ignored; current authored edits are visible before synchronization. Unknown names, wrong types/arity, malformed settings, multiple active 3D worlds and absent 3D context fail explicitly. Bridge regressions cover the high u32 bit, duplicates/empty names, invalid inputs, active authoring and real query filtering; the typed query and shared-session/browser fixtures use named masks. This is scripting integration toward Causeway, whose voxel/game proof remains open.

Initial 2D gameplay operations should cover:

Dimension is explicit in names/types where ambiguity would otherwise exist. The language workspace remains independent: decay/ gains no Sindri dependency; sindri-decay performs all conversion and host calls.

Gather proof

The first 2D physics track is incomplete until Gather visibly depends on it. Gather should evolve so that:

Pathfinding remains navigation, not physics. The Wisp may plan through the grid, but collision remains authoritative for physical overlap. Do not couple Rapier into sindri-grid to make those systems agree implicitly.

3D engine foundation and remaining integration

PhysicsWorld3d now implements standalone fixed-step simulation over Sindri-owned RigidBody3d, Collider3d, PhysicsPose3d and PhysicsEvent3d values. Vectors are XYZ; body and local collider rotations are unit quaternions in [x, y, z, w] order. Quaternions must be finite with squared norm within 0.0001 of one; zero or non-unit rotations outside that tolerance fail explicitly; accepted near-unit values normalize at the backend boundary. Shapes are boxes, spheres and Y-axis capsules with finite positive dimensions. Friction is non-negative, restitution is in [0, 1], and damping is non-negative.

Bodies support static, dynamic, position-kinematic and velocity-kinematic kinds. Insertion validates the whole body and every piece before mutating the backend; errors identify the failing piece. Compounds use local offsets/rotations and sum mass. Gravity scale, damping and all-axis rotation locking are engine policy. Locked rotation clears authored initial angular velocity and angular setters. Velocity/angular-velocity setters accept dynamic or velocity-kinematic bodies; impulses require dynamic bodies. All controls reject invalid values before mutation. Teleports preserve velocity; position-kinematic moves/targets take effect at the next fixed step. Missing handles fail instead of queuing future spawns.

One positive finite engine Duration advances exactly one step. The engine returns solid/sensor start/stop events with sorted entity pairs and dimension- specific event values; event stream order remains backend-owned. Membership/filter masks use the same mutual-pair rules as 2D. Removing an entity removes all of its pieces and drops events whose handles no longer map to live entities, preventing reused backend slots from inheriting old identity. No backend types escape.

Native tests exercise XYZ integration, gravity scale, all shapes landing on solid geometry, rotated/offset collision, quaternion round-trip, masks/sensor enter/exit, kinematic target timing, compound mass/impulse, teleport velocity, rotation lock, atomic rejection and removal/reuse. WASM compilation is a separate gate; no 3D voxel/game proof is claimed. A scratch exported host fixture separately exercises XYZ motion and solid-box landing in Chromium through a read-only Decay observer.

The scene now registers sindri.physics3d.rigid_body, sindri.physics3d.collider and sindri.physics3d.world. Body payloads use the engine's XYZ fields (including position and rotation), but an entity's composed Transform3D is authoritative when present. Without a transform, the body pose is the fallback. Colliders accept one piece or { "pieces": [...] }; a collider without a body creates a static body, while a body without a collider takes no part. Shape dimensions and offsets are local units. The scene applies the entity's composed world scale (including parents) once before solver insertion and voxel reach planning. Without a transform, scale is one. Positive finite uniform scale works for all pieces; boxes also support nonuniform scale if their local rotated axes remain orthogonal. Axis-permuting rotations work; sheared boxes, nonuniform spheres/capsules, zero/negative scale and overflowing dimensions/offsets fail explicitly, naming the entity and piece when applicable. The scene does not approximate ellipsoids or stretched capsule caps. Collider wireframes use the same resolved geometry and omit unsupported geometry. Standalone solver values remain world units, and 2D collider semantics are unchanged. Existing scenes authored with world-unit dimensions must convert them to local units; an axis-aligned box's old half extents and offsets divide by its composed scale. Orbit Camera Lab is converted, while the quarry's unit-scale pieces need no edit. Orbit Lab's keyboard shrink/restore controls exercise live scaling through Session and real Chromium: center and edge rays verify synchronized geometry, including reactivating a removed barrier. Native scale review exercises inspector edits, matching wireframes, Undo/Redo, persistence, Play and Stop restoration. Explorer game adoption remains open. World settings accept gravity: [x, y, z] and optional layer labels; removing/disabling the settings restores the host's gravity. Multiple active 3D world settings fail explicitly.

ScenePhysics3d validates the complete active authored batch before changing runtime gravity, lifecycle or bodies. Both scene drivers reject an active entity claiming 2D and 3D body/collider/controller/tilemap ownership. Moving 3D bodies reject a Z-locked local transform rather than allowing simulation and rendering to disagree. The driver removes inactive/despawned entities and removed colliders, including inherited inactivity, and re-registers them on reactivation. Unchanged components preserve live velocity; transform/ancestor moves teleport or set a position-kinematic target. Any body/collider payload change or resolved geometry change caused by entity or ancestor scale rebuilds that body and resets its motion to authored values. Unchanged scale does not rebuild; scale writes become effective at the next fixed step. Authored collider payloads are never rewritten by scaling. Live coefficient/control patching is not a 3D scene capability yet.

After solving, all non-static body kinds write XYZ/quaternion poses back through parent space, preserving scale and component payloads. Parent bodies are written before children regardless of allocation order; world transforms are snapshotted before writes. Equivalent quaternion signs do not count as external movement. Position-kinematic targets set through the runtime world write back after solving. Events describe the last successful step; invalid input retains prior runtime state/events. Registered defaults and body-kind choices are available to generic checked authoring. Tests exercise commands/undo/redo and stable-ID save/reopen, not native inspector or Play interaction. The Physics menu includes all three 3D components, and its registered-component coverage test passes.

This is the engine prerequisite for the physics-update voxel-world proof in Causeway. Two shared-session regressions exercise XYZ motion, solid landing and disabling/reactivating bodies; browser host execution exercises the same motion and landing. Native editor interaction is exercised in the native review, and authored voxel collision (below) supplies the occupied/resident/edited world proof. Typed rotated probes are exercised as described above. CCD controls, contacts, force/torque, materials, joints and character controllers also have no 3D authoring/Decay surface yet. The 2D API remains unchanged.

Live 3D collision coefficients

PhysicsWorld3d::materials(entity) returns copied PhysicsMaterial values. set_materials(entity, values) updates existing colliders without rebuilding their body or geometry. The order is the original primitive pieces followed by convex hull pieces, then static groups in ascending key and piece order. Static triangle meshes use their original piece order. An existing static owner whose groups have all been removed accepts an empty list; an absent owner fails.

The complete piece count and all coefficients validate before any mutation. Friction is finite and nonnegative; restitution is finite in [0, 1], using the same asset-free PhysicsMaterial rules as 2D. Updates preserve collider and body handles, pose, mass, linear/angular velocity, forces, position-kinematic targets and checkpoint replay. Changed values refresh contact processing and wake affected bodies, including sleeping neighbors of a changed static floor. Identical values leave sleeping bodies asleep. Friction retains the minimum combine rule and restitution the average rule.

Rapier can reuse a resting contact's cached coefficients even after a pose refresh. A changed batch therefore requests one full contact update on the next successful step, temporarily bypassing contact recycling for that step only. Normal recycling resumes afterward. Checkpoints retain the pending refresh; an invalid timestep does not consume it. Geometry and contact identities remain intact. A profile reload may consequently cost one full world contact pass.

Native backend regressions exercise mixed primitive/hull validation and state, static triangle/group order and identity, empty owners, sleeping bodies, kinematic targets, friction changes on existing contacts without new start events, sleeping-neighbor wakeup and restitution/replay.

Scene synchronization resolves profiles before committing any 3D body or voxel batch. Missing/wrongly typed profiles and invalid enabled overrides leave live 3D geometry, motion and events intact. Cached model plans start from authored literal coefficients every step, so removing a profile restores those values. Coefficient-only edits (including literal model coefficients) preserve body motion, character support state and existing contacts. Structural geometry, layers, sensor, scale or body edits retain the normal rebuild behavior.

Voxel coefficient edits retain compiled boxes, section keys and solver groups, consume no geometry rebuild budget and apply coefficients in solver group order. Session builds one source set for both dimensions before stepping; restoring a checkpoint keeps the latest profile assets delivered by the host. Export discovers 3D profile references in inactive entities and spawned prefab contents as well as ordinary scenes. The editor registers Physics Material 3D in its Physics family, with a profile asset field and command-backed generic override controls.

Model Collision Lab's floor and spawned convex prop share materials/slide.profile and explicitly override restitution to zero. Native scene/runtime/export regressions exercise sharing, precedence, invalid-batch retention, reload, profile removal, structural rebuilds and checkpoint replay. This capability is added for Explorer, with feature-lab proof; Explorer adoption remains pending. The rebuilt Chromium smoke fetches the profile and both GLBs, reaches both goals, checks rendered colors and reports no runtime/GPU errors. The native editor review covers profile selection, overrides, Undo/Redo, save/reopen, valid/invalid asset reload, prop Play goal and Stop restoration. Native visual evidence is X11/software Vulkan; broader platform/GPU coverage remains open.

Replaceable static collider groups

PhysicsWorld3d::replace_static_group(entity, key, pose, pieces) changes one static geometry group under a real engine entity, retaining that body's handle, all other group's collider handles and any originally inserted pieces. A first nonempty group creates a static owner; group keys are caller-owned runtime u64 identities, not scene or entity handles. This is a general streamed/editable geometry primitive toward Causeway, with no voxel dependency in the solver.

Pose belongs to the whole owner, so changing it moves every group together. Each piece retains its local offset/rotation, sensor, masks and coefficients. Immediate indexed queries see the new geometry and return the owner's entity; exact ties use original pieces, then ascending group key and piece order. Replacement rebuilds only that group's backend colliders, although query-index ordering/pose refresh visits the owner's retained pieces as well. Hosts must bound total owned pieces and per-step edits; the API imposes no residency budget.

validate_static_group checks pose, all pieces, static kind and (for a new nonempty owner) pending-control compatibility without mutation, allowing hosts to prevalidate their whole edit batch. Invalid replacements preserve geometry. remove_static_group releases one group, preserving body and all other pieces; an unknown group returns false on an existing static owner. Missing/nonstatic owners fail. Replacing with an empty list removes that group and applies the owner pose; it never creates an absent owner. An empty existing owner remains registered until ordinary remove, which cancels every group and allows clean owner reuse. Changed/removed pieces follow ordinary structural collider event semantics; retained pieces are not rebuilt.

Six native regressions exercise immediate replacement/removal queries, original-piece preservation, actual body/unaffected collider handles, canonical ordering, invalid inputs/nonstatic owners/pending conflicts, sensor/mask/local pose filtering, empty/reused owners and a dynamic body's landing/contact event against its real owner followed by falling after removal. All 187 physics tests, warning-denied native checks/Clippy and all-target/all-feature WASM checks pass. This is an engine prerequisite. The scene snapshot adapter below now provides revision caches and bounded reconciliation; authored policy/residency production and real Causeway/Decay/editor/browser streamed collision remain absent.

Authored voxel colliders

sindri.physics3d.voxel_collider opts the voxel world on the same entity into 3D collision. It carries friction, restitution, layers and a residency margin in voxels (default two). The entity must carry sindri.voxel_world and must not also carry a 3D body or collider: the collision cache owns that entity's static solver body exclusively, and a conflict fails the step before either batch changes the solver.

What collides is a block set's answer, not navigation's. TileDefinition gains collides (default true): a colliding block contributes its full bounds() box (height or extent, in sixteenths), and a block with "collides": false -- Causeway's water -- is passed through. supports and walkable are not read: a railing post stops a crate and holds no walker up. Worlds of numbered materials collide as whole cubes. The policy revision is a hash of every block's answer; each section's occupancy revision is a hash of its voxels after edits, so an edit, a different generator or a rebound block set all change the revision and equal content reuses cached geometry.

Residency follows what can hit voxels. Scene characters add their scaled probe extent, requested travel and skin/snap/step margins to the same residency plan, so characters alone retain collision terrain. Each step, every planned dynamic body contributes its farthest piece extent plus twice the travel its velocity and gravity allow in that step, transformed into the world's voxel space (the entity's composed pose and scale, then the grid's cell size and centring from voxel_space) and widened by the margin. The union of sections those reaches touch is the complete snapshot handed to SceneVoxelCollision3d; terrain no body is near is not resident, wherever the camera is. A single reach wider than the section budget fails the step whole rather than publishing a partial window. Ordinary authored kinematic and static bodies do not drive residency; scene characters do. Queries see only resident sections -- a script asking about distant terrain uses Grid/Aim.

ScenePhysics3d::step_with_tile_sets resolves these worlds through VoxelGround (generator, palette and edits), plans the voxel snapshot against the same solver state as the body batch, validates both, then commits bodies and voxels before solving. The cache lives inside ScenePhysics3d, so a fresh session or the editor's Play/Stop reset clears both together. step is the same call without block sets, for hosts whose voxel colliders use materials. Shared native/browser game sessions and editor Play pass their bound block sets.

Causeway exercises it: taking a laid block back spawns a loose-block prefab whose dynamic body pops out of the emptied cell, falls onto the world's own voxels and returns to the stock only when Physics3d.collision_started reports the Floor world (a three-second fallback covers water and edges). Scene regressions cover landing and the owner contact event, raycasts against the owner, falling down an edited shaft after the body has slept, far-from-origin residency, block-set non-colliding water and half-height slabs, releasing geometry when the component is removed, and atomic rejection of missing worlds, conflicting owners, unbound block sets and over-budget reaches.

Resident voxel snapshot adapter

SceneVoxelCollision3d connects resolved resident VoxelSection snapshots to keyed static solver groups. It owns each supplied real scene entity's solver body exclusively; it creates no artificial section entities and never steps physics. Hosts provide the complete set of active collision worlds/sections on every synchronize call, independently of render visibility. The adapter takes World only to validate owner lifetime/activity; authored component resolution, composed pose/scale and residency selection remain the host's responsibility.

A VoxelCollisionWorld3d supplies owner, world pose, positive finite XYZ scale, shared layers/friction/restitution/sensor settings, a collision shape policy and its revision. Each VoxelCollisionSection3d supplies coordinate, voxel snapshot and occupancy revision. Revisions must change whenever the corresponding data changes: replacing a generator with sections whose local revision is again zero must not reuse the old occupancy revision. Likewise changing a callback's answer requires a new policy revision. Solidity is explicit, not guessed from render occlusion or navigation walkability. All blocks in a snapshot share the world's physical settings; distinct block coefficients need separate groups or a later richer policy. Reflected/nonpositive scale is rejected. Geometry stays in the engine's f32 coordinate domain, with its existing extreme-value limits.

Compiled boxes and collider arrays are retained by occupancy/policy revision, scale and physical settings. Unchanged sections keep their geometry; scale or coefficient changes rebuild collider pieces from cached boxes without recompiling occupancy. Pose-only changes move the owner without replacing sections. New keys are allocated in section-coordinate order and retained while resident. Departed sections release their groups; omitted, inactive or despawned owners release the owned body. A supplied stale owner is an error. Removing the last section keeps an empty owned body until its world is omitted. Reset the cache together with the solver; unrelated/lost owned bodies are rejected. Dropping the cache alone does not clean up the solver: synchronize an empty snapshot first unless both objects are being reset.

VoxelCollisionBudget3d defaults to four active worlds, 64 retained sections, eight rebuilt sections per call and 8,192 total retained collider pieces. Each compilation visits at most 4,096 cells. Query-index refresh still visits retained owner pieces. Removal work is bounded by previously retained sections/ pieces. Over-budget inputs fail before changing solver geometry or cached revisions; the adapter does not publish a partial window or silently defer a hole. Hosts must choose a bounded complete collision window or deliberately configure larger limits. Snapshot validation and every changed collider group complete before any insertion, removal or pose update; failed input retains the previous snapshot for a corrected retry. VoxelCollisionReport3d reports active world/section/piece counts and compiled/rebuilt/removed section counts.

Seven native scene regressions exercise unchanged/edited/policy cache reuse, retained collider arrays, transformed slab bounds, scale/settings/pose updates, all four budget failures, late invalid inputs and retry, duplicates/ownership, negative-section departure/reentry, inactivity/despawn/reset and actual landing/ contact on occupied voxel geometry followed by falling through an edited hole. All 497 scene tests, warning-denied native checks/Clippy and all-target/all-feature WASM checks pass. This is the scene/solver seam; authored voxel colliders (above) produce its snapshots from real worlds.

Voxel collision geometry foundation

compile_section_collision in sindri-voxel accepts a VoxelSection and a caller-owned VoxelId -> Option<VoxelShape> collision policy. Air is always empty; None makes other blocks noncolliding. Solidity is independent of render transparency/occlusion, so a host must supply its actual block policy rather than infer collision from what hides a face. The function never samples a generator or a neighbouring/unresident section and never mutates occupancy.

SectionCollisionBox uses exact section-local integer min/max bounds in sixteenths of a voxel, within 0..=256. Full cubes merge greedily in X, then Z, then Y, across material IDs that the policy classifies as full. Partial slabs, posts and other box shapes retain their individual exact bounds. Output is deterministic for a stable policy, disjoint and covers precisely the collidable occupancy; the box count is at most 4,096, not necessarily the minimum possible. Invalid zero-thickness, inverted or out-of-voxel shapes return a typed VoxelCollisionError. Physics coefficients and per-block collision layers are not represented in this geometry compiler; hosts needing distinct coefficients must partition policy/output rather than silently merge incompatible settings.

Five native regressions exercise air/full sections, merging across materials, 24 varied occupancy patterns with exact cover/no-overlap/no-air assertions, partial shapes/noncollision, worst-case output bounds, typed errors and edited negative sections across residency unload/reload. The crate also checks for WASM. This is a renderer/physics-independent engine prerequisite toward Causeway, not solver or game proof. Scene integration must retain compiled geometry by occupancy and policy revision, rebuild only entering/edited sections, release leaving/disabled worlds, apply composed transforms and bound per-step work. The native scene adapter now exercises resident snapshot collision and owner queries/events. Authored host/game/editor/Decay/browser integration remains absent. No component or host surface changes here.

Feature-track slices

Implement this in reviewable PRs rather than one giant patch:

  1. Physics foundation. Add sindri-physics, dependency-policy changes, Sindri-owned 2D/3D public model, backend masking, fixed-step 2D runtime, collision layers/events, native + WASM tests. Do not mark Editor or Script complete.
  2. 2D authoring. Register/serialize the 2D scene components, inspector controls, checked edits, undo/redo, and collider gizmos.
  3. Decay 2D. Add typed velocity/impulse/event access through sindri-decay with language/host contract tests.
  4. Gather physics. Convert obstacles, pickups, player movement, and one hazard interaction to the real physics path; prove native/browser behavior and update capability docs/matrices.
  5. 3D physics. Establish the standalone runtime first, then integrate scene/editor/Decay surfaces with a real 3D proof that exercises them end to end. The engine foundation alone does not complete this track.

Each PR runs all checks relevant to its dependency and target surface. A slice that introduces Rapier must run cargo deny; a slice touching browser-reachable physics must compile WASM; the Gather slice must run the real browser smoke test.

Non-goals for the first 2D slice

Those are not forbidden forever. They are deliberately absent until a real game needs them.

Joint motor authoring choices

Hinge and slider motor_mode fields are registered choices derived from MotorMode2d: velocity and position. The generic inspector offers these choices through its existing checked-command path, including the default velocity mode for old payloads that omit the field. Selecting a mode preserves other settings; it does not enable the motor or reset its target or gains. This closes an authoring gap found while reviewing the platformer mechanisms.

Geometric movement foundation

PhysicsWorld2d::move_and_slide computes read-only 2D swept displacement with positive skin, bounded iterations and explicit penetration/budget outcomes. It uses current poses and existing query filtering; ordinary shape casts retain their zero-normal initial-overlap contract. See character movement for the result semantics, validation, ownership and remaining vertical slices. This is not yet the full character controller; one-way geometry is two-sided in this primitive. No editor, Decay or platformer integration is claimed.

Ground probes now report nearest support and classify world-space normals against configurable up and slope limits, including zero-travel contacts at the skin. Steep surfaces are reported, not skipped; initial penetration is unwalkable. The character movement contract defines tolerances and remaining controller movement policy. Ordinary geometric queries are unchanged; this is not yet controller or game proof.

move_and_slide_grounded composes sliding with post-move support and optional snapping using one shared skin. Snap defaults off; positive downward travel is accepted only for walkable support. Upward requests and initial penetration suppress snapping and grounded state. The result separates slide motion, support, snap and total translation; gameplay applies total translation once and owns prior support state. Native tests exercise landing, ledges, blocked ascent, repeated support and downhill snapping. One-way controller policy and scene ownership are now implemented; checked editor/Decay/platformer proof remain open.

Grounded sliding also enforces max_slope_angle: upward-facing steep contacts cannot create rise beyond the positive remaining request. Horizontal approaches stop at steep slopes; explicit jumps can slide with bounded rise and steep descent remains possible without support or snap. Walkable slopes, vertical walls and ceilings keep geometric projection. Motion and support share the slope tolerance; ordinary move_and_slide remains unrestricted. No gameplay gravity or constant slope speed is supplied. See the character contract and native slope regressions.

Optional grounded steps use step_height (default zero) and require initial walkable support, no ascent, clear full-height lift, improved forward progress, a walkable landing within the height cap and final non-penetrating support. Ceilings, overhangs, tall obstacles and absent/steep support retain the ordinary path. The result separates step lift, selected forward slide and downward landing; apply their total translation once. Full-height clearance and skin-sized minimum progress are conservative limits. Impact contact geometry now refines movement cast normals, preventing large artificial hops on flat box faces. Ordinary ray/overlap/shape-cast contracts are unchanged; see the character contract/tests.

Opt-in platform_support captures the previous synchronized support entity/pose. Grounded movement verifies old walkable contact using current local pieces, derives the origin's displacement through previous/current body poses and sweeps carry before character motion. The support alone is excluded during carry, then restored for ordinary slide/step/ground queries. platform reports requested and actual carry plus collisions/current pose; total translation includes it once. No velocity inheritance or persistent snapshot state is supplied. Hosts advance/clear snapshots for grounding changes, teleports or structural edits and avoid additional parent/ solver motion. Wall clipping and ceiling crush remain explicit. Rotation follows a chord with fixed probe orientation, not an arc/orientation sweep. See the character contract for ordering, limitations and native kinematic/geometry tests.

Grounded controller phases now respect per-piece one-way support sides/cones; request-scoped drop_through ignores only one-way solids while ordinary geometric queries remain two-sided. Scene-authored sindri.physics2d.character owns one solid collider probe (additional sensors allowed) and derives a stationary kinematic body, rejecting simultaneous rigid-body ownership. Runtime CharacterRequests2d queues displacement/snap permission and timed drop-through; ScenePhysics2d seeds fresh support, captures synchronized poses, applies movement once and exposes cached results through character_motion/CharacterMotions2d. Settings, parenting, teleports, removal and structural edits clear old state; positive drop remainders cover a whole pass and decrement afterward. Saved component data contains settings, never input queues or runtime support handles. No game rules are supplied. See the character contract for frame ordering, query masks, transform semantics, sensor timing and remaining editor/Decay/game integration. Compound character probes, same-step solver response and swept controller triggers remain absent.

3D character query foundation

PhysicsWorld3d::move_character and move_character_where wrap Rapier's kinematic character controller in engine-owned CharacterOptions3d, CharacterMotion3d and CharacterCollision3d values. They are read-only world-displacement queries with current-pose geometry, slope/step/snap options and copied hit results; applying motion and gameplay policy are separate. Scene ownership is described below; there is no Decay surface yet. This is added generally for Explorer's planned movement, with native engine regression evidence and honestly pending game/editor/browser proof. See Character movement for validation, backend flag semantics, numerical limits and remaining slices.

Scene-owned 3D character movement

sindri.physics3d.character derives a stationary kinematic body from one scaled solid Collider 3D piece, with optional sensor pieces and no authored rigid body. ScenePhysics3d consumes queued world displacement after the solve, applies it once and writes the result through the existing parent-space transform path. Input/solver reach participates in voxel residency before any bodies change. Actual solved carry expands residency before rider queries; a carry budget failure retains queued input and leaves riders unmoved but does not roll back the solve. Session uses this same driver, including checkpoints; no host assembles another movement loop. Backend limits, request/result lifetime, next-solve sensor timing and remaining Decay/game proof are specified in the character contract.

Classified 3D ground queries

PhysicsWorld3d::probe_ground and probe_ground_where expose read-only nearest support geometry through GroundOptions3d and copied GroundProbe3d values. They classify skin/touching contacts and downward casts against a slope limit, retain steep nearest surfaces and reject initial penetration as support. Filters, stable whole-entity predicate reuse, deterministic ties and current poses use the existing query index. No snap, carry or gameplay standing policy is applied. Native geometry/filter/lifecycle/invalid-input regressions exercise this general Explorer prerequisite; browser/editor/Decay/game proof remain pending. See the contract.

Read-only 3D platform carry

move_character_grounded / move_character_grounded_where combine optional PlatformSupport3d, collision-limited carry, ordinary Rapier movement and classified final support. Current compound geometry verifies the previous pose; translation and rotation-point travel sweep before input movement, with separate copied carry/movement hits and one total translation. Pending targets contribute nothing until solved. Native regressions cover geometry, filtering, wall clipping, vertical platforms, jumps and validation. Hosts still own snapshot invalidation, once-only application and terrain reach; scene ownership is described above. See the contract for chord, fixed-orientation and backend correction/iteration limits.

Scene Character 3D enables platform carry by default, with a flat carry_platforms opt-out. Support is classified before solving; solved translation and rotation-point carry precede input movement and are included once in the cached GroundedCharacterMotion3d. Dependent rider caches invalidate with support lifecycle changes; fresh snapshots prevent replay after clipped travel. Native scene and Session regressions exercise parenting, jumps, lifecycle, replay and terrain budgets. Browser/controller, editor interaction and Explorer proof remain pending; no controller-on-controller carry is claimed.

Imported-model collision preparation

Assets now prepare bounded model-space triangles with accumulated selected-node transforms, reflected winding and primitive-instance ranges. This is the CPU geometry prerequisite for model-derived static meshes and convex hulls, added for the planned Explorer showcase. Static triangle geometry and standalone-world queries are now implemented; scene asset bindings remain absent. Standalone convex hulls are available below. No runtime body is created by asset preparation. See the asset contract.

Static triangle geometry

TriangleMesh3d::new(vertices, triangles) builds an immutable, clone-shared physics resource without an asset, renderer or scene dependency. Its private backend acceleration structure is shared across instances and world snapshots. It accepts open, disconnected and nonmanifold surfaces without welding or repair; rays see both sides and gaps remain empty. A mesh is a surface, not a solid volume or a convex approximation. Authored winding remains meaningful for normals.

Construction rejects empty geometry, more than 1,000,000 vertices or triangles, nonfinite coordinates or coordinates outside +/-100,000,000 units, out-of-range indices and degenerate triangles. Triangle cross-product squared length must be positive and finite in backend f32 arithmetic; extremely small triangles can therefore fail even after CPU model preparation succeeds. These bounds keep construction arithmetic representable. TriangleMeshError3d identifies a failing vertex or triangle; failed construction returns no partial resource.

TriangleMeshCollider3d adds local pose, sensor flag, collision layers and PhysicsMaterial coefficients. PhysicsWorld3d::validate_static_mesh_group and replace_static_mesh_group validate the owner pose, every piece (with its index), composed mesh bounds, static ownership and incompatible pending controls before mutation. Static meshes share keys, body identity, ordering, replacement and removal with existing primitive groups. A group can replace either kind. The owner pose moves all groups; piece poses remain local. An empty replacement removes a group and never creates an absent owner. Queries see changes before a solver step; simulation contacts update at the next solve.

Eight native regressions exercise geometry errors/budgets, shared resources, open gaps and two-sided rays, immediate ray/overlap/cast/ground/character queries, local rotation and filtering, replacement/removal, failed late-piece edits and pending controls, original body insertion/duplicate ownership, dynamic sphere landing and identical cloned-world replay. This is standalone physics proof for the planned Explorer showcase. Primitive ColliderShape3d remains unchanged. Explicit static scene asset bindings are described below; automatic host loading and native/browser/editor/Explorer project proof remain pending; WebAssembly compilation alone does not prove browser behavior.

Convex hull geometry

ConvexHull3d::new(points) builds an immutable, clone-shared volumetric hull without asset, renderer or scene dependencies. It fills concavities rather than performing convex decomposition. Duplicate and interior points are allowed; scale is baked into input positions. Construction accepts 4..=4,096 points in +/-10,000 local units. Too few/too many points, nonfinite/out-of-range points, flat/collinear/identical input and unusable backend mass properties return ConvexHullError3d. Unit-density mass, principal inertia and their inverses must be positive and finite, with finite center of mass and inertia-frame rotation. Tiny, thin or numerically ill-conditioned hulls may therefore be rejected.

ConvexHullCollider3d carries the shared geometry, local pose, sensor flag, layers and existing PhysicsMaterial. PhysicsWorld3d::validate_hull_body and insert_hull_body take a rigid body, primitive pieces and hull pieces. Diagnostics number primitives first, then hulls. Compounds require 1..=1,024 total pieces. The body, all pieces, composed bounds, individual/combined mass properties, entity ownership and queued controls are checked before insertion. Failure keeps pending controls and leaves the world untouched. Geometry has default unit density; sensor pieces contribute mass, matching existing primitive behavior.

Hull bodies use the ordinary insertion, coefficients, immediate queries, teleports/kinematic targets, velocity/impulse controls, events, removal and world snapshot lifecycle. All four body kinds work; a hull may join primitives in a compound. Primitive-only compounds also pass through this API, subject to its stricter count and mass checks. Static hulls remain original body pieces when keyed static groups are added later.

Eight native regressions exercise invalid/degenerate construction, shared geometry and exact tetrahedron rays/interior overlap, mixed-compound mass and local rotation/offset, pending impulses, dynamic landing and cloned replay, atomic invalid edits/budgets/duplicate ownership, both kinematic kinds and sensor filtering/enter/exit events. This foundation is added for the planned Explorer showcase. Model-derived scene bindings now have explicit native proof below; automatic asset loading and native/browser/editor/game project proof remain pending. Serialized primitive shapes and Decay APIs do not change in this slice; WASM compilation is not browser behavior proof.

Static model scene ownership

sindri.physics3d.model_collider requires a logical asset ID and optionally accepts friction (default 0.5), restitution (0), layers (all) and sensor (false). It owns one static body and cannot coexist with a 3D rigid body, primitive collider, character or voxel collider. Mixed 2D/3D ownership is rejected. A render model is optional and can name a different asset. There is no invented blank asset or engine add-component default. The editor completes the component from a real project GLB and offers a model asset picker through declared field metadata. It withholds competing body/collider/character components in either order. Render and collision asset choices remain independent and undo separately; see the native authoring review.

Hosts explicitly prepare and deliver resources with Session::bind_model_collision(id, &ModelAsset) after asynchronous decoding. The runtime calls bounded CPU model preparation, then scene-owned ModelCollisionGeometry3d validation; neither scene nor physics depends on assets. Failed preparation keeps the previous binding. Removing a binding makes active references fail before the next 3D solve, retaining live geometry until corrected. Shared model asset discovery and export include collision-only references from scenes and prefabs, including inactive entities. Automatic native/browser player collision binding now runs after asset loading completes and before Session starts. It expands all scene/prefab references, including inactive entities and overrides, and prepares only authored collision assets. Missing or invalid geometry names the logical asset ID. Native headless ProjectRun waits for its worker queue and installs authored collision before stepping. Editor Play uses its asynchronous model queue, including unsaved world references and retained later-scene/prefab assets; unavailable or invalid assets keep Play stopped with a named diagnostic. Successful model reloads replace collision before the next step; failures retain the last valid binding. Stop releases Play-only retention. References introduced during Play require restarting the run. Model Collision Lab's editor/headless parity regression reaches the goal with identical state, then restores and replays wall obstruction.

The scene driver prevalidates the entire authored plan before changing its solver. Composed positive finite scale is baked into model-space triangles; nonuniform scale is supported, invalid/overflowing/degenerate results fail. Identity geometry shares its immutable resource. A cache retains at most 64 scaled variants; active colliders retain their resources independently. Preparation may populate this derived cache even when another entity invalidates the plan. Unchanged source/properties/scale keeps the registration; changed resources or scale rebuild once. Translation/rotation uses existing static teleports. Disable, removal and primitive/model transitions use the same ownership lifecycle.

Checkpoints copy the solver and share immutable geometry. Restoring keeps the latest host-delivered bindings, like script sources; the next step reconciles against those resources. Native scene tests exercise dynamic landing, inherited scale/rotation, invalid updates, lifecycle and ownership transitions. Native Session tests decode the actual GLB fixture, block character motion, replay the same checkpoint and retain replacements across restore and failed preparation. This slice is foundation for Explorer; native/browser/editor project and game proof remain pending, and no surface earns a completion checkmark from compiling.

Convex model scene ownership

sindri.physics3d.model_convex_collider uses the static model collider's required asset and optional friction/restitution/layers/sensor fields. It supplies one volumetric hull to an optional sindri.physics3d.rigid_body; without that body it is static. All four body kinds are supported. Primitive collider pieces may share this body, while characters, static model collision, voxel collision and mixed 2D/3D owners are rejected before solver mutation. Moving Z-locked owners are rejected. Rendering remains independent.

Hosts explicitly call Session::bind_model_convex_collision(id, &ModelAsset) after decoding. CPU preparation expands the selected hierarchy with a 4,096 expanded-vertex budget, including duplicate vertices and repeated mesh instances. ModelConvexGeometry3d computes one hull of the entire hierarchy; it fills gaps and concavities and performs no decomposition, welding or simplification. A flat model fails with a typed error. Hull point, volume and mass limits are those of ConvexHull3d; asset preparation retains its other default node/triangle limits. Failed preparation leaves the previous binding intact.

Positive finite composed nonuniform scale is baked into points. Unit-scale instances share immutable geometry; at most 64 scaled variants are cached, with active instances retaining their geometry independently. Source, scale or collider/body edits rebuild; unchanged frames and transform teleports preserve velocity. Dynamic hull extent contributes to voxel residency. Disable, removal and primitive transitions use the shared scene lifecycle. Checkpoints share immutable hulls and restoring preserves the latest host bindings.

Native scene regressions exercise landing, scaling, inherited placement, mixed pieces, live velocity, replay, residency extent, invalid scale/material/ownership, Z-lock and unbinding retention. A Session regression uses the decoded GLB fixture, reorients a node to make it volumetric, moves and replays its body, and verifies replacement across restore and rejected planar replacement. Hull rebuild prevalidation ignores existing registration; insertion still rejects duplicate ownership.

Shared discovery now retains separate triangle/convex uses for each logical asset across scenes, expanded prefabs/overrides and inactive entities. Export includes convex-only references and original verified GLB bytes. The native/browser player, headless ProjectRun and editor Play prepare only authored representations before gameplay. Editor startup merges unsaved and later-scene uses without losing either representation, and keeps their assets retained through Play.

Session::bind_model_collisions expands CPU geometry once, validates every requested representation, then replaces bindings. A failed hull preparation leaves both previous bindings intact, including an asset used for triangles elsewhere. Shared triangle/convex use inherits the 4,096 expanded-vertex convex budget; triangle-only assets keep their larger budget. Editor model reload uses this same atomic operation and retains the previous rendered model on collision failure. References introduced during Play still require stopping and restarting.

Add Convex Model Collider uses a real project GLB and the model asset picker. Rigid bodies and primitive pieces may share it; static model/voxel collision, characters and 2D collision (including tilemaps) are withheld in either order. Model Collision Lab now has a volumetric GLB prop that falls onto the imported floor, receives a Decay impulse after landing, and reaches a travel goal. This is native player/Session, rebuilt browser and native editor feature proof for Explorer; see the editor review. Explorer game adoption remains open. Static triangle and convex bindings remain independent, even when they name the same asset.

Convex-model spawn-window controls

Existing Physics3d motion reads, setters and impulses now accept an authored convex-only body before its first synchronization, and hull/primitive compounds. PhysicsWorld3d::validate_body_settings checks the real authored/composed body without requiring or inventing primitive geometry. The host also validates collider schemas, nonempty primitive pieces when present, convex material, positive finite composed hull scale, dimension/owner conflicts and moving Z locks. Invalid authoring or input never replaces an earlier valid queued request.

No asset loading or hull construction runs in the scripting host. Geometry, bounds, volume, scaled mass and pending kind compatibility validate together before scene solver mutation. Missing/invalid hull bindings fail synchronization and retain queued controls for a corrected retry. Reads return copied authored starts/last queued setters; impulses wait for the actual combined mass. Rotation locks still return zero angular velocity. Successful materialization replays once in call order, retains authored motion fields and participates in checkpoints.

Five native host regressions cover scaled hull mass/ordered controls/replay, invalid authoring preserving the last request, kinematic impulse rejection and rotation locks, mixed-compound mass, and missing binding/corrected retry. Model Collision Lab now spawns its prop from a typed prefab, queues velocity, impulse and angular motion immediately, and reports pending-read verification. A project Session regression checks the first materialization step and checkpoint replay, then reaches the prop's contact-start impulse/travel goal. Rebuilt Chromium verifies the pending-read message, both fetched GLBs and both lab goals. Native editor Play/restart reaches the prop goal and Stop removes the spawned body; see the review. Explorer adoption remains open.