# Feature examples: 3D

Sindri AI knowledge pack, file `13-examples-3d.md`. Complete, working 3D example projects: transforms, orbit camera, character movement, lighting, model collision, day/night.

Start with `00-start-here.md` if you have not read it. Every fact here is copied from the Sindri repository; nothing in this file is a guess.

## Contents of this file

- **3D Transform Lab** (`examples/transform`)
- **3D Orbit Camera Lab** (`examples/orbit`)
- **3D Character Lab** (`examples/character3d`)
- **3D Lighting Lab** (`examples/lighting3d`)
- **Model Collision Lab** (`examples/model-collision`)
- **Day/Night Lab** (`examples/daynight`)

---

## Example: 3D Transform Lab (`examples/transform`)

Every text file of the project follows, whole, under its path relative to the project folder. JSON files are shown with short arrays on one line; whitespace does not matter to Sindri.

### 3D Transform Lab

A feature example of world-space aiming, direction reads and orbiting, from
slice 6 of the 3D update. A cube faces a raised goal and walks to it while a
perspective camera orbits the scene. The cube starts under a scaled parent:
its script uses world coordinates so the parent does not change its speed.
Pages publishes it at `https://sindri.vardir.no/examples/transform/`.

Open this project in the editor, or run:

```sh
cargo run -p sindri-player -- examples/transform
```

Read `assets/transform.decay`: `look_at` faces the goal, `forward` supplies the
unit movement direction, and `rotate_around` turns the camera's position and
orientation together. On arrival the walker copies its parent-relative
`Quat` rotation onto the goal, then restores the rotation saved at startup.
Both entities are checked before the script announces completion. A local `var`
can edit a copied quaternion's x/y/z/w components; assign it back whole so the
host normalizes once and rejects zero/non-finite values without mutation.

Directions include parent rotation and ignore scale;
they are read-only, but a vector copied from one can be changed normally.

The shared-runtime test opens the project, validates its scene, compiles both
scripts and steps until the walker reaches the goal, verifies the goal's facing
and checks the restored walker rotation and scale. Real Chromium repeats the
save/copy/restore milestones and inspects the changed rendered scene. This
demonstrates the transform API; the Explorer genre showcase will supply the gameplay proof
with models, collision and a character controller.

### `assets/transform.decay`

```rust
// A feature example: face a raised goal and move there, with an orbiting camera.
script Walker {
    @export
    let speed: number = 2.0;
    var arrived: bool = false;
    var home_rotation: Quat = Quat(x: 0.0, y: 0.0, z: 0.0, w: 1.0);

    fn start() {
        home_rotation = this.transform.rotation;
        print("Quaternion saved");
    }

    fn update(dt: number) {
        if arrived { return; }
        let goal = World.find("Goal");
        let distance = this.transform.world_position.distance(goal.transform.world_position);
        if distance <= 0.01 {
            let facing = this.transform.rotation;
            goal.transform.rotation = facing;
            this.transform.rotation = home_rotation;
            let copied = goal.transform.rotation;
            let restored = this.transform.rotation;
            let copy_error = abs(copied.x - facing.x) + abs(copied.y - facing.y)
                + abs(copied.z - facing.z) + abs(copied.w - facing.w);
            let restore_error = abs(restored.x - home_rotation.x) + abs(restored.y - home_rotation.y)
                + abs(restored.z - home_rotation.z) + abs(restored.w - home_rotation.w);
            if copy_error > 0.0001 || restore_error > 0.0001 {
                print("Quaternion mismatch");
                return;
            }
            print("Quaternion copied and restored");
            arrived = true;
            Game.set("arrived", 1.0);
            print("Arrived");
            return;
        }
        this.transform.look_at(goal.transform.world_position);
        this.transform.world_position += this.transform.forward * min(distance, speed * dt);
    }
}

script Orbit {
    fn start() {
        this.transform.look_at(Vec3(0.0, 1.0, 0.0));
    }
    fn update(dt: number) {
        this.transform.rotate_around(Vec3(0.0, 1.0, 0.0), Vec3(0.0, 1.0, 0.0), dt * 0.15);
    }
}
```

### `assets/transform.scene`

```json
{
  "format_version": 10,
  "metadata": {
    "name": "3D Transform Lab"
  },
  "entities": [
    {
      "id": "camera",
      "name": "Camera",
      "transform_3d": {
        "position": [9, 7, 11],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.camera": {
          "projection": "perspective",
          "vertical_fov_degrees": 50,
          "near": 0.1,
          "far": 100
        },
        "sindri.script": {
          "source": "transform.decay",
          "script": "Orbit"
        }
      }
    },
    {
      "id": "carrier",
      "name": "Carrier",
      "transform_3d": {
        "position": [1, 0, 1],
        "scale": [1.5, 1.5, 1.5]
      },
      "components": {}
    },
    {
      "id": "walker",
      "name": "Walker",
      "transform_3d": {
        "position": [-2, 0.5, 0],
        "scale": [0.5, 0.5, 0.8]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/walker.png"
        },
        "sindri.script": {
          "source": "transform.decay",
          "script": "Walker"
        }
      },
      "parent": "carrier"
    },
    {
      "id": "goal",
      "name": "Goal",
      "transform_3d": {
        "position": [2, 3, -3],
        "scale": [0.5, 0.5, 0.5]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/goal.png"
        }
      }
    },
    {
      "id": "floor",
      "name": "Floor",
      "transform_3d": {
        "position": [0, -0.3, 0],
        "scale": [12, 0.2, 12]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/floor.png"
        }
      }
    },
    {
      "id": "sun",
      "name": "Sun",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.light": {
          "kind": "directional",
          "color": [1, 0.95, 0.8],
          "intensity": 1.1
        }
      }
    }
  ]
}
```

### `sindri.toml`

```toml
format_version = 1
[project]
name = "3D Transform Lab"
main_scene = "assets/transform.scene"
```

### Binary files in this project

`assets/textures/floor.png`, `assets/textures/goal.png`, `assets/textures/walker.png`


---

## Example: 3D Orbit Camera Lab (`examples/orbit`)

Every text file of the project follows, whole, under its path relative to the project folder. JSON files are shown with short arrays on one line; whitespace does not matter to Sindri.

### 3D Orbit Camera Lab

A feature example for perspective FOV and engine-owned third-person orbit,
from slice 6 of the 3D update. A blue walker heads to a raised gold goal.
The gray barrier blocks the camera's requested six-unit orbit; the camera
pulls in immediately, keeps facing the walker, then eases back out when
Decay removes the barrier after two seconds.
Pages publishes it at `https://sindri.vardir.no/examples/orbit/`.

Open the project in the editor or run:

```sh
cargo run -p sindri-player -- examples/orbit
```

Read `assets/orbit.decay`. Gameplay chooses the target and orbit angles; the
shared runtime handles following, smoothing, aiming and obstruction queries.
`Camera.perspective_fov` changes projection without altering clipping planes.
The floor is on another collision layer, and the target's collider is excluded.
Hold the right mouse button and drag to look around the walker. The script
reads `Input.Pointer.delta`, applies sensitivity in radians per motion unit
(without `dt`), and clamps pitch to stay away from vertical. Without dragging,
the camera continues its slow automatic orbit. Click the view to capture the
cursor in the native/browser player or native editor Play: motion then continues beyond the window
edge without holding a button. Click again, press U or Escape, or leave the
window's focus to release. The console reports capture/release and nonzero
captured motion. Capture needs a player gesture in a browser and may be denied;
`Input.Pointer.locked` reports actual state. Native captured motion uses raw
device counts; browser motion uses physical pixels. In editor Play, hiding
the Game view, pausing/stopping, editing text or picking releases capture.
Press 1 to shrink the barrier to 0.02 units wide, 2 to restore its ten-unit
width. Press 3 to inspect the wall from the walker's far side without the
camera's automatic yaw, or R to restart automatic orbit and the wall's
two-second disappearance. Manual edits reactivate the barrier and keep it
visible. The script waits for synchronized
center and edge rays before reporting the new collision width: the center
must still hit the wall, while the ray at X=3 misses it only when narrow.
Touch look remains pending.

The project regression opens the scene, compiles all scripts and plays to its
goal while checking camera pull-in, recovery, facing and the live FOV. Native
and exported WebGPU Chromium captures show the blocked and recovered camera;
the browser run reaches the scripted goal. A real Chromium right-button drag
also reached Decay as exactly 60 pixels right and 40 down once, with the camera
response captured. This is feature evidence.
Explorer's model-based game proof remains pending. Native editor scale
editing, Undo/Redo, save/reopen and Play are recorded in
the scale review (`docs/collider-scale-editor-review.md` in the Sindri repository).
`scripts/browser/pointer-lock.mjs` exercises real browser capture, unbounded
motion, U/Escape release, sandbox denial, repeated runtime scale edits and the
project goal. Collision uses one sight-line ray, not a camera-volume sweep;
collider poses come from the last physics phase.

Native X11/Vulkan verification also runs the actual window to the goal, drives
raw motion beyond the viewport, releases with U/Escape/focus loss and checks
that returning focus does not recapture. Its screenshot was inspected. Other
native window platforms have not been visually verified in this slice.

Native editor Play verification opens this project, plays to Arrived, captures
from the Game view and injects motion beyond the viewport. The console reports
6,000 horizontal/120 vertical raw units, and the screenshot was inspected.
Actual X11 pointer-grab probes verify U/Escape/focus-loss/pause/stop/hidden-view
release; focus regain, resume and a new Play do not recapture automatically.
Editor input unit tests also cover raw counts at non-unit display scale,
ignored absolute warps, accumulation between steps and once-only consumption.

Collider dimensions are local units: each rendered cube uses half extents
`[0.5, 0.5, 0.5]`, and the scene applies its entity scale to physics. Changing
a barrier's scale updates both the visible wall and orbit collision at the next
fixed step. Explorer game adoption remains part of the 3D plan.

### `assets/orbit.decay`

```rust
// The camera stays on the walker's side of the barrier, then returns to its orbit.
script CameraRig {
    var yaw: number = 0.0;
    var pitch: number = 0.35;
    var was_locked: bool = false;
    var scale_pending: bool = false;
    var inspect_scale: bool = false;
    var barrier_width: number = 10.0;
    @export
    var sensitivity: number = 0.005;
    fn start() {
        let target = World.find("Walker");
        Camera.perspective_fov(this.entity, 65.0);
        Camera.orbit(this.entity, target, 0.0, 0.35, 6.0);
        Camera.orbit_offset(this.entity, Vec3(0.0, 0.5, 0.0));
        Camera.orbit_smoothing(this.entity, 8.0);
        Camera.orbit_collision(this.entity, 1.0, 0.2);
    }
    fn update(dt: number) {
        verify_scale();
        if Input.Keyboard.just_pressed("Digit1") { resize_barrier(0.02); }
        if Input.Keyboard.just_pressed("Digit2") { resize_barrier(10.0); }
        if Input.Keyboard.just_pressed("Digit3") {
            inspect_scale = true;
            yaw = 3.1415927;
            pitch = 0.35;
        }
        if Input.Keyboard.just_pressed("R") {
            inspect_scale = false;
            yaw = 0.0;
            pitch = 0.35;
            let barrier = World.find("Barrier");
            World.set_active(barrier, true);
            barrier.transform.scale.x = 10.0;
            Barrier.on(barrier).manual = false;
            Barrier.on(barrier).elapsed = 0.0;
            scale_pending = false;
            print("Barrier automatic");
        }
        if !inspect_scale { yaw += dt * 0.2; }
        if Input.Pointer.just_pressed("Left") && !Input.Pointer.over_ui && Input.Touch.count == 0.0 {
            if Input.Pointer.locked { Input.Pointer.unlock(); }
            else { Input.Pointer.lock(); }
        }
        if Input.Keyboard.just_pressed("U") { Input.Pointer.unlock(); }
        if Input.Pointer.locked != was_locked {
            was_locked = Input.Pointer.locked;
            if was_locked { print("Pointer captured"); }
            else { print("Pointer released"); }
        }
        if Input.Pointer.locked || (Input.Pointer.is_down("Right") && !Input.Pointer.over_ui) {
            let motion = Input.Pointer.delta;
            if Input.Pointer.locked && (motion.x != 0.0 || motion.y != 0.0) {
                print("Pointer motion " + motion.x + " " + motion.y);
            }
            yaw -= motion.x * sensitivity;
            pitch = clamp(pitch + motion.y * sensitivity, -1.2, 1.2);
        }
        Camera.orbit(this.entity, World.find("Walker"), yaw, pitch, 6.0);
    }
    fn resize_barrier(width: number) {
        let barrier = World.find("Barrier");
        World.set_active(barrier, true);
        Barrier.on(barrier).manual = true;
        barrier.transform.scale.x = width;
        barrier_width = width;
        scale_pending = true;
        if width < 1.0 { print("Barrier narrow"); }
        else { print("Barrier wide"); }
    }
    // Queries see the previous synchronized solve, so verify on later updates.
    fn verify_scale() {
        if !scale_pending { return; }
        let center = Physics3d.raycast(Vec3(0.0, 2.0, 6.0), Vec3(0.0, 0.0, -1.0), 10.0, 1.0, false, null);
        if center == null { return; }
        if center.entity != World.find("Barrier") { return; }
        let hit = Physics3d.raycast(Vec3(3.0, 2.0, 6.0), Vec3(0.0, 0.0, -1.0), 10.0, 1.0, false, null);
        if barrier_width < 1.0 {
            if hit == null {
                scale_pending = false;
                print("Barrier scale query narrow");
            }
        } else if hit != null {
            if hit.entity == World.find("Barrier") {
                scale_pending = false;
                print("Barrier scale query wide");
            }
        }
    }
}

script Walker {
    var arrived: bool = false;
    fn update(dt: number) {
        if arrived { return; }
        let goal = World.find("Goal");
        let distance = this.transform.world_position.distance(goal.transform.world_position);
        if distance <= 0.01 {
            arrived = true;
            print("Arrived");
            return;
        }
        this.transform.look_at(goal.transform.world_position);
        this.transform.world_position += this.transform.forward * min(distance, 0.8 * dt);
    }
}

script Barrier {
    var elapsed: number = 0.0;
    var manual: bool = false;
    fn update(dt: number) {
        if manual { return; }
        elapsed += dt;
        if elapsed >= 2.0 { World.set_active(this.entity, false); }
    }
}
```

### `assets/orbit.scene`

```json
{
  "format_version": 10,
  "metadata": {
    "name": "3D Orbit Camera Lab"
  },
  "entities": [
    {
      "id": "camera",
      "name": "Camera",
      "transform_3d": {
        "position": [0, 3, 6],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.camera": {
          "projection": "perspective",
          "vertical_fov_degrees": 50,
          "near": 0.1,
          "far": 100
        },
        "sindri.script": {
          "source": "orbit.decay",
          "script": "CameraRig"
        }
      }
    },
    {
      "id": "walker",
      "name": "Walker",
      "transform_3d": {
        "position": [-1, 0.5, -1],
        "scale": [0.5, 0.5, 0.8]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/walker.png"
        },
        "sindri.script": {
          "source": "orbit.decay",
          "script": "Walker"
        },
        "sindri.physics3d.collider": {
          "shape": {
            "shape": "box",
            "half_extents": [0.5, 0.5, 0.5]
          },
          "offset": [0, 0, 0],
          "rotation": [0, 0, 0, 1],
          "sensor": false,
          "layers": {
            "memberships": 1,
            "filter": 4294967295
          },
          "friction": 0.5,
          "restitution": 0
        }
      }
    },
    {
      "id": "goal",
      "name": "Goal",
      "transform_3d": {
        "position": [1, 2, -3],
        "scale": [0.4, 0.4, 0.4]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/goal.png"
        }
      }
    },
    {
      "id": "barrier",
      "name": "Barrier",
      "transform_3d": {
        "position": [0, 2, 1],
        "scale": [10, 4, 0.4]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/floor.png"
        },
        "sindri.physics3d.collider": {
          "shape": {
            "shape": "box",
            "half_extents": [0.5, 0.5, 0.5]
          },
          "offset": [0, 0, 0],
          "rotation": [0, 0, 0, 1],
          "sensor": false,
          "layers": {
            "memberships": 1,
            "filter": 4294967295
          },
          "friction": 0.5,
          "restitution": 0
        },
        "sindri.script": {
          "source": "orbit.decay",
          "script": "Barrier"
        }
      }
    },
    {
      "id": "floor",
      "name": "Floor",
      "transform_3d": {
        "position": [0, -0.3, 0],
        "scale": [14, 0.2, 14]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/floor.png"
        },
        "sindri.physics3d.collider": {
          "shape": {
            "shape": "box",
            "half_extents": [0.5, 0.5, 0.5]
          },
          "offset": [0, 0, 0],
          "rotation": [0, 0, 0, 1],
          "sensor": false,
          "layers": {
            "memberships": 2,
            "filter": 4294967295
          },
          "friction": 0.5,
          "restitution": 0
        }
      }
    },
    {
      "id": "sun",
      "name": "Sun",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.light": {
          "kind": "directional",
          "color": [1, 0.95, 0.8],
          "intensity": 1.1
        }
      }
    }
  ]
}
```

### `sindri.toml`

```toml
format_version = 1
[project]
name = "3D Orbit Camera Lab"
main_scene = "assets/orbit.scene"
```

### Binary files in this project

`assets/textures/floor.png`, `assets/textures/goal.png`, `assets/textures/walker.png`


---

## Example: 3D Character Lab (`examples/character3d`)

Every text file of the project follows, whole, under its path relative to the project folder. JSON files are shown with short arrays on one line; whitespace does not matter to Sindri.

### 3D Character Lab

A feature example for scene-owned 3D character movement, added for the planned
Explorer showcase. Gravity, jumping, movement speed, ferry reversal and the goal
are Decay gameplay. The shared engine clips displacement and supplies classified
support and solved platform carry; it contains no lab-specific gameplay.
Pages publishes it at `https://sindri.vardir.no/examples/character3d/`.

Run `cargo run -p sindri-player -- examples/character3d`, or open `sindri.toml`
in the editor and enter Play. The blue cube starts aboard a gold ferry. Ride it,
press Space to jump off, then hold D to reach the thin gold goal pad. A/D move
along X and W/S along Z. Landing on the gray floor after riding and jumping is
required before reaching the goal. Console messages report each milestone.
Tune the Walker's exported `speed`, `gravity` and `jump_speed` in the scene.

The request made during a script update is applied after the next physics solve.
Jump permission comes from classified `motion.grounded`, rather than the raw
Rapier contact flag. The jump step includes the last pre-solve support carry;
the following airborne step has no carry. Snap is enabled only while grounded
and descending. Collision normals cancel upward intent at a ceiling.

The native Session tests open this project, compile its scripts, verify idle
carry through reversal, and use keyboard input to jump, land and reach the goal:

```sh
cargo test -p sindri-runtime --all-features --test a_3d_character_project_plays
```

Export with `sindri-export` and install the generic player's WebAssembly package
in the export's `pkg/`, as in CI. The real Chromium check uses ordinary keys,
checks gameplay milestones and GPU/runtime errors, and captures the rendered goal:

```sh
SINDRI_BASE_PATH=/examples/character3d/ node scripts/browser/character3d.mjs \
  target/dist/character3d target/character3d.png
```

This is native Session, browser and editor feature proof. The
native editor review (`docs/physics-character3d-editor-review.md` in the Sindri repository) exercises
selected footing guides, settings/undo/save/reopen and keyboard Play to the goal.
The Explorer game's goal remains a separate acceptance item. Imported models,
slopes, steps and terrain residency are not demonstrated by this small project.

### `assets/character.decay`

```rust
// Gameplay owns gravity, jump permission and speed. The engine clips each move.
script Walker {
    @export
    var speed: number = 3.0;
    @export
    var gravity: number = 12.0;
    @export
    var jump_speed: number = 5.0;
    var rise: number = 0.0;
    var carried: bool = false;
    var jumped: bool = false;
    var landed: bool = false;
    var arrived: bool = false;
    fn start() {
        print("Character lab ready: ride, Space to jump, D to reach the gold pad");
    }
    fn update(dt: number) {
        let motion = Physics3d.character_motion(this.entity);
        var grounded = false;
        if motion != null {
            grounded = motion.grounded;
            let platform = motion.platform;
            if platform != null {
                if platform.motion.translation.x > 0.0 && !carried {
                    carried = true;
                    print("Character carried");
                }
            }
            if grounded && rise <= 0.0 { rise = 0.0; }
            for collision in motion.movement.collisions {
                if collision.hit.normal.y < -0.5 && rise > 0.0 { rise = 0.0; }
            }
            let support = motion.ground;
            if support != null {
                if grounded && support.entity == World.find("Floor") && jumped && !landed {
                    landed = true;
                    print("Character landed on floor");
                }
            }
        }
        if grounded && Input.Keyboard.just_pressed("Space") {
            rise = jump_speed;
            jumped = true;
            print("Character jumped");
        }
        var horizontal = 0.0;
        var depth = 0.0;
        if Input.Keyboard.is_down("D") { horizontal += speed; }
        if Input.Keyboard.is_down("A") { horizontal -= speed; }
        if Input.Keyboard.is_down("S") { depth += speed; }
        if Input.Keyboard.is_down("W") { depth -= speed; }
        if carried && jumped && landed && !arrived {
            let delta = this.transform.world_position - World.find("Goal").transform.world_position;
            if abs(delta.x) < 0.6 && abs(delta.z) < 0.8 {
                arrived = true;
                print("Character goal reached");
            }
        }
        rise = max(-15.0, rise - gravity * dt);
        Physics3d.move_character(this.entity, Vec3(horizontal * dt, rise * dt, depth * dt), grounded && rise <= 0.0);
    }
}

script Ferry {
    fn update(dt: number) {
        let x = this.transform.world_position.x;
        if x >= 2.0 { Physics3d.set_velocity(this.entity, Vec3(-1.0, 0.0, 0.0)); }
        if x <= -1.0 { Physics3d.set_velocity(this.entity, Vec3(1.0, 0.0, 0.0)); }
    }
}

script CameraRig {
    fn start() {
        Camera.orbit(this.entity, World.find("Walker"), 0.0, 0.45, 8.0);
        Camera.orbit_offset(this.entity, Vec3(0.0, 0.3, 0.0));
        Camera.orbit_smoothing(this.entity, 8.0);
    }
}
```

### `assets/character.scene`

```json
{
  "format_version": 10,
  "metadata": {
    "name": "3D Character Lab"
  },
  "entities": [
    {
      "id": "camera",
      "name": "Camera",
      "transform_3d": {
        "position": [0, 3, 6],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.camera": {
          "projection": "perspective",
          "vertical_fov_degrees": 50,
          "near": 0.1,
          "far": 100
        },
        "sindri.script": {
          "source": "character.decay",
          "script": "CameraRig"
        }
      }
    },
    {
      "id": "walker",
      "name": "Walker",
      "transform_3d": {
        "position": [0, 1.31, 0],
        "scale": [0.6, 0.6, 0.6]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/walker.png"
        },
        "sindri.physics3d.collider": {
          "pieces": [
            {
              "shape": {
                "shape": "box",
                "half_extents": [0.5, 0.5, 0.5]
              },
              "offset": [0, 0, 0],
              "rotation": [0, 0, 0, 1],
              "sensor": false,
              "layers": {
                "memberships": 1,
                "filter": 4294967295
              },
              "friction": 0.5,
              "restitution": 0
            }
          ]
        },
        "sindri.physics3d.character": {
          "snap_distance": 0.15
        },
        "sindri.script": {
          "source": "character.decay",
          "script": "Walker"
        }
      }
    },
    {
      "id": "ferry",
      "name": "Ferry",
      "transform_3d": {
        "position": [0, 0.5, 0],
        "scale": [2, 1, 2]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/goal.png"
        },
        "sindri.physics3d.collider": {
          "pieces": [
            {
              "shape": {
                "shape": "box",
                "half_extents": [0.5, 0.5, 0.5]
              },
              "offset": [0, 0, 0],
              "rotation": [0, 0, 0, 1],
              "sensor": false,
              "layers": {
                "memberships": 1,
                "filter": 4294967295
              },
              "friction": 0.5,
              "restitution": 0
            }
          ]
        },
        "sindri.script": {
          "source": "character.decay",
          "script": "Ferry"
        },
        "sindri.physics3d.rigid_body": {
          "kind": "kinematic_velocity",
          "position": [0, 0, 0],
          "rotation": [0, 0, 0, 1],
          "linear_velocity": [1, 0, 0],
          "angular_velocity": [0, 0, 0],
          "gravity_scale": 1,
          "linear_damping": 0,
          "angular_damping": 0,
          "lock_rotation": false
        }
      }
    },
    {
      "id": "goal",
      "name": "Goal",
      "transform_3d": {
        "position": [5, 0.1, 0],
        "scale": [1, 0.2, 2]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/goal.png"
        }
      }
    },
    {
      "id": "floor",
      "name": "Floor",
      "transform_3d": {
        "position": [2, -0.25, 0],
        "scale": [18, 0.5, 8]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/floor.png"
        },
        "sindri.physics3d.collider": {
          "pieces": [
            {
              "shape": {
                "shape": "box",
                "half_extents": [0.5, 0.5, 0.5]
              },
              "offset": [0, 0, 0],
              "rotation": [0, 0, 0, 1],
              "sensor": false,
              "layers": {
                "memberships": 1,
                "filter": 4294967295
              },
              "friction": 0.5,
              "restitution": 0
            }
          ]
        }
      }
    },
    {
      "id": "sun",
      "name": "Sun",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.light": {
          "kind": "directional",
          "color": [1, 0.95, 0.8],
          "intensity": 1.1
        }
      }
    }
  ]
}
```

### `sindri.toml`

```toml
format_version = 1
[project]
name = "3D Character Lab"
main_scene = "assets/character.scene"
```

### Binary files in this project

`assets/textures/floor.png`, `assets/textures/goal.png`, `assets/textures/walker.png`


---

## Example: 3D Lighting Lab (`examples/lighting3d`)

Every text file of the project follows, whole, under its path relative to the project folder. JSON files are shown with short arrays on one line; whitespace does not matter to Sindri.

### 3D Lighting Lab

A feature example for Explorer's planned lighting track. It uses the existing
Benchmark's cube, pyramid and GLB assets. No in-tree game uses these controls yet.
Open this project in the editor or `sindri-player examples/lighting3d`.
Pages publishes it at `https://sindri.vardir.no/examples/lighting3d/`.

Two orange panels face away from the camera. `Double-sided panel` shows its back
with the same lighting as other geometry; `Single-sided panel` is hidden. Select
either panel's Material and change `sidedness` between `inherit`, `single` and
`double` to compare visibility, then Undo/Redo. The single-sided capture control
changes only the visible panel's policy. This is also feature proof for the
Explorer material track; shared material assets and alpha modes remain open.

The Camera's Decay script assigns warm/blue colors, intensity, range and spot
half-angles to lights authored black, dark and disabled. During the first three
seconds it changes ambient intensity/color and the background from cool to warm,
alternates the point and spot, turns/tilts their scaled carrier and rotates
the directional sun. At the ready goal it restores the carrier and original
ambient/background, keeping the lights on. Setting its exported `lit` to false
reaches the same goal with every light disabled; the entity and script continue
running. Scene ambient fill is
restored at the goal, so final native/browser control images isolate the
script's illumination.

The goal test opens the actual project and steps its shared Session, observing
both alternating phases, extracted ambient/clear colors, composed positions and
directions, updated sun state, retained entity activity and the final enabled/
disabled controls.
Typed checking and LSP metadata use the same host surface. Native/editor and
real WebGPU captures are feature evidence, with game adoption still open.

Three `materials/*.profile` assets now provide shared factors and sidedness for
six objects. `polished.profile` drives a cube and a GLB; changing its emission
updates both. The two back-facing panels share `orange.profile`, with an explicit
local Single override on the hidden panel. Use **New render material** in the
project browser to create another; select it in an entity's Material profile
picker. The structured profile inspector validates Save, and the scene picker
uses normal Undo/Redo. Texture ownership remains with each mesh/model.

The native/WebGPU controls include `prepare-lighting3d.py --inline-materials`
(equal pixels with inline values) and `--green-materials` (one shared profile
edit changes two visible surfaces). Neither changes gameplay scripts or the goal.

### `assets/lighting.decay`

```rust
// Feature example: scripts own the light clock; engine properties own shading.
script Lighting {
    @export let lit: bool = true;
    var elapsed: number = 0.0;
    var ready: bool = false;
    var spot_phase: bool = false;

    fn start() {
        this.transform.look_at(Vec3(0.0, 0.0, 0.0));
        let spot = World.find("Warm spotlight");
        spot.light.color = Color(1.0, 0.12, 0.025);
        spot.light.intensity = 24.0;
        spot.light.range = 6.0;
        spot.light.outer_angle = 0.75;
        spot.light.inner_angle = 0.35;
        let point = World.find("Blue point");
        point.light.color = Color(0.025, 0.15, 1.0);
        point.light.intensity = 24.0;
        point.light.range = 6.0;
        let sun = World.find("Sun");
        sun.light.color = Color(1.0, 0.95, 0.86);
        sun.light.intensity = 0.04;
        sun.light.enabled = true;
        let environment = World.find("World");
        environment.environment.ambient_intensity = 0.15;
        environment.environment.ambient_color = Color(0.1, 0.2, 1.0);
        environment.environment.background = Color(0.015, 0.025, 0.12);
        print("3D lighting loaded");
    }
    fn update(dt: number) {
        if ready { return; }
        elapsed += dt;
        let rig = World.find("Light carrier");
        rig.transform.position.x = sin(elapsed * 6.2831853) * 0.3;
        rig.transform.yaw = 1.5707963 + sin(elapsed * 6.2831853) * 0.25;
        rig.transform.pitch = sin(elapsed * 6.2831853) * 0.15;
        World.find("Sun").transform.yaw += dt * 0.25;
        World.find("Warm spotlight").light.enabled = elapsed >= 1.0;
        World.find("Blue point").light.enabled = elapsed < 1.0 || elapsed >= 2.0;
        if elapsed >= 1.0 && !spot_phase {
            spot_phase = true;
            let environment = World.find("World");
            environment.environment.ambient_intensity = 0.3;
            environment.environment.ambient_color = Color(1.0, 0.25, 0.1);
            environment.environment.background = Color(0.12, 0.025, 0.015);
            print("3D lighting spot phase");
        }
        if elapsed >= 3.0 {
            rig.transform.position.x = 0.0;
            rig.transform.yaw = 1.5707963;
            rig.transform.pitch = 0.0;
            World.find("Warm spotlight").light.enabled = lit;
            World.find("Blue point").light.enabled = lit;
            World.find("Sun").light.enabled = lit;
            let environment = World.find("World");
            environment.environment.ambient_intensity = 0.025;
            environment.environment.ambient_color = Color(1.0, 1.0, 1.0);
            environment.environment.background = Color(0.035, 0.045, 0.065);
            ready = true;
            print("3D lighting ready");
        }
    }
}
```

### `assets/lighting.scene`

```json
{
  "format_version": 10,
  "metadata": {
    "name": "3D Lighting Lab"
  },
  "entities": [
    {
      "id": "Camera",
      "name": "Camera",
      "transform_3d": {
        "position": [8, 9, 12],
        "scale": [1, 1, 1],
        "rotation": [-0.2635350713876335, 0.2785822852913061, 0.07979199930413745, 0.9200947849638301]
      },
      "components": {
        "sindri.camera": {
          "projection": "perspective",
          "vertical_fov_degrees": 35,
          "near": 0.1,
          "far": 200
        },
        "sindri.script": {
          "source": "lighting.decay",
          "script": "Lighting",
          "properties": {
            "lit": true
          }
        }
      }
    },
    {
      "id": "Sun",
      "name": "Sun",
      "transform_3d": {
        "position": [0, 20, 0],
        "scale": [1, 1, 1],
        "rotation": [-0.2635350713876335, 0.2785822852913061, 0.07979199930413745, 0.9200947849638301]
      },
      "components": {
        "sindri.light": {
          "kind": "directional",
          "intensity": 0,
          "color": [0, 0, 0],
          "enabled": false
        }
      }
    },
    {
      "id": "World",
      "name": "World",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.environment": {
          "ambient_intensity": 0.025,
          "shadows": {
            "enabled": true,
            "distance": 20,
            "map_size": 1024,
            "bias": 0.001
          }
        }
      }
    },
    {
      "id": "Floor",
      "name": "Floor",
      "transform_3d": {
        "position": [0, -0.1, 0],
        "scale": [12, 0.2, 8]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/floor.png"
        }
      }
    },
    {
      "id": "Swatch-0",
      "name": "Swatch-0",
      "transform_3d": {
        "position": [-2.5, 0.75, -1.5],
        "scale": [1.3, 1.5, 1.3]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/cube.png"
        },
        "sindri.material": {
          "profile": "materials/dielectric.profile"
        }
      }
    },
    {
      "id": "Swatch-1",
      "name": "Swatch-1",
      "transform_3d": {
        "position": [0.0, 0.75, -1.5],
        "scale": [1.3, 1.5, 1.3]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/cube.png"
        },
        "sindri.material": {
          "base_color": [0.7, 0.7, 0.7, 1],
          "metallic": 1.0,
          "roughness": 1.0,
          "emissive": [0, 0, 0]
        }
      }
    },
    {
      "id": "Swatch-2",
      "name": "Swatch-2",
      "transform_3d": {
        "position": [2.5, 0.75, -1.5],
        "scale": [1.3, 1.5, 1.3]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/cube.png"
        },
        "sindri.material": {
          "profile": "materials/polished.profile"
        }
      }
    },
    {
      "id": "Swatch-4",
      "name": "Swatch-4",
      "transform_3d": {
        "position": [0.0, 0.75, 1.5],
        "scale": [1.3, 1.5, 1.3]
      },
      "components": {
        "sindri.model": {
          "asset": "models/prop.glb"
        },
        "sindri.material": {
          "profile": "materials/polished.profile"
        }
      }
    },
    {
      "id": "Swatch-5",
      "name": "Swatch-5",
      "transform_3d": {
        "position": [2.5, 0.75, 1.5],
        "scale": [1.3, 1.5, 1.3]
      },
      "components": {
        "sindri.model": {
          "asset": "models/prop.glb"
        },
        "sindri.material": {
          "profile": "materials/dielectric.profile"
        }
      }
    },
    {
      "id": "light-carrier",
      "name": "Light carrier",
      "disabled": false,
      "transform_3d": {
        "position": [0, 0, 0],
        "rotation": [0, 0.70710677, 0, 0.70710677],
        "scale": [2, 2, 2]
      }
    },
    {
      "id": "Warm-point",
      "name": "Warm spotlight",
      "parent": "light-carrier",
      "transform_3d": {
        "position": [0.5, 1.5, -1.25],
        "rotation": [-0.70710677, 0, 0, 0.70710677]
      },
      "components": {
        "sindri.light": {
          "kind": "spot",
          "color": [0, 0, 0],
          "intensity": 0,
          "range": 6,
          "enabled": false
        }
      }
    },
    {
      "id": "Blue-point",
      "name": "Blue point",
      "parent": "light-carrier",
      "transform_3d": {
        "position": [-0.5, 1.5, 1.25]
      },
      "components": {
        "sindri.light": {
          "kind": "point",
          "color": [0, 0, 0],
          "intensity": 0,
          "range": 6,
          "enabled": false
        }
      }
    },
    {
      "id": "Swatch-3",
      "name": "Swatch-3",
      "transform_3d": {
        "position": [-2.5, 0.75, 1.5],
        "scale": [1.3, 1.5, 1.3]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "surface",
          "texture": "textures/surface.png",
          "surface": {
            "vertices": [
              [-0.5, -0.5, -0.5],
              [0.5, -0.5, -0.5],
              [0.5, -0.5, 0.5],
              [-0.5, -0.5, 0.5],
              [0, 0.5, 0]
            ],
            "uvs": [
              [0, 0],
              [1, 0],
              [1, 1],
              [0, 1],
              [0.5, 0.5]
            ],
            "indices": [0, 1, 2, 0, 2, 3, 0, 4, 1, 1, 4, 2, 2, 4, 3, 3, 4, 0]
          }
        },
        "sindri.material": {
          "base_color": [0.7, 0.7, 0.7, 1],
          "metallic": 0.0,
          "roughness": 0.55,
          "emissive": [0, 0, 0]
        }
      }
    },
    {
      "id": "Backface-single",
      "name": "Single-sided panel",
      "transform_3d": {
        "position": [-1.7, 0.9, 3.0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "surface",
          "texture": "textures/cube.png",
          "surface": {
            "vertices": [
              [-0.7, -0.8, 0],
              [0.7, -0.8, 0],
              [0.7, 0.8, 0],
              [-0.7, 0.8, 0]
            ],
            "uvs": [
              [0, 0],
              [1, 0],
              [1, 1],
              [0, 1]
            ],
            "indices": [0, 2, 1, 0, 3, 2],
            "normals": [
              [0, 0, -1],
              [0, 0, -1],
              [0, 0, -1],
              [0, 0, -1]
            ]
          }
        },
        "sindri.material": {
          "profile": "materials/orange.profile",
          "override_sidedness": true,
          "sidedness": "single"
        }
      }
    },
    {
      "id": "Backface-double",
      "name": "Double-sided panel",
      "transform_3d": {
        "position": [-3.6, 0.9, 3.0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "surface",
          "texture": "textures/cube.png",
          "surface": {
            "vertices": [
              [-0.7, -0.8, 0],
              [0.7, -0.8, 0],
              [0.7, 0.8, 0],
              [-0.7, 0.8, 0]
            ],
            "uvs": [
              [0, 0],
              [1, 0],
              [1, 1],
              [0, 1]
            ],
            "indices": [0, 2, 1, 0, 3, 2],
            "normals": [
              [0, 0, -1],
              [0, 0, -1],
              [0, 0, -1],
              [0, 0, -1]
            ]
          }
        },
        "sindri.material": {
          "profile": "materials/orange.profile"
        }
      }
    }
  ]
}
```

### `assets/materials/dielectric.profile`

```json
{
  "format_version": 1,
  "name": "Chalk",
  "type": "render_material",
  "values": {
    "base_color": [0.7, 0.7, 0.7, 1],
    "metallic": 0.0,
    "roughness": 1.0,
    "emissive": [0, 0, 0],
    "sidedness": "inherit"
  }
}
```

### `assets/materials/orange.profile`

```json
{
  "format_version": 1,
  "name": "Orange panel",
  "type": "render_material",
  "values": {
    "base_color": [0.85, 0.4, 0.06, 1],
    "metallic": 0,
    "roughness": 0.7,
    "emissive": [0.08, 0.025, 0.002],
    "sidedness": "double"
  }
}
```

### `assets/materials/polished.profile`

```json
{
  "format_version": 1,
  "name": "Polished metal",
  "type": "render_material",
  "values": {
    "base_color": [0.7, 0.7, 0.7, 1],
    "metallic": 1.0,
    "roughness": 0.15,
    "emissive": [0, 0, 0],
    "sidedness": "inherit"
  }
}
```

### `sindri.toml`

```toml
format_version = 1
[project]
name = "3D Lighting Lab"
main_scene = "assets/lighting.scene"
```

### Binary files in this project

`assets/models/prop.glb`, `assets/textures/cube.png`, `assets/textures/floor.png`, `assets/textures/surface.png`


---

## Example: Model Collision Lab (`examples/model-collision`)

Every text file of the project follows, whole, under its path relative to the project folder. JSON files are shown with short arrays on one line; whitespace does not matter to Sindri.

### Model Collision Lab

A feature example of static and dynamic convex collision from imported GLBs. One model contains
both the green ground and the orange wall; the same asset is referenced by
`sindri.model` and `sindri.physics3d.model_collider`. The shared player prepares
collision before starting gameplay after asset loading completes (asynchronously in the browser).
Pages publishes it at `https://sindri.vardir.no/examples/model-collision/`.

Run `cargo run -p sindri-player -- examples/model-collision`. Hold D until the
blue walker hits the wall, S to go around its end, D to cross, then W to reach
the gold pad. Decay owns the input, downward movement and goal; the controller
clips motion against the imported triangles. The console reports obstruction,
bypass, crossing and goal completion.

`models/generate.py` reproduces the self-contained static GLB using Python's
standard library. Its floor and wall are two triangle primitives with normals,
indices and materials, with no inline scene collision vertices. It is authored
fixture geometry rather than an art showcase.

The player's regression opens this project, compiles its script and plays the
keyboard route to its goal through Session using the same installation helper
as the native/browser host. `scripts/browser/model-collision.mjs` plays the
exported project with keyboard events, verifies the GLB fetch and all milestones,
and captures the result without changing world state.

Editor Play and native headless `ProjectRun` also install collision before
stepping. The editor/headless parity test follows the same route with matching
world state, restores the initial world and replays wall obstruction. A native editor review (`docs/model-collision-editor-review.md` in the Sindri repository) verifies
Play goal, valid/invalid model reloads, Save refusal and Stop restoration.
A purple volumetric prop is spawned from `prefabs/prop.prefab` and is rendered
and collides from `models/prop.glb`. The camera script queues typed velocity,
impulse and locked angular motion immediately after spawning, and verifies
pending reads before its first solve. A Session regression checks materialization
and checkpoint replay with actual hull mass.
It falls onto the imported floor; Decay observes landing, applies one impulse,
and reports its travel goal. `models/generate-prop.py` reproduces that GLB.
The native player and editor/headless parity regressions check this goal beside
the walker goal, and the browser smoke requires both GLB fetches and both goals.
Editor Add Convex Model Collider uses project models, permits body/primitive
compounds, and withholds incompatible owners. Reload preparation is atomic when
one GLB supplies both triangle and convex collision. Explorer game adoption
remains separate work; this example is feature proof.

The floor and spawned prop share `materials/slide.profile`, using the same
`physics_material` asset format as 2D. Their `sindri.physics3d.material` components
apply its friction and explicitly override restitution to zero. Coefficients
reload without replacing model collision or resetting prop motion. The runtime
regression changes the shared profile and restores a checkpoint while keeping
the latest host asset; the exported-browser smoke requires the profile fetch.

### `assets/level.scene`

```json
{
  "format_version": 10,
  "metadata": {
    "name": "Model Collision Lab"
  },
  "entities": [
    {
      "id": "camera",
      "name": "Camera",
      "transform_3d": {
        "position": [4, 6, 10],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.camera": {
          "projection": "perspective",
          "vertical_fov_degrees": 50,
          "near": 0.1,
          "far": 100
        },
        "sindri.script": {
          "source": "walker.decay",
          "script": "CameraRig",
          "properties": {
            "prop_prefab": "prefabs/prop.prefab"
          }
        }
      }
    },
    {
      "id": "level",
      "name": "Level",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.model": {
          "asset": "models/level.glb"
        },
        "sindri.physics3d.model_collider": {
          "asset": "models/level.glb"
        },
        "sindri.physics3d.material": {
          "profile": "materials/slide.profile",
          "override_restitution": true,
          "restitution": 0.0
        }
      }
    },
    {
      "id": "walker",
      "name": "Walker",
      "transform_3d": {
        "position": [-2, 0.31, 0],
        "scale": [0.6, 0.6, 0.6]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/walker.png"
        },
        "sindri.physics3d.collider": {
          "pieces": [
            {
              "shape": {
                "shape": "box",
                "half_extents": [0.5, 0.5, 0.5]
              },
              "offset": [0, 0, 0],
              "rotation": [0, 0, 0, 1],
              "sensor": false,
              "layers": {
                "memberships": 1,
                "filter": 4294967295
              },
              "friction": 0.5,
              "restitution": 0
            }
          ]
        },
        "sindri.physics3d.character": {
          "snap_distance": 0.15
        },
        "sindri.script": {
          "source": "walker.decay",
          "script": "Walker"
        }
      }
    },
    {
      "id": "goal",
      "name": "Goal",
      "transform_3d": {
        "position": [2, 0.05, 0],
        "scale": [1, 0.1, 1]
      },
      "components": {
        "sindri.mesh": {
          "primitive": "cube",
          "texture": "textures/goal.png"
        }
      }
    },
    {
      "id": "sun",
      "name": "Sun",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.light": {
          "kind": "directional",
          "color": [1, 0.95, 0.8],
          "intensity": 1.1
        }
      }
    },
    {
      "id": "physics",
      "name": "Physics",
      "components": {
        "sindri.physics3d.world": {
          "gravity": [0, -9.81, 0]
        }
      }
    }
  ]
}
```

### `assets/materials/slide.profile`

```json
{
  "format_version": 1,
  "name": "Slide",
  "type": "physics_material",
  "values": {
    "friction": 0.3,
    "restitution": 0.4
  }
}
```

### `assets/prefabs/prop.prefab`

```json
{
  "format_version": 1,
  "entities": [
    {
      "id": "prop",
      "name": "Prop",
      "transform_3d": {
        "position": [-3.5, 3, -2],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.model": {
          "asset": "models/prop.glb"
        },
        "sindri.physics3d.model_convex_collider": {
          "asset": "models/prop.glb",
          "friction": 0.5
        },
        "sindri.physics3d.rigid_body": {
          "kind": "dynamic",
          "position": [0, 0, 0],
          "rotation": [0, 0, 0, 1],
          "linear_velocity": [0, 0, 0],
          "angular_velocity": [0, 0, 0],
          "gravity_scale": 1,
          "linear_damping": 0,
          "angular_damping": 0,
          "lock_rotation": true
        },
        "sindri.script": {
          "source": "walker.decay",
          "script": "Prop"
        },
        "sindri.physics3d.material": {
          "profile": "materials/slide.profile",
          "override_restitution": true,
          "restitution": 0.0
        }
      }
    }
  ]
}
```

### `assets/walker.decay`

```rust
// The model supplies collision; Decay owns movement, gravity and the goal.
script Walker {
    var blocked: bool = false;
    var arrived: bool = false;
    var around: bool = false;
    var across: bool = false;
    fn start() {
        print("Model collision ready: D into the wall, S around it, D then W to the gold pad");
    }
    fn update(dt: number) {
        let motion = Physics3d.character_motion(this.entity);
        var grounded = false;
        if motion != null {
            grounded = motion.grounded;
            for collision in motion.movement.collisions {
                if collision.hit.entity == World.find("Level") && collision.hit.normal.x < -0.5 && !blocked {
                    blocked = true;
                    print("Model wall blocked");
                }
            }
        }
        var x = 0.0;
        var z = 0.0;
        if Input.Keyboard.is_down("D") { x += 3.0; }
        if Input.Keyboard.is_down("A") { x -= 3.0; }
        if Input.Keyboard.is_down("S") { z += 3.0; }
        if Input.Keyboard.is_down("W") { z -= 3.0; }
        let position = this.transform.world_position;
        if blocked && position.z > 1.8 && !around {
            around = true;
            print("Model wall bypass ready");
        }
        if around && position.x > 1.8 && !across {
            across = true;
            print("Model across wall");
        }
        let delta = this.transform.world_position - World.find("Goal").transform.world_position;
        if blocked && grounded && abs(delta.x) < 0.5 && abs(delta.z) < 0.5 && !arrived {
            arrived = true;
            print("Model goal reached");
        }
        Physics3d.move_character(this.entity, Vec3(x * dt, -4.0 * dt, z * dt), grounded);
    }
}

script CameraRig {
    @export let prop_prefab: Prefab;
    fn start() {
        this.transform.look_at(Vec3(0.0, 0.0, 0.0));
        let prop = World.spawn(this.prop_prefab);
        Physics3d.set_velocity(prop, Vec3(0.5, 0.0, 0.0));
        Physics3d.apply_impulse(prop, Vec3(0.5, 0.0, 0.0));
        Physics3d.set_angular_velocity(prop, Vec3(0.0, 1.0, 0.0));
        let linear = Physics3d.velocity(prop);
        let angular = Physics3d.angular_velocity(prop);
        if abs(linear.x - 0.5) < 0.001 && abs(angular.y) < 0.001 {
            print("Model spawn controls queued");
        }
    }
}


// A falling imported prop: the solved pose triggers one Decay impulse, then
// its travel proves that the hull is a real dynamic body on the shared clock.
script Prop {
    var pushed: bool = false;
    var arrived: bool = false;
    fn update(dt: number) {
        let position = this.transform.world_position;
        if !pushed {
            for other in Physics3d.collision_started() {
                if other == World.find("Level") {
                    pushed = true;
                    Physics3d.apply_impulse(this.entity, Vec3(3.0, 0.0, 0.0));
                    print("Model prop landed and pushed");
                }
            }
        }
        if pushed && !arrived && position.x > -2.3 {
            arrived = true;
            print("Model prop goal reached");
        }
    }
}
```

### `sindri.toml`

```toml
format_version = 1
[project]
name = "Model Collision Lab"
main_scene = "assets/level.scene"
```

### Binary files in this project

`assets/models/generate-prop.py`, `assets/models/generate.py`, `assets/models/level.glb`, `assets/models/prop.glb`, `assets/textures/goal.png`, `assets/textures/walker.png`


---

## Example: Day/Night Lab (`examples/daynight`)

Every text file of the project follows, whole, under its path relative to the project folder. JSON files are shown with short arrays on one line; whitespace does not matter to Sindri.

### Day/Night Lab

A feature example for Explorer and external Low Tide 3D's planned lighting
track. This is feature proof, with in-tree game adoption still open. Geometry
reuses Model Collision Lab's two self-contained GLBs: a green ground/wall and a
purple prop. No engine time-of-day service or game-specific Rust is involved.
Pages publishes it at `https://sindri.vardir.no/examples/daynight/`.

Open `examples/daynight` in the editor, or run `sindri-player examples/daynight`.
The Camera's Decay script runs a twelve-second day, starting at 06:00. It blends
sun brightness/color, ambient fill and background through sunrise, midday,
sunset and night. The sun rotates, and the purple GLB travels and turns with a
work spotlight and blue point light attached. At night the sun is disabled and
the mounted lights take over. A ready goal reports the first completed day; the
clock, carrier and lighting continue afterward.

The Camera's exported properties configure the project:

| Property | Meaning |
| --- | --- |
| `day_seconds` | Seconds per day, clamped to at least one |
| `start_hour` | Starting hour, wrapping over 24 hours |
| `preview_hour` | A nonnegative value freezes the clock at that hour; -1 plays |
| `move_carrier` | Move/turn the imported prop and its lights |
| `night_lights` | Enable night illumination independently of entity activity |

The project test opens the actual files, compiles the script, plays two clock
configurations through all four phases and verifies the goal, continuing sun and
carrier motion, composed night-light positions and both imported render commands.
Frozen-hour controls keep every model stationary: 10:00/14:00 shadow on/off pairs
isolate the changing sun direction on imported green ground, and midnight
lights on/off isolates mounted illumination with the sun off. Native live-cycle and WebGPU frozen-hour phase captures are retained by CI; native editor Play exercises the same clock.
Fog/post controls, skyboxes, local-light shadows and in-tree game proof are not
part of this example. See the evidence (in `09-3d.md`).

### `assets/daynight.decay`

```rust
// The clock and transitions belong to the project, not an engine time service.
script DayNight {
    @export let day_seconds: number = 12.0;
    @export let start_hour: number = 6.0;
    @export let preview_hour: number = -1.0;
    @export let move_carrier: bool = true;
    @export let night_lights: bool = true;
    var elapsed: number = 0.0;
    var phase: number = -1.0;
    var ready: bool = false;

    fn start() {
        this.transform.look_at(Vec3(0.0, 0.0, 0.0));
        print("Day/night loaded");
    }
    fn update(dt: number) {
        elapsed += dt;
        var hour = (max(0.0, start_hour) + elapsed * 24.0 / max(1.0, day_seconds)) % 24.0;
        if preview_hour >= 0.0 { hour = preview_hour % 24.0; }
        shade(hour);
        let carrier = World.find("Carrier");
        if move_carrier {
            carrier.transform.position.x = -2.0 + sin(elapsed * 0.8) * 1.5;
            carrier.transform.position.z = cos(elapsed * 0.8);
            carrier.transform.yaw = elapsed * 0.4;
        }
        announce(hour);
        if !ready && (elapsed >= max(1.0, day_seconds) || preview_hour >= 0.0) {
            ready = true;
            print("Day/night ready");
        }
    }
    fn shade(hour: number) {
        let daylight = max(0.0, sin((hour - 6.0) * 0.2617994));
        let sun = World.find("Sun");
        sun.transform.pitch = -0.15 - daylight * 0.9;
        sun.transform.yaw = -2.1 + (hour - 10.0) * 0.12;
        sun.light.color = Color(1.0, 0.25, 0.1).lerp(Color(1.0, 0.95, 0.86), daylight);
        sun.light.intensity = daylight * 1.6;
        sun.light.enabled = daylight > 0.001;
        let world = World.find("World");
        world.environment.ambient_intensity = 0.03 + daylight * 0.22;
        world.environment.ambient_color = Color(0.2, 0.3, 0.8).lerp(Color(1.0, 1.0, 1.0), daylight);
        let horizon = Color(0.3, 0.06, 0.02).lerp(Color(0.12, 0.32, 0.55), daylight);
        world.environment.background = Color(0.008, 0.012, 0.035).lerp(horizon, min(1.0, daylight * 4.0));
        let night = 1.0 - min(1.0, daylight * 4.0);
        let enabled = night_lights && night > 0.001;
        let spot = World.find("Work spot");
        spot.light.intensity = night * 24.0;
        spot.light.enabled = enabled;
        let point = World.find("Blue point");
        point.light.intensity = night * 14.0;
        point.light.enabled = enabled;
    }
    fn announce(hour: number) {
        var current: number = -1.0;
        if hour >= 7.0 && hour < 10.0 { current = 0.0; }
        if hour >= 12.0 && hour < 16.0 { current = 1.0; }
        if hour >= 17.0 && hour < 18.0 { current = 2.0; }
        if hour >= 20.0 || hour < 6.0 { current = 3.0; }
        if current == phase || current < 0.0 { return; }
        phase = current;
        if phase == 0.0 { print("Day/night sunrise"); }
        if phase == 1.0 { print("Day/night midday"); }
        if phase == 2.0 { print("Day/night sunset"); }
        if phase == 3.0 { print("Day/night night"); }
    }
}
```

### `assets/daynight.scene`

```json
{
  "format_version": 10,
  "metadata": {
    "name": "Day/Night Lab"
  },
  "entities": [
    {
      "id": "Camera",
      "name": "Camera",
      "transform_3d": {
        "position": [8, 9, 12],
        "scale": [1, 1, 1],
        "rotation": [-0.2635350713876335, 0.2785822852913061, 0.07979199930413745, 0.9200947849638301]
      },
      "components": {
        "sindri.camera": {
          "projection": "perspective",
          "vertical_fov_degrees": 35,
          "near": 0.1,
          "far": 200
        },
        "sindri.script": {
          "source": "daynight.decay",
          "script": "DayNight",
          "properties": {
            "day_seconds": 12,
            "start_hour": 6,
            "preview_hour": -1,
            "move_carrier": true,
            "night_lights": true
          }
        }
      }
    },
    {
      "id": "Sun",
      "name": "Sun",
      "transform_3d": {
        "position": [0, 20, 0],
        "scale": [1, 1, 1],
        "rotation": [-0.17101007166283438, -0.8137976813493737, -0.2961981327260238, 0.4698463103929543]
      },
      "components": {
        "sindri.light": {
          "kind": "directional",
          "intensity": 0,
          "enabled": false
        }
      }
    },
    {
      "id": "World",
      "name": "World",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.environment": {
          "ambient_intensity": 0.03,
          "shadows": {
            "enabled": true,
            "distance": 16,
            "map_size": 1024,
            "bias": 0.001
          },
          "background": [0.008, 0.012, 0.035, 1]
        }
      }
    },
    {
      "id": "Floor",
      "name": "Floor",
      "transform_3d": {
        "position": [0, 0, 0],
        "scale": [1, 1, 1]
      },
      "components": {
        "sindri.model": {
          "asset": "models/prop.glb"
        }
      }
    },
    {
      "id": "Carrier",
      "name": "Carrier",
      "transform_3d": {
        "position": [-2, 1, 0],
        "scale": [1, 2, 1]
      },
      "components": {
        "sindri.model": {
          "asset": "models/caster.glb"
        }
      }
    },
    {
      "id": "Work-spot",
      "name": "Work spot",
      "parent": "Carrier",
      "transform_3d": {
        "position": [0, 0.55, 0],
        "scale": [1, 1, 1],
        "rotation": [-0.434965534, 0, 0, 0.9004471]
      },
      "components": {
        "sindri.light": {
          "kind": "spot",
          "color": [1, 0.3, 0.06],
          "intensity": 24,
          "range": 7,
          "inner_angle": 0.4,
          "outer_angle": 0.7,
          "enabled": false
        }
      }
    },
    {
      "id": "Blue-point",
      "name": "Blue point",
      "parent": "Carrier",
      "transform_3d": {
        "position": [0.75, 0.5, 0],
        "scale": [1, 1, 1],
        "rotation": [0, 0, 0, 1]
      },
      "components": {
        "sindri.light": {
          "kind": "point",
          "color": [0.04, 0.2, 1],
          "intensity": 14,
          "range": 7,
          "inner_angle": 0.4,
          "outer_angle": 0.7,
          "enabled": false
        }
      }
    }
  ]
}
```

### `sindri.toml`

```toml
format_version = 1
[project]
name = "Day/Night Lab"
main_scene = "assets/daynight.scene"
```

### Binary files in this project

`assets/models/caster.glb`, `assets/models/prop.glb`

