diff --git a/docs/plans/2026-06-12-asset-provenance-audit.md b/docs/plans/2026-06-12-asset-provenance-audit.md new file mode 100644 index 0000000..7cae33c --- /dev/null +++ b/docs/plans/2026-06-12-asset-provenance-audit.md @@ -0,0 +1,129 @@ +--- +date: 2026-06-12 +topic: asset-provenance-audit +status: verified +--- + +# Asset & code provenance audit β€” definitive rebuild / no-rebuild verdicts + +**Why this exists:** `String-sg/sensemaking-agents` is a **public, MIT-licensed** repo intended for MOE-wide student publication. Parts of the world engine were ported from Bruno Simon's work during the hackathon. This audit pins the provenance of every world asset and shader to a verified source and license, so we know exactly what must be replaced and what can stay. + +**How it was verified (2026-06-12):** every upstream claim below was checked against the live GitHub repos/files β€” license files fetched raw, directory listings via the GitHub API, shader uniforms compared line-by-line. Sources are linked at the bottom. Nothing below is guessed. + +--- + +## TL;DR + +| Verdict | What | Why | +|---|---|---| +| πŸ”΄ **MUST REPLACE** | Grass (material + shaders + class), Noises generator, Sky sphere/background/stars materials, 9 of 12 shader partials | Ported **byte-for-byte from `brunosimon/infinite-world`, which has NO license** β†’ all rights reserved. Cannot ship to students. | +| πŸ”΄ **MUST REPLACE** | Rain overlay (streaks **verbatim** + lens-droplet pass), water-shader foam/sparkle/contour layers, Aurora curtains | Ported from **`dannylimanseta/tinyskies`, which has NO license** β€” and its droplet shader is itself "adapted from Shadertoy" (probable Heartfelt lineage, **CC BY-NC-SA = non-commercial**). Two bad layers deep. | +| 🟒 **KEEP β€” attribution required** | Tree foliage system (`Tree.js` port), `foliageSDF.png`, 3 Perlin partials, Three.js, DRACO decoders, stats.js, lil-gui | MIT / Apache-2.0 β€” fully usable in a government product. We must add the license notices (currently missing). | +| 🟒 **KEEP β€” no action** | Water base waves + shore halo, curved-earth, island terrain, all birds, tree GLBs, terrain textures, day-cycle palettes, all ambient props (butterflies/fireflies/particles/rainbow/mailbox/telescope), procedural audio, all React/agent/server code | Our own authorship (this repo or Wondo's upstream `student-space`), or inspiration-only (ideas aren't copyrightable). | + +Net: **trees do NOT need rebuilding** (MIT), **water's base is ours** but three of its shader layers must be replaced, and the **rain system + aurora must be rebuilt**. Total mandatory replacement: **~5 engineer-days** (grass cluster ~3d + rain/water-layers/aurora ~2d). + +--- + +## πŸ”΄ MUST REPLACE β€” derived from `brunosimon/infinite-world` (NO license) + +`infinite-world` has no LICENSE file (GitHub API reports `"license": null`; a 2021 community request to add one β€” [Issue #8 on his my-room-in-3d](https://github.com/brunosimon/my-room-in-3d/issues/8), same situation β€” was never answered). Under the Berne Convention, no license = all rights reserved. Our own code comments confirm direct porting ("Geometry and shader are byte-for-byte his" β€” `Grass.js:17`). + +Every path below is under `src/engine/student-space/Game/View/`. Upstream paths verified to exist at `brunosimon/infinite-world` `sources/Game/View/`. + +| Our file | Upstream match (verified) | Disposition | Est. | +|---|---|---|---| +| `Materials/GrassMaterial.js` + `Materials/shaders/grass/{vertex,fragment}.glsl` | `Materials/GrassMaterial.js`, `shaders/grass/` β€” all uniforms (`uGrassDistance`, `uTerrainATexture`…`D`, `uFresnel*`, `uSunPosition`) match verbatim | **Rebuild** (clean-room: spec from screenshots, write fresh). Already planned as the grass-v2 issue; technique β€” instanced blades, terrain sampling, wind, distance fade β€” is freely reusable; his code text is not. | 2d | +| `Grass.js` (the class: geometry grid, blade buffers) | `Grass.js` β€” modified but derived | **Rebuild together with the material** (same issue; interfaces `bindTerrain()` etc. stay so callers don't change) | (incl.) | +| `Noises.js` + `Materials/NoisesMaterial.js` + `shaders/noises/` | `Noises.js`, `Materials/NoisesMaterial.js` | **Replace** β€” trivial: generate the noise `DataTexture` on CPU or via the MIT Gustavson Perlin we already carry | 0.5d | +| `Materials/SkyBackgroundMaterial.js` + `shaders/skyBackground/` | `Materials/SkyBackgroundMaterial.js` | **Delete** β€” already detached from the scene (`Sky.js:40` removes the mesh; CSS sky is the real backdrop) | 0.5d total for the sky cluster | +| `Materials/SkySphereMaterial.js` + `shaders/skySphere/` | `Materials/SkySphereMaterial.js` | **Delete or rebuild.** Recommended: **delete** β€” the CSS gradient sky already owns the backdrop; verify nothing visible regresses, then remove. Rebuild only if a WebGL sky is still wanted later. | (incl.) | +| `Materials/StarsMaterial.js` + `shaders/stars/` | `Materials/StarsMaterial.js` | **Delete or rebuild.** If night stars matter to the look, rebuild is small (point sprites + twinkle); otherwise delete with the sky sphere. | (incl., +0.5d if rebuilt) | +| `Materials/shaders/partials/` β€” `getGrassAttenuation`, `getSunShade`, `getSunShadeColor`, `getSunReflection`, `getSunReflectionColor`, `getFogColor`, `getRotatePivot2d`, `inverseLerp`, `remap` | `shaders/partials/` (same filenames) | **Replace during the grass rebuild.** Note: `inverseLerp`, `remap`, `getRotatePivot2d` are unprotectable one-line math, but they're 30 seconds to retype β€” do it anyway so the partials dir is 100% clean. | (incl.) | +| `View/Island.js` plateau fragment shader, lines ~517–521 | Two lines re-typed from his `getSunShade`/`getSunShadeColor` (flagged by our own comment) | **Rework in place** β€” half-Lambert wrap (`dot(N,-S)*0.5+0.5`) is a standard technique; retype it and choose our own shade tint instead of his `vec3(0.0, 0.5, 0.7)`. De-minimis risk, near-zero cost. | 0.1d | + +--- + +## πŸ”΄ MUST REPLACE β€” derived from `dannylimanseta/tinyskies` (NO license) + +Tiny Skies is the "GlobeFly" miniature-planet flying game by Danny Limanseta ([repo](https://github.com/dannylimanseta/tinyskies), live at tinyskies.vercel.app). The repo is **public but carries no LICENSE file** (GitHub API: `license: null`) β†’ all rights reserved, same legal position as infinite-world. Derivation was confirmed by fetching its actual sources and comparing line-by-line (details per row). Some of this entered our codebase via Wondo's legacy `student_space_island_v0.html`, which had already ported it β€” the provenance follows the code regardless of the hop. + +| Our file | Evidence of derivation (verified against fetched TinySkies source) | Disposition | Est. | +|---|---|---|---| +| `View/Rain.js` β€” streak pass | **Character-for-character identical** to `client/src/game/RainOverlay.ts`: same `streakFrag` (taper/across/shape lines), same constants `STREAK_COUNT=200`, `WIND_ANGLE=0.35`, `ANGLE_JITTER=0.09`, `NOISE_SIZE=256`. Our own comment: "verbatim port of Tiny Skies' streak pool." | **Rebuild fresh.** Falling-streak quads are a generic technique β€” write a new pool + shader from a one-paragraph spec without the old file open. | 0.5d | +| `View/Rain.js` β€” drops/lens pass | Direct port of TS `glassFrag`: identical r-loop (`4.0 β†’ 0`), identical cell math, identical lifecycle line `fract(time * (d.b + 0.1) * 0.45 + d.g) * 1.4`, identical live-cell gate. Our comment: "Direct port of TS's glassFrag." **Worse:** TS's own header says "adapted from Shadertoy" with no ID β€” the dominant lens-rain lineage is "Heartfelt" by Martijn Steinrucken, **CC BY-NC-SA 3.0 (non-commercial)**. Probable (not proven) NC chain on top of the unlicensed port. | **Rebuild from scratch with a visibly different construction** (e.g., texture-stamped droplet sprites or own hash-grid droplets). Do NOT "adapt" any Shadertoy rain shader β€” Shadertoy's default license is CC BY-NC-SA. The *idea* of droplets refracting the framebuffer is free; every implementation line must be ours. | 1d | +| `View/Island.js` β€” water **foam blob layer** (`w1`–`w7`) | Identical structure to TS `Globe.ts` ocean: `w1*w2*w4*w6 + w3*w5*w7*0.3`, `1.0 - smoothstep(0.002, …)`, and **all seven time coefficients identical** (3.6, 2.7, 2.1, 1.5, 1.2, 1.8, 0.9); spatial frequencies Γ·10 exactly as our comment admits ("ported from TinySkies… we scale freqs down by ~10Γ—"). | **Replace the layer.** Our own foam-cell *textures* (which are ours) already do similar work β€” lean on those + a freshly derived sine set with new structure and coefficients. | 0.5d | +| `View/Island.js` β€” water **sparkle layer** (`sp1`–`sp5`) | Identical combination formula `sp1*sp2*sp3*sp4 + sp2*sp3*sp5*0.5`, identical time coefficients (3.5, 2.8, 4.1, 1.9, 2.3), same threshold/`0.97` smoothstep shape. | **Replace the layer** (fresh sparkle construction β€” e.g., hash-based glints). | (incl.) | +| `View/Island.js` β€” shore **contour ripple layer** | Our comment: "TinySkies-style scrolling concentric contour ripples"; TS source has the matching `fract(depth * 6.0 - time * 0.8)` contour. | **Replace the layer.** The crisp waterline halo + wet-sand tint above it are our own and stay. | (incl.) | +| `View/Aurora.js` | Same construction as TS `client/src/game/Aurora.ts` with nudged constants: three x-only sine waves + ripple, displacement `(w1+w2+w3) * (0.25 + uv.y*0.75)` vs TS `(0.3 + uv.y*0.7)`, sway `sin(p.x * …) * 0.3 * uv.y` identical, same uniforms/blending. Entered via our legacy v0 file. | **Rebuild fresh** (it's a beloved twilight cue β€” keep the feature, rewrite the ribbons from a spec: different wave construction, own palette already differs). | 0.5d | + +**Verified NOT derived from TinySkies (inspiration only β€” keep):** `DayCycle.js` palette (ours is a 13-key hourly interpolation with our own twilight keys; TS uses 3 discrete presets with different values and structure), `CssSky.js` (CSS gradient approach, ours), water *base* waves + crisp shore halo (our legacy `buildWater`), `Weather.js` rain state machine, `Sound.js` (fully procedural Web Audio, no assets), Butterflies/Fireflies/Particles/Rainbow/Mailbox/Telescope/Flowers/Fruits/Sprouts (own recipes per headers and construction). + +**Total mandatory replacement (both πŸ”΄ tables): ~5 engineer-days** β€” grass cluster ~3d (already planned as T2) + rain/water-layers/aurora ~2d (added to T2). + +**Release gate (hard rule):** no student-wide release while any row in either πŸ”΄ table remains unreplaced. Internal dev and the June 22 stakeholder demo may run on current code β€” publication is the legal trigger, not the demo. *(Assumption to confirm with whoever owns legal/comms.)* + +**Team rule going forward:** before porting *anything*, check the source repo for a LICENSE file. Public β‰  licensed. And never adapt Shadertoy code β€” the site default is CC BY-NC-SA (non-commercial). + +--- + +## 🟒 KEEP β€” properly licensed, **attribution must be added** (action: F2) + +| Item | Source (verified) | License | Obligation | +|---|---|---|---| +| Tree foliage system β€” `Tree.js` port (80 billboard planes per icosphere, SDF alpha, two-tone sun shading; his TSL re-expressed in GLSL) | `brunosimon/folio-2025` β†’ `sources/Game/World/{Trees,Foliage,Leaves}.js` | **MIT** ([license.md verified](https://github.com/brunosimon/folio-2025/blob/main/license.md), Β© 2025 Bruno Simon; no asset carve-outs in the readme) | Retain his copyright + MIT notice (header comment in `Tree.js` + `THIRD_PARTY_NOTICES.md` entry) | +| `public/trees/foliageSDF.png` | `brunosimon/folio-2025` β†’ `static/foliage/foliageSDF.png` (byte-size match, 11KB) | **MIT** (covered by repo license; no exclusions documented) | Manifest row + notice entry | +| `Fruits.js` bush leaf-blobs ("Bruno-style billboard leaf-blobs") | Our code using the folio-2025 technique + same atlas | MIT-derived | Covered by the Tree.js notice | +| `shaders/partials/perlin2d.glsl`, `perlin3dPeriodic.glsl`, `perlin4d.glsl` | Stefan Gustavson, classic Perlin noise (webgl-noise lineage; credit headers already present in the files) | **MIT** (stegu/webgl-noise) | Keep the credit headers; add notice entry | +| `public/draco/*` (decoder .js/.wasm) | Google Draco via Three.js examples | **Apache-2.0** (verified) | Notice entry; redistribution explicitly permitted | +| `three` (npm) | mrdoob/three.js | **MIT** (verified) | Notice entry | + +**Important nuance for collaborators:** MIT does *not* mean "no obligations." It means we may use, modify, and sell β€” *provided the copyright and license text is retained*. None of these notices exist in our repo today. Creating `THIRD_PARTY_NOTICES.md` + header comments is issue F2 (0.5d) and makes all of this row fully compliant. + +**Do NOT port from these Bruno repos in future:** `my-room-in-3d` and `infinite-world` β€” both verified to have **no license**. Only `folio-2025` and `folio-2019` (both MIT) are safe sources. Three.js Journey *lesson* code sits in an ambiguous carve-out in his course terms ("sole exception of the examples of lines of code provided in the training exercises") with no explicit commercial grant β€” treat it as off-limits for this product; use the MIT folio repos instead. + +--- + +## 🟒 KEEP β€” our own authorship, no action + +| Item | Authorship trail | +|---|---| +| Water shader **base** (layered sine waves, crisp shore halo, wet-sand tint, depth gradient) in `View/Island.js` | Port of *our own* legacy `buildWater` from Wondo's pre-engine `student-space` code ("port of the legacy buildWater shader" β€” `Island.js:15`). **Exception:** the foam-blob, sparkle, and contour-ripple *layers* inside this shader are TinySkies-derived β€” see the πŸ”΄ TinySkies table. | +| Curved-earth displacement (`onBeforeCompile` splice, `CURVE_K`) | Our own legacy `P.post.curvedEarth` (`Island.js:20`). Technique (parabolic drop-off) is generic. | +| Island heightfield, silhouette functions, sand/cliff geometry | Authored in `State/Island.js` / `View/Island.js` (this lineage) | +| `public/birds/MaskedBower.glb` + all 6 procedural bird species (`Kira.js`) | App-authored (Blender + code), commits traced in this repo | +| `public/trees/{oak,cherry}TreesVisual.glb` | Authored in Wondo's upstream `wondopamine/student-space` (committed here 2026-05-18). Even if modeled following folio-2025's blend files, those are MIT β€” covered either way. | +| `public/student-space/textures/{sand-soft-ripples, cliff-soft-strata, water-foam-cells, water-short-bubbles}.png` | Authored in Wondo's upstream (committed 2026-05-25). β˜‘οΈ **One-line confirmation requested from Wondo:** these were created by you (hand-made or generated with a service whose terms grant output ownership), not downloaded from a texture site. If any came from a third-party library, flag it and we add it to the manifest. | +| Engine fork itself (`src/engine/student-space/`) | Clean-cut vendoring of Wondo's own `wondopamine/student-space` @ `cd30172` β€” same team, no external licensing issue. `UPSTREAM.md` still to be written (F2). | +| Camera, Renderer, Game glue, DayCycle, all State slices, statusHeuristics, all React/agents/server/DB code | Authored in this repo / upstream; comments like "replaces Bruno's Player/Camera chain" mean *our replacement code*, not his. | +| `public/logo/SVG@2x.svg` | String/MOE branding | + +--- + +## Action checklist (maps to plan issues) + +- [ ] **T2 (P0, 2d, design engineer):** Clean-room grass rebuild β€” material + shaders + `Grass.js` class + the 9 Bruno partials. Spec-from-screenshots process; keep `bindTerrain()` interface. +- [ ] **T2b (P0, 0.5d):** Replace Noises generator (CPU DataTexture or Gustavson-Perlin-based). +- [ ] **T2c (P0, 0.5d):** Delete sky background/sphere materials (already CSS-backed); decide stars (delete now, rebuild later if night look needs them); verify no visual regression. +- [ ] **T2d (P0, 0.1d):** Retype + retint the 2-line plateau sun-shade in `View/Island.js`. +- [ ] **T2e (P0, 1.5d):** Rebuild rain overlay β€” fresh streak pool (trivial) + new lens-droplet construction (no Shadertoy adaptation; own implementation of the refraction idea, or a different droplet look entirely). +- [ ] **T2f (P0, 0.5d):** Replace the three TinySkies-derived water layers (foam blobs, sparkles, contour ripples) with own constructions; keep our base waves/halo/foam-textures. +- [ ] **T2g (P0, 0.5d):** Rebuild aurora ribbons from a fresh spec (keep the feature and our palette; new wave construction). +- [ ] **F2 (P0, 0.5d, Wondo):** `THIRD_PARTY_NOTICES.md` (Bruno Simon folio-2025 MIT, Gustavson webgl-noise MIT, Draco Apache-2.0, Three.js MIT) + header notice in `Tree.js` + `UPSTREAM.md` + asset manifest. Include Wondo's texture-authorship confirmation. +- [ ] **Release gate:** recorded above; CI/manual check before any student-wide release that the πŸ”΄ table is empty. +- [ ] **(unchanged, product-driven, not legally required):** T1 parametric tree generator β€” trees are MIT-clean as-is; the generator is about *many better trees*, on our own timeline. + +## Sweep coverage note + +Every file under `src/engine/student-space/Game/` was checked (headers + a full-text scan for "port of / verbatim / lifted / adapted / Shadertoy / URLs / author names"). Items confirmed clean beyond the tables above: `Debug/Stats.js` and `Debug/UI.js` (thin wrappers over the `stats.js` and `lil-gui` npm packages, both MIT), `util/easing.js` (own one-line math), `Kira.js`, `ThumbnailRenderer.js`, all heuristics files, all State slices, all Data seeds. + +## Sources (all fetched 2026-06-12) + +- [folio-2025 license.md β€” MIT](https://github.com/brunosimon/folio-2025/blob/main/license.md) Β· [folio-2025 `static/foliage/` listing](https://api.github.com/repos/brunosimon/folio-2025/contents/static/foliage) Β· [folio-2025 `Foliage.js`](https://github.com/brunosimon/folio-2025/blob/main/sources/Game/World/Foliage.js) +- [folio-2019 license.md β€” MIT](https://github.com/brunosimon/folio-2019/blob/master/license.md) +- [infinite-world repo β€” no LICENSE file; API `license: null`](https://github.com/brunosimon/infinite-world) Β· [infinite-world `Materials/` listing](https://api.github.com/repos/brunosimon/infinite-world/contents/sources/Game/View/Materials) Β· [infinite-world grass vertex.glsl (uniform-level match)](https://raw.githubusercontent.com/brunosimon/infinite-world/master/sources/Game/View/Materials/shaders/grass/vertex.glsl) +- [my-room-in-3d β€” no license; unanswered request, Issue #8](https://github.com/brunosimon/my-room-in-3d/issues/8) +- [Three.js Journey general conditions (lesson-code carve-out, no commercial grant)](https://threejs-journey.com/general-conditions) +- [Google Draco LICENSE β€” Apache-2.0](https://github.com/google/draco/blob/main/LICENSE) Β· [Three.js LICENSE β€” MIT](https://github.com/mrdoob/three.js/blob/dev/LICENSE) +- [dannylimanseta/tinyskies β€” no LICENSE file; API `license: null`](https://github.com/dannylimanseta/tinyskies) Β· [RainOverlay.ts (streak constants + "adapted from Shadertoy" glassFrag)](https://raw.githubusercontent.com/dannylimanseta/tinyskies/cursor/globefly-multiplayer-globe-flight-game/client/src/game/RainOverlay.ts) Β· [Aurora.ts](https://api.github.com/repos/dannylimanseta/tinyskies/contents/client/src/game/Aurora.ts?ref=cursor/globefly-multiplayer-globe-flight-game) Β· [Globe.ts (ocean foam/sparkle)](https://raw.githubusercontent.com/dannylimanseta/tinyskies/cursor/globefly-multiplayer-globe-flight-game/client/src/game/Globe.ts) +- ["Heartfelt" by Martijn Steinrucken β€” Shadertoy, CC BY-NC-SA 3.0](https://www.shadertoy.com/view/ltffzl) Β· [Shadertoy default license terms](https://www.shadertoy.com/terms) diff --git a/docs/plans/2026-06-15-000-feat-island-editor-engine-overview.md b/docs/plans/2026-06-15-000-feat-island-editor-engine-overview.md new file mode 100644 index 0000000..48bc9a5 --- /dev/null +++ b/docs/plans/2026-06-15-000-feat-island-editor-engine-overview.md @@ -0,0 +1,196 @@ +--- +title: Island Editor Engine β€” initiative overview & plan index +type: feat +status: proposed +date: 2026-06-15 +revised: 2026-06-15 (post design-review / grill) +written_against_commit: 22856862 +--- + +# Island Editor Engine β€” overview & plan index + +> A sequenced set of self-contained plans that turn the island's hard-coded scene into a +> **data-driven world authored through a dev-facing in-app editor** β€” placement *and* species +> appearance β€” exported as committed defaults every user boots from. This file is the map. Each +> numbered plan is executable by an implementer with **zero context from the design session**. +> +> **This revision** reflects a design review (the `/grill-me` pass). Where it differs from the +> first draft, the review's decision wins β€” notably: **no 3D gizmo** (numeric inspector instead), +> **stable uuid object ids** (not `kind:index`), **full live add/remove incl. trees**, a **species +> palette** workstream (plan 005), and a **working-copy + committed-file** persistence model. + +--- + +## Decisions locked with the requester (design review, 2026-06-15) + +| # | Decision | +|---|---| +| Audience / home | **Dev/engineer tool**, gated by `import.meta.env.DEV` + `#editor` hash. Not in production, not on the student SideRail. | +| Output | **Two committed artifacts**: `defaultIslandLayout.json` (placement) + `defaultSpeciesPalette.json` (species colors). Authored β†’ exported β†’ committed β†’ ships via PR/deploy. | +| Scope of the model | **Authored static stage only** β€” trees, flowers, fruits, mailbox, telescope. Grown/bloomed objects (the reflection mechanic) stay owned by `Sprouts`, untouched. | +| Operations | select Β· add Β· remove Β· move Β· rotate(yaw) Β· scale β€” **all kinds incl. trees**, live. | +| Transform UX | **Numeric inspector** (type exact x/z/yaw/scale) + reuse the existing ground-plane drag for coarse move. **No `TransformControls` / 3D gizmo** (dropped β€” too risky at three 0.149, untestable headlessly). | +| Tree add/remove | Live teardown + `_placeAll` rebuild of the per-species `InstancedMesh`; a brief rebuild flash is accepted. | +| Object identity | **Stable uuid per object, assigned once ("baked") and never recomputed from array position.** Defaults carry frozen ids in the committed file; editor-added objects get fresh uuids. | +| Per-object config | kind + species + transform. **No per-object color** (color lives in the species palette). | +| Species palette | Edit **existing** species' colors live (oak/cherry two-tone leaves, the 6 flower palettes, the 6 fruit colors) β†’ applied via shader uniforms / `material.color`. New species/geometry = v2. | +| Edit persistence | localStorage **working copy** layered over the committed-file **base**, a "diverged from default" **badge**, a **revert to default** action, and **Export**. | +| Preview | Toggle **bare authored stage ↔ populated** (reuses the existing `showAll` mature-island preview). | +| Undo/redo | **One unified command stack** across move / add / remove / inspector / palette edits. | +| Per-student pick-and-plant | Re-key `decorOffsets` from **index β†’ stable uuid** with a one-time migration, so a student's moved objects survive a designer changing the defaults. (Promoted from optional β†’ in-scope.) | +| Deferred (v2+) | island shape/terrain editing; "core mechanics" tuning (thresholds/weather); new-species creation / asset import; a deployed self-serve designer surface + DB; per-instance color; multi-select; the 3D gizmo. | + +Plans live in `docs/plans/` (the repo's active planning home), **not** the empty root `plans/`. + +--- + +## Current state (verified against commit `22856862`) + +A mature hand-rolled **Three.js engine** at `src/engine/student-space/` (122 files): + +- **Game root & loop** β€” `Game/Game.js`: singleton `Game`, rAF loop gated by + `_running`/`_hidden`/`_renderActive`, `setRenderActive(active)`, and a `dispose()` that nulls + every singleton. `Game/index.js` is `createGame(...)`. +- **State slices** β€” hand-rolled observer slices under `Game/State/` (no Redux). Each: mutation + methods β†’ `subscribe(cb)` fan-out (try/catch) β†’ lenient `hydrate`/`serialize` β†’ `_persist()` via + `Persistence` (debounced). `schema.js` holds per-slice lenient mergers + `coercePosition`. +- **View objects** β€” bespoke per-kind `THREE.Group`s under `Game/View/`; **no base class**. Per-kind + registries (`Tree.entries`, `Fruits.entries`, `Flowers.flowers`). +- **Authored layout is hard-coded constants** β€” `Tree.PLACEMENTS` (7, `{species,x,z,scale,yaw}`), + `Fruits.BUSH_PLACEMENTS` (4), `Flowers` (18, `seed = 1337`, all 6 species), `Mailbox` (`-0.6,2.5`), + `Telescope` (`RIM_THETA=1.30, RIM_RADIUS=4.85`). Bounds/height: `State/Island.js` + (`heightAt`, `isOnPlateau`, `isPlaceable(inset=0.3)`, `radius=5.0`). +- **Species colors are hard-coded constants** β€” `Tree` `OAK_COLOR_A/B`,`CHERRY_COLOR_A/B` + (shader uniforms `uColorA/uColorB`); `Flowers.SPECIES` (`petal`/`centre`/`face`); + `Fruits.FRUIT_SPECIES` (`color`). All live-mutable (shader uniform / `material.color`). +- **A move-only student "Arrange" mode ships** β€” `ss:edit-mode` (button in + `IslandProgressionOverlay.tsx`) drag-moves sprouts/bloomed/decor; persists via + `Sprouts.decorOffsets` (**index-keyed**) to localStorage + the server snapshot + (`vips_island_snapshots`, via `IslandSnapshotBridge`). `View/Sprouts.js:618-681` applies offsets. +- **Dev-gate precedent** β€” `EngineHost.tsx:319` mounts `{import.meta.env.DEV && game ? + : null}`; `Debug.js` gates `lil-gui` behind `import.meta.env.DEV` + + `#debug`. React seam: `useEngine()`, `useEngineSliceVersion(slice)`. + +### The gap this initiative closes + +Authored placement **and** species appearance are constants β€” no add/remove, no transform, no +recolor, no authoring tool, no export. This builds the data models, the editor, and the +ship-as-default pipeline. + +--- + +## Plan set & execution order + +| # | File | Title | Depends on | Status | +|---|------|-------|-----------|--------| +| 001 | `…-001-feat-island-layout-data-model-plan.md` | Layout data model (uuid ids Β· default Β· render-from-data Β· working-copy slice) | β€” | Not started | +| 002 | `…-002-feat-island-editor-selection-transform-plan.md` | Selection + numeric-inspector transform + unified command/undo (**no gizmo**) | 001 | Not started | +| 003 | `…-003-feat-island-editor-authoring-surface-plan.md` | Dev-gated panel: palette/add Β· delete Β· inspector Β· undo Β· **full add/remove incl. tree rebuild** Β· preview toggle | 001, 002 | Not started | +| 004 | `…-004-feat-island-layout-export-default-pipeline-plan.md` | Layout export + committed `defaultIslandLayout.json` + **decorOffsets uuid re-key migration** | 001, 003 | Not started | +| 005 | `…-005-feat-island-species-palette-plan.md` | Species palette: data model + `defaultSpeciesPalette.json` + live recolor + palette editing UI + export | 001, 003 | Not started | + +**Execution order: 001 β†’ 002 β†’ 003, then 004 and 005 (independent β€” separate artifacts) after 003.** +Each downstream plan was written against `22856862` and assumes the architecture below; **re-validate +each against its predecessor's as-merged APIs** before executing (every plan carries a drift check + +STOP-and-report escape hatches). + +``` +001 ──▢ 002 ──▢ 003 ──┬──▢ 004 (layout export / committed default / offset re-key) +(model) (select+ (panel └──▢ 005 (species palette: model + recolor + export) + inspector) add/remove) +``` + +--- + +## Target architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ EDITOR SURFACE (React, DEV + #editor) [003 + 005] β”‚ +β”‚ paletteΒ·add/delete Β· inspector(x/z/yaw/scale/species/locked) Β· recolor Β· β”‚ +β”‚ preview toggle Β· undo/redo Β· revert-to-default Β· Export(layout + palette) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ commands β”‚ recolor +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SELECTION + NUMERIC TRANSFORM + COMMAND STACK [002]β”‚ β”‚ SPECIES PALETTE [005]β”‚ +β”‚ raycast pick Β· numeric x/z/yaw/scale commits Β· β”‚ β”‚ per-species colors β†’ β”‚ +β”‚ reuse drag for coarse move Β· terrain-snap/bounds β”‚ β”‚ uniforms / material.colorβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ updateObject / addObject / removeObject β”‚ default+working copy +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ISLAND-LAYOUT SLICE (working-copy + base) [001] β”‚ β”‚ SPECIES-PALETTE SLICE [005]β”‚ +β”‚ uuid objects Β· CRUD Β· events Β· divergence/revert β”‚ β”‚ same working-copy pattern β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ render-from-data + reconcile(add/remove) β”‚ apply-on-change + Tree (rebuild) Β· Flowers/Fruits (per-instance) Β· Mailbox/Telescope (move) + committed: defaultIslandLayout.json [004] committed: defaultSpeciesPalette.json [005] +``` + +### Data models + +- **`PlacedObject`** `{ id: uuid, kind, species?, x, z, yaw?, scale?, locked? }` β€” `y` is always + derived from `island.heightAt(x,z)`, never stored. `IslandLayout` `{ v, objects: PlacedObject[] }`. +- **`SpeciesPalette`** `{ v, species: { [kind]: { [speciesId]: { colors… } } } }` β€” colors only in + v1 (oak/cherry two-tone, flower petal/centre/face, fruit color). + +### Persistence model (both slices) + +base = committed default file (004/005) β†’ fallback to the constants-derived seed β†’ **working copy** +in localStorage layered on top β†’ `diverged` flag β†’ `revertToDefault()` β†’ `Export` writes the file. + +--- + +## Cross-cutting concerns (apply to every plan) + +- **No 3D gizmo.** Transforms are numeric (inspector) + the existing ground-plane drag for coarse + move. Do not add `TransformControls`. +- **Stable uuids, never index-as-identity.** Ids are assigned once and frozen; never recompute an + id from a live array position (that desyncs under add/remove/reorder). +- **Provenance is not a blocker, stay clear of the rebuild.** Per + `docs/plans/2026-06-12-asset-provenance-audit.md`, placeable content + the colors the palette edits + (tree foliage MIT; flowers/fruits own) are release-clean. The **ambient visuals** (grass/sky/rain/ + water/aurora) must be rebuilt before public release β€” the editor must **not** touch them. Island + shape editing (deferred v2) collides with that rebuild; that's why it's deferred. +- **rAF / HMR / dispose discipline.** New slices/controllers register in `Game.dispose()` and remove + their own listeners; respect `setRenderActive`. +- **State-slice ceremony.** A slice = slice file Β· `schema.js` merger Β· `State.js` construct/hydrate Β· + (persistence wiring) Β· `Game.dispose` clear Β· `*.d.ts`. Plans 001/005 enumerate every file. +- **Testing.** Vitest in `test/engine/*.test.ts` (slices, merge, reconcile) and + `test/components/*.test.tsx` (panel). Follow `Sprouts.test.ts` / `Sprouts.pickPlant.test.ts` / + `IslandSnapshotBridge.test.ts`. Numeric transforms + recolor are fully unit-testable (no WebGL). +- **Gates:** `pnpm check` (Biome + tsc) and `pnpm test` before any unit is "done"; `pnpm build` for + UI changes (and to verify the editor is DEV-stripped from production). +- **Components:** Base UI (`@base-ui-components/react`) for behavior + local `src/components/ui/*` + visuals. **Do not** install shadcn. + +--- + +## Scope boundaries (initiative) + +Not student-facing; no terrain/heightfield editing; no new-species/asset import; no deployed/DB +designer surface; no multiplayer; does not change the reflectionβ†’growβ†’bloom mechanic or the +ambient-visual rebuild. + +--- + +## Source map (verified) + +- Loop/dispose: `Game/Game.js`, `Game/index.js`. Bounds: `State/Island.js`. +- Placement consts: `View/Tree.js` (`PLACEMENTS:66`, `_placeAll:415`), `View/Fruits.js` + (`BUSH_PLACEMENTS:36`, `_placeBushes:92`), `View/Flowers.js` (`seed:359`, `_buildOne:378`), + `View/Mailbox.js:49`, `View/Telescope.js:27`. +- Color consts: `View/Tree.js:50-53` + `makeLeavesMaterial:246`; `View/Flowers.js:20-27`; + `View/Fruits.js:23-32`. +- Move APIs: `Tree.moveEntry:617`, `Flowers.moveInstance:509`, `Fruits.moveEntry:251`, + `Mailbox.move:223`, `Telescope.move:166`. +- Edit/persist: `State/Sprouts.js` (`decorOffsets:100`, `setDecorOffset:263`, `serialize:490`, + `hydrate:424`), `View/Sprouts.js:618-681`, `State/IslandSnapshotBridge.js`, + `src/components/IslandProgressionOverlay.tsx`. +- Schema/persistence: `State/schema.js` (`coercePosition:471`, `mergeSprout:482`, `mergeArray:520`), + `State/Persistence.js` (`KEY:33`, `SLICES:47`, `_exportJson:153`, `_importJson:168`), `State/State.js`. +- Server snapshot: `src/server/island-snapshot.handler.server.ts`, `…/island-state-at.handler.server.ts`, + `src/db/schema.ts:583` (`vipsIslandSnapshots`), `src/server/function-schemas.ts`. +- React seam / dev gate: `EngineHost.tsx:319`, `use-engine.ts`, `use-engine-slice-version.ts`, + `IslandProgressionOverlay.tsx`, `Debug/Debug.js:33-36`. +- Types template: `State/Sprouts.d.ts`. Tests: `test/engine/Sprouts*.test.ts`, + `test/components/*.test.tsx`. Provenance: `docs/plans/2026-06-12-asset-provenance-audit.md`. diff --git a/docs/plans/2026-06-15-001-feat-island-layout-data-model-plan.md b/docs/plans/2026-06-15-001-feat-island-layout-data-model-plan.md new file mode 100644 index 0000000..111773c --- /dev/null +++ b/docs/plans/2026-06-15-001-feat-island-layout-data-model-plan.md @@ -0,0 +1,218 @@ +--- +title: Island Layout Data Model β€” data-driven authored placement (uuid ids, working-copy slice) +type: feat +status: proposed +date: 2026-06-15 +revised: 2026-06-15 (post design-review) +written_against_commit: 22856862 +part_of: 2026-06-15-000-feat-island-editor-engine-overview.md +plan_index: 001 +--- + +# Island Layout Data Model β€” data-driven authored placement + +## Overview + +Make the island's hard-coded authored placement (`Tree.PLACEMENTS`, `Fruits.BUSH_PLACEMENTS`, the +seeded flower set, `Mailbox`/`Telescope` coords) into a typed, serializable **`IslandLayout`** owned +by a state slice. A **default layout** derived 1:1 from today's constants makes booting from it a +**visual no-op**. Each view kind then reads its base placements from the slice. The slice carries a +full CRUD API + events + a **working-copy-over-committed-base persistence model** (localStorage +working copy, a "diverged from default" flag, and `revertToDefault()`) so the later editor plans +have a real model to drive and a dev's in-progress edits survive reload. + +**Ships no editor UI and no runtime add/remove of meshes** β€” foundation only. Keystone for 002–005. + +> Read `…-000-…-overview.md` first. Locked decisions this plan honors: **statics-only** scope; +> **stable uuid ids** (not `kind:index`); **working-copy + committed-base** persistence. + +--- + +## Preconditions / drift check (DO FIRST) + +1. `git rev-parse --short HEAD` β€” if not `22856862`, re-verify the anchors. +2. Confirm anchors: `Tree.js` `PLACEMENTS:66` + `_placeAll:415` (pushes `entries` with `index`, + `authoredScale`); `Fruits.js` `BUSH_PLACEMENTS:36` + `_placeBushes:92`; `Flowers.js` `seed=1337:359`, + `_buildOne:378` (flower 0 pinned `-1.4,1.0`; `i>0` polar via `hash(seed,…)`), `INSTANCES=18`; + `Mailbox.js:49`; `Telescope.js:27`; `View/Sprouts.js:618-681` (applies `getDecorOffset(kind,i)` by + **index**); `Persistence.js` `KEY:33`/`SLICES:47`/`load.empty:234`; `State.js:79-125`; `schema.js` + `coercePosition:471`/`mergeSprout:482`/`mergeArray:520`; `Game.dispose:310-359`. +3. **STOP and report** if: a view already reads placement from a slice; `Tree._placeAll` also renders + grown/bloomed trees (not just the 7 statics); or a `Game/State/IslandLayout.js` / + `Game/Data/islandLayout*` already exists. + +--- + +## Requirements Trace + +- **R1.** Typed serializable `IslandLayout` `{ v, objects: PlacedObject[] }` and `PlacedObject` + `{ id, kind, species?, x, z, yaw?, scale?, locked? }`. `id` is a **stable uuid string** assigned + once and never recomputed from position. `y` is never stored. +- **R2.** `defaultIslandLayout()` reproduces the current island **exactly** (objects, species, + positions, scales, yaws). Default objects carry **frozen, deterministic** ids. +- **R3.** A singleton `IslandLayout` slice owns the live layout with `list`, `listByKind`, `get`, + `addObject`, `removeObject`, `updateObject(id,patch)`, `moveObject(id,{x,z})`, `setLayout`, + `resetToDefault`/`revertToDefault`, `isDiverged()`, `subscribe`, `hydrate`, `serialize` β€” following + the `Sprouts.js` idiom (caches, `_invalidateCache`, `_fan`, `_persist`). +- **R4.** Mutations fan typed events (`objectAdded|objectRemoved|objectUpdated|layoutReplaced`), + try/catch-wrapped. +- **R5. Persistence model:** **base** = `defaultIslandLayout()` (plan 004 later swaps this to read the + committed `defaultIslandLayout.json`); a **working copy** persists to localStorage; on hydrate the + live layout = working copy if present else base; `isDiverged()` = working copy differs from base; + `revertToDefault()` clears the working copy β†’ live = base. +- **R6.** All five kinds read their **base** placements from the slice instead of the constant, + preserving object **order** (so the index-keyed `decorOffsets` override at `View/Sprouts.js:618` + still aligns at boot). +- **R7.** The shipped pick-and-plant override layer is **unchanged** and still applies on top. +- **R8.** New slice/singleton participates in `Game.dispose()`; no leaked listeners. +- **R9.** `IslandLayout.d.ts` (+ `index.d.ts` if it enumerates slices) types the surface, mirroring + `Sprouts.d.ts`. +- **R10.** Unit tests: model + default parity, slice CRUD/events, serialize round-trip, working-copy + hydrate + divergence + revert. `pnpm check` + `pnpm test` pass. + +--- + +## Scope Boundaries + +**In:** data model, default builder, slice (CRUD + working-copy/divergence/revert), persistence +wiring, constβ†’slice base swap (all 5 kinds), types, tests. +**Not in:** any editor UI; runtime add/remove that spawns/despawns meshes (002/003 β€” the slice fans +events but views read **once at build** here); any change to pick-and-plant/`decorOffsets`/Sprouts; +grown/bloomed objects; species colors (plan 005); server persistence / committed file (plan 004); +terrain; `SCHEMA_VERSION` bump (additive slice is backward-compatible). + +--- + +## Key Technical Decisions + +1. **`PlacedObject`** as in R1. `y` derived from `island.heightAt(x,z)` always. `yaw`/`scale` default + `0`/`1`; `locked` defaults `false` (semantics consumed later). +2. **Stable uuid ids, frozen at authoring.** Default objects get **deterministic** ids in + `defaultIslandLayout()` β€” e.g. `tree-0`…`tree-6`, `flower-0`…`flower-17`, `fruit-0`…, `mailbox-0`, + `telescope-0`. These are *labels assigned once*, not recomputed from live array index β€” so they + survive add/remove/reorder. Editor-added objects (002/003) get fresh `crypto.randomUUID()` (or the + `uuid()` helper `Sprouts.js` already imports). Plan 004 freezes the default ids into the committed + JSON. (This replaces the first draft's live `kind:index` identity.) +3. **Default reproduces constants, incl. flowers.** Export the flower base-placement formula from + `Flowers.js` (`FLOWER_SEED=1337`) and consume it from both `_buildOne` and the default builder β€” + one source of truth. Trees/fruits/mailbox/telescope bake their explicit coords. +4. **Reuse `coercePosition` + lenient-merge convention** (`mergePlacedObject`/`mergeIslandLayout`). +5. **Working-copy persistence (locked decision C).** The slice persists a working copy to + localStorage (so a dev's edits survive reload) over a base default, with `isDiverged()` + revert. + Base = `defaultIslandLayout()` here; plan 004 repoints base at the committed file. +6. **Views read the layout once at build** (mirrors where they read the const). Live reconcile = 003. +7. **Order preserved** so the index-keyed `decorOffsets` still aligns at boot; plan 004 re-keys + `decorOffsets` to uuid (then order no longer matters). + +--- + +## Implementation Units + +### U1 β€” Data model + default builder (uuid ids) +**Files:** create `Game/Data/islandLayout.js` (+ `.d.ts`); modify `View/Flowers.js` (export formula); +export `PLACEMENTS`/`BUSH_PLACEMENTS` from `Tree.js`/`Fruits.js` (or re-declare locally β€” escape hatch +below). +**Approach:** export `flowerBasePlacement(i)` from `Flowers.js` (`FLOWER_SEED=1337`, the `i===0` pin + +the `hash`-based polar formula β€” bit-identical to the current inline math) and call it from +`_buildOne`. `defaultIslandLayout()` builds `{ v:1, objects }`: trees from `PLACEMENTS` (`id: +\`tree-${i}\``), fruits from `BUSH_PLACEMENTS` (`fruit-${i}`), 18 flowers from `flowerBasePlacement` +(`flower-${i}`), `mailbox-0` (`-0.6,2.5`, `locked:true`), `telescope-0` (`cos1.30Β·4.85, sin1.30Β·4.85`, +`locked:true`). 31 objects. JSDoc typedefs + `islandLayout.d.ts`. +**Escape hatch:** if exporting the consts creates an import cycle, re-declare them in `islandLayout.js` ++ a test asserting equality with the view-module values. +**Done:** `defaultIslandLayout().objects.length === 31`; U7 parity passes. + +### U2 β€” Schema mergers +**Files:** `State/schema.js`. +**Approach:** add `mergePlacedObject` (known-keys `id,kind,species,x,z,yaw,scale,locked`; `kind` in the +5-set; `x/z/yaw/scale` finite; `locked` bool; `id`+`kind` required else reject) and +`mergeIslandLayout(raw)` (`{ v, objects: mergeArray(raw.objects, mergePlacedObject) }`, `null` if no +`objects[]`). Mirror `mergeSprout` exactly. + +### U3 β€” `IslandLayout` slice (working-copy model) +**Files:** create `Game/State/IslandLayout.js` (+ `.d.ts`). +**Approach (mirror `Sprouts.js`):** singleton; `this._base = defaultIslandLayout()`, +`this.objects = clone(this._base.objects)`. Frozen-snapshot caches for `list`/`listByKind`/`get`. +Mutations validate β†’ mutate β†’ `_invalidateCache` β†’ `_fan(event)` β†’ `_persist()`: +`addObject` (merge; assign `\`${kind}-${uuid()}\`` if no id; reject dup id; `objectAdded`), +`removeObject(id)` (`objectRemoved`), `updateObject(id,patch)` (never change `id`/`kind`; +`objectUpdated`), `moveObject(id,{x,z})` (via `coercePosition`), `setLayout(layout)` +(`mergeIslandLayout`; `layoutReplaced`), `revertToDefault()` (objects ← base; clear working copy; +`layoutReplaced`). `isDiverged()` = objects deep-differ from `_base.objects`. `subscribe`/`_fan` = +copy `Sprouts.js`. `hydrate(snapshot)` = if a valid non-empty working copy β†’ `objects ← it`, else keep +base. `serialize()` = `{ v:1, objects }`. `_persist()` = `Persistence.getInstance().save('islandLayout', +this.serialize())`. + +### U4 β€” Persistence + State + dispose + types +**Files:** `Persistence.js` (add `islandLayout` to `KEY`, `SLICES`, and `empty` in `load()`); +`State.js` (`import IslandLayout`; construct `this.islandLayout = new IslandLayout()` near `this.island`; +`this.islandLayout.hydrate(snapshot.islandLayout)` in the hydrate block); `Game.js` dispose (null the +singleton the same way siblings are nulled); create `IslandLayout.d.ts` (mirror `Sprouts.d.ts`: export +`PlacedObject`, `IslandLayout`, the event union, the typed class); add to `index.d.ts` if it lists slices. +**Escape hatch:** match the existing `Game.dispose` mechanism exactly; if unrecognized, STOP & report. + +### U5 β€” Trees render base from slice (proof) +**Files:** `Tree.js`. Replace `for(const placement of PLACEMENTS)` in `_placeAll` with +`for(const placement of this.state.islandLayout.listByKind('tree'))` (each has `{id,species,x,z,yaw, +scale}`; existing destructure unchanged). Keep `PLACEMENTS` exported as the default seed. Replace +`PLACEMENTS.length` (`:565`) with the slice count / `this.entries.length`. Carry the layout `id` onto +each `entry` (for 002/004). **Verify** the 7 trees render identically and pick-and-plant survives a +reload. **Escape hatch:** if `_placeAll` runs before State exists, or leaf-InstancedMesh bookkeeping +breaks, STOP & report. + +### U6 β€” Remaining kinds render base from slice +**Files:** `Fruits.js`, `Flowers.js`, `Mailbox.js`, `Telescope.js`. Same swap, preserve order/index, +carry the layout `id` onto each record. Flowers: build from `listByKind('flower')` using each object's +`x/z/yaw/species` (identical to baked seed). Mailbox/Telescope: read `get('mailbox-0')`/`get('telescope-0')` +(fallback to const). **Escape hatch:** if `Flowers._buildOne` can't take external coords cleanly, defer +Flowers (trees+fruits prove the pattern) and report. + +### U7 β€” Tests + gates +**Files:** `test/engine/IslandLayout.test.ts`, `test/engine/islandLayout.defaults.test.ts`. +**Scenarios:** default parity (31 objects; per-kind counts 7/4/18/1/1; `tree-i` deep-equals +`PLACEMENTS[i]`; `flower-i` equals `flowerBasePlacement(i)`; `mailbox-0`/`telescope-0` coords); schema +merges (U2 cases); CRUD + events; ids stay stable across remove (removing `tree-2` does not renumber +`tree-3`); serialize round-trip; **working-copy hydrate** (mutate β†’ `serialize` β†’ fresh `hydrate` +restores it via a `memoryAdapter`); **divergence** (`isDiverged()` true after a mutation, false after +`revertToDefault()`); dispose nulls the singleton. +**Verify:** +```bash +pnpm test test/engine/IslandLayout.test.ts test/engine/islandLayout.defaults.test.ts +pnpm test # full suite; Sprouts.pickPlant.test.ts + IslandSnapshotBridge.test.ts MUST stay green +pnpm check +``` +Patterns: `test/engine/Sprouts.test.ts`, `Sprouts.pickPlant.test.ts`. + +--- + +## System-Wide Impact + +- **Pick-and-plant:** unchanged; index-keyed offsets still align at boot (order preserved). Plan 004 + re-keys them to uuid (then order-independent). Until then, do not reorder default objects in a way + that ships. +- **`IslandSnapshotBridge`/`vips_island_snapshots`:** unchanged (serializes `Sprouts`, not the layout). +- **`SCHEMA_VERSION`:** unchanged; old snapshots lack `islandLayout` β†’ slice uses base default. +- **Perf/render:** identical mesh construction; only the *source* of the placement array changes. + +## Risks + +| Risk | Mitigation | +|---|---| +| Flower formula drift | one exported `flowerBasePlacement`; U7 per-index parity | +| Import cycle | U1 escape hatch (local re-declare + parity test) | +| Slice built after a view's `_placeAll` | State builds before View; U5 escape hatch | +| Dispose teardown misunderstood | U4 escape hatch; U7 asserts clean re-construct | +| Flowers `_buildOne` too entangled | U6 escape hatch (defer Flowers) | + +## Done Criteria +1. `pnpm check` + `pnpm test` exit 0; new tests green; `Sprouts.pickPlant`/`IslandSnapshotBridge` green. +2. `defaultIslandLayout().objects.length === 31` with uuid-style frozen ids. +3. `pnpm dev` on `/` is visually identical to `main`; pick-and-plant survives reload per kind. +4. A slice mutation persists to localStorage and `isDiverged()`/`revertToDefault()` behave. + +## Sources +Overview `…-000-…`. Consts: `Tree.js:66/415/565`, `Fruits.js:36/92`, `Flowers.js:359/378`, +`Mailbox.js:49`, `Telescope.js:27`. Override (untouched): `View/Sprouts.js:618-681`. Slice idiom: +`State/Sprouts.js` (`setDecorOffset:263`, `serialize:490`, `hydrate:424`). Schema: `schema.js:471/482/520`. +Persistence: `Persistence.js:33/47/234`. State: `State.js:79-125`; dispose `Game.js:310-359`. Bounds: +`State/Island.js`. Types: `Sprouts.d.ts`. Tests: `test/engine/Sprouts*.test.ts`. diff --git a/docs/plans/2026-06-15-002-feat-island-editor-selection-transform-plan.md b/docs/plans/2026-06-15-002-feat-island-editor-selection-transform-plan.md new file mode 100644 index 0000000..1118ae1 --- /dev/null +++ b/docs/plans/2026-06-15-002-feat-island-editor-selection-transform-plan.md @@ -0,0 +1,188 @@ +--- +title: Island Editor β€” selection, numeric transform & unified command/undo (no gizmo) +type: feat +status: proposed +date: 2026-06-15 +revised: 2026-06-15 (post design-review β€” TransformControls dropped) +written_against_commit: 22856862 +part_of: 2026-06-15-000-feat-island-editor-engine-overview.md +plan_index: 002 +depends_on: [001] +--- + +# Island Editor β€” selection + numeric transform + command/undo + +## Overview + +With placement data-driven (001), this plan adds the **engine core of the editor**: **select** an +object (raycast pick + highlight), **transform** it via a precise API the inspector calls +(`applyTransform(id, {x,z,yaw,scale})`) plus the existing **ground-plane drag** for coarse move, +**commit** to the layout, and **undo/redo** via a unified command stack. A small **`EditableView`** +adapter per kind lets all of this work uniformly across the bespoke views. + +**Locked decision honored: no 3D gizmo.** Transforms are numeric (inspector, built in 003) + drag for +coarse move. This removes `TransformControls` entirely β€” the single riskiest, least-testable piece of +the first draft. Everything here is unit-testable without WebGL. + +> Read `…-000-…-overview.md` and confirm 001 is merged. + +--- + +## Preconditions / drift check (DO FIRST) + +1. **001 merged.** `Game/State/IslandLayout.js` exposes `list/listByKind/get/updateObject/moveObject/ + subscribe` and fans `objectUpdated`; objects carry **stable uuid** `id`; each view's per-object + record carries its layout `id` (001 U5/U6). Use as-merged names if they differ. +2. Anchors: the existing drag reference `View/Sprouts.js` β€” `_raycaster`/`_drag`/`_dragGroundPlane` + (~317-334), `_handlePointerDown:347`, `_raycastDraggable:434`, `_handlePointerMove:471`, + `_finishDrag:773`/`_cancelDrag:868`, `camera.controls.enabled` suppression; per-object groups + (`Tree.entries[i].group`, `Flowers.flowers[i].group`, `Fruits.entries[i].group`, `Mailbox.group`, + `Telescope.group`); move APIs (`Tree.moveEntry:617`, `Flowers.moveInstance:509`, + `Fruits.moveEntry:251`, `Mailbox.move:223`, `Telescope.move:166`); `View.js` construction (~40-122) + + `SUBSYSTEMS` dispose (~187-220); `camera.controls` bound at `View.js:55`; bounds + `Island.heightAt/isPlaceable`; dev gate `Debug.js:33-36`; `window.__studentSpaceGame` in + `EngineHost.tsx`. +3. **STOP and report** if the student pick-and-plant has been removed (this plan leaves it intact), or + 001's objects aren't uuid-addressable. + +--- + +## Requirements Trace + +- **R1.** An `EditableView` adapter per kind resolves a layout object's `THREE.Object3D` + (`getObject3D(id)`), enumerates raycast targets (`hitTargets()`), and applies a live transform + (`applyTransform(id,{x?,z?,yaw?,scale?})`) by wrapping the existing move API + setting + `group.rotation.y`/`group.scale`. (`spawn`/`remove` are **declared**; implemented in 003.) +- **R2.** An `EditController` raycast-picks an object on pointer-down (editor active) β†’ `Selection`, + with a highlight. +- **R3.** `EditController.applyTransform(id, patch)` (the API the 003 inspector calls) transforms the + mesh **and** commits to `state.islandLayout.updateObject(id, patch)` (`y` always `heightAt`). +- **R4.** A **coarse-move drag** (reuse the `Sprouts.js` ground-plane pattern): pointer-drag a selected + object across the plateau, suppress `camera.controls` during drag, snap `y`, reject `!isPlaceable`, + commit `{x,z}` on release. **No gizmo.** +- **R5.** A **unified `CommandStack`** records every commit (`{before,after}` for transforms; extended + by 003/005 for add/remove/recolor) with `undo()`/`redo()`. +- **R6.** Editor is **dev-gated** (`import.meta.env.DEV` + `#editor`), `activate()`/`deactivate()`-able + from React (003), exposed as `window.__islandEditor` in dev for pre-UI testing. +- **R7.** Everything participates in `View.dispose()`/`Game.dispose()`; `camera.controls.enabled` + restored on dispose/cancel. +- **R8.** Tests cover selection, `applyTransform`β†’layout+mesh, drag bounds reject, undo/redo, controls + restore. `pnpm check`+`pnpm test` pass. (No WebGL needed β€” gizmo is gone.) + +--- + +## Scope Boundaries + +**In:** adapters (transform of existing objects), Selection, EditController (pick + applyTransform + +coarse-move drag), unified CommandStack, dev activation, tests. +**Not in:** add/remove spawn/despawn (003 β€” `spawn`/`remove` are stubs here); the inspector/palette +**UI** (003); species recolor (005); export (004); 3D gizmo (dropped); multi-select; retiring the +student pick-and-plant. + +--- + +## Key Technical Decisions + +1. **No gizmo.** The first draft's `TransformControls` is removed: it's risky at three 0.149 and can't + be unit-tested in happy-dom. Selection is click-to-pick; transforms are numeric (003 inspector via + `applyTransform`) + the proven ground-plane drag for coarse move. +2. **Additive controller; student pick-and-plant untouched.** Both can run; the dev editor is + `#editor`-gated, the student drag is `ss:edit-mode`-gated. Add a one-line guard so the student drag + is inert while `#editor` is active. +3. **`y` derived, never stored.** Transform commits store `{x,z,yaw,scale}`; view snaps `y` via + `heightAt`. +4. **Reactive sync:** subscribe to `objectUpdated` β†’ `adapter.applyTransform` keeps the mesh in sync + when the layout changes from elsewhere (e.g. undo, inspector). +5. **`EditableView` is a per-kind adapter object** (no base class) looked up by `kind`. +6. **Unified command stack** so add/remove (003) and recolor (005) compose with transforms in one + undo history. + +--- + +## Implementation Units + +### U1 β€” `EditableView` adapters +**Files:** create `Game/View/edit/editableViews.js` (+ `.d.ts`). +Per kind, an adapter closing over the view: `getObject3D(id)` (look up the record by its layout `id` +from 001 β†’ `.group`), `hitTargets()` (the groups), `applyTransform(id,t)` (translate β†’ existing +`moveEntry`/`moveInstance`/`move`; `t.yaw` β†’ `group.rotation.y`; `t.scale` β†’ `group.scale.setScalar`), +`spawn(obj)`/`remove(id)` β†’ `console.warn('… see plan 003')`. `buildEditableViews(view, island)` returns +`{tree,flower,fruit,mailbox,telescope}`. **Escape hatch:** if a kind exposes no per-object group to +transform, STOP & report (trees: use the per-tree trunk `entry.group`; `moveEntry` re-projects leaves). + +### U2 β€” Selection +**Files:** `Game/View/edit/Selection.js`. `select(id)`/`deselect()`/`get()`; a tiny change-callback set +(003 observes). Highlight: a cheap `THREE.BoxHelper(object3d)` or a ground ring at the object; dispose +on deselect. + +### U3 β€” `EditController` (pick + numeric transform + coarse-move drag) +**Files:** create `Game/View/edit/EditController.js` (+ `.d.ts`). +- Construct `{view,state,camera,scene,island,editableViews,selection}`; `activate()`/`deactivate()` + add/remove a canvas `pointerdown`. +- **Pick:** pointerdown raycasts `Ξ£ editableViews[*].hitTargets()`; map hit β†’ layout `id` via + `object.userData`/identity; `selection.select(id)`. +- **`applyTransform(id, patch)`** (called by the 003 inspector + by undo): clamp translate to + `isPlaceable`; `editableViews[kind].applyTransform(id, patch)`; push a command (U4); commit + `state.islandLayout.updateObject(id, {...patch})` (omit `y`). +- **Coarse-move drag:** reuse the `Sprouts.js` mechanics β€” on drag of the selected object, project to a + ground plane, set `x/z`, `y=heightAt`, tint/block when `!isPlaceable`, `camera.controls.enabled=false` + during, commit `{x,z}` (via `applyTransform`) on release inside bounds else snap back. +- Subscribe to `state.islandLayout` `objectUpdated` β†’ `editableViews[kind].applyTransform(id, …)` to + keep meshes synced on external changes (undo, inspector). + +### U4 β€” Unified `CommandStack` +**Files:** `Game/View/edit/CommandStack.js` (+ `.d.ts`). `push({do,undo})`; `undo()`/`redo()` with a +redo stack; optional cap (~100). Transform command: `do=()=>layout.updateObject(id,after)`, +`undo=()=>layout.updateObject(id,before)`. Generic so 003 add/remove + 005 recolor slot in. + +### U5 β€” Dev activation + lifecycle +**Files:** `View.js` (construct `this.editController` after the view kinds; add to `SUBSYSTEMS`); maybe +`Debug/Debug.js` for an `editor` toggle; `index.d.ts` if it enumerates subsystems. +Do **not** `activate()` by default. Expose `window.__islandEditor` in dev. `dispose()` β†’ `deactivate()`, +remove listeners, restore `camera.controls.enabled = true`, dispose the highlight. **Escape hatch:** +conform to the existing `SUBSYSTEMS` dispose shape; if unclear, STOP & report. + +### U6 β€” Tests + gates +**Files:** `test/engine/IslandEditor.selection.test.ts`, `…transform.test.ts`. (Construct `Game`/`View` +in happy-dom like `Camera.test.ts`/`Sprouts.pickPlant.test.ts`.) +**Scenarios:** simulated raycast hit β†’ `selection.get()` is the id; `applyTransform` writes +`{x,z,yaw,scale}` to `layout.updateObject` (assert `layout.get(id)`) and moves/rotates/scales the group; +`y` not stored; off-plateau translate rejected (mirror `Sprouts.pickPlant.test.ts`); drag toggles +`camera.controls.enabled` (stub controls) and restores it; undo restores `before`, redo `after`; +`dispose()` restores `controls.enabled` + clears the highlight. +**Verify:** +```bash +pnpm test test/engine/IslandEditor.selection.test.ts test/engine/IslandEditor.transform.test.ts +pnpm test ; pnpm check +pnpm dev # /#editor: click an object β†’ highlight; numeric/drag move within bounds commits; reload persists (001 working copy) +``` + +--- + +## System-Wide Impact + +- **Student pick-and-plant:** untouched; guarded off while `#editor` active. Both write different + layers (editor β†’ layout; student β†’ `decorOffsets`). +- **Layout slice:** first writer of `updateObject`; edits persist to the working copy (001). +- **Camera:** the drag toggles `controls.enabled`; restore is bulletproofed (dispose + cancel + a + try/finally). A stuck `false` bricks orbit β€” U6 covers it. +- **No WebGL test dependency** β€” the gizmo's removal makes the core fully unit-testable. + +## Risks +| Risk | Mitigation | +|---|---| +| Tree transform anchor (leaves are a shared InstancedMesh) | transform the per-tree trunk `entry.group`; `moveEntry` re-projects leaves; U1 escape hatch | +| Stuck `controls.enabled=false` | restore in dispose + cancel + try/finally; U6 | +| Editor + student drag both raycast | guard student drag off under `#editor` | + +## Done Criteria +1. `pnpm check`+`pnpm test` green; new tests pass; `Sprouts.*`/`IslandLayout.*` unaffected. +2. `/#editor`: click selects (highlight), numeric + drag transforms commit + persist; off-plateau + rejected. 3. Outside `#editor`, world + student pick-and-plant unchanged. 4. Dispose leaves + `controls.enabled === true` and no highlight in the scene. **No `TransformControls` anywhere.** + +## Sources +Overview/001. Drag reference `View/Sprouts.js:347/434/773/618-681`. Move APIs `Tree.js:617`, +`Flowers.js:509`, `Fruits.js:251`, `Mailbox.js:223`, `Telescope.js:166`. Camera `View.js:55`. Lifecycle +`View.js:40-122/187`, `Game.js:310-359`. Bounds `State/Island.js`. Dev gate `Debug.js:33-36`, +`EngineHost.tsx`. Tests `test/engine/Camera.test.ts`, `Sprouts.pickPlant.test.ts`. diff --git a/docs/plans/2026-06-15-003-feat-island-editor-authoring-surface-plan.md b/docs/plans/2026-06-15-003-feat-island-editor-authoring-surface-plan.md new file mode 100644 index 0000000..e05939d --- /dev/null +++ b/docs/plans/2026-06-15-003-feat-island-editor-authoring-surface-plan.md @@ -0,0 +1,191 @@ +--- +title: Island Editor β€” dev-gated authoring surface (panel Β· palette/add Β· delete Β· inspector Β· full add/remove Β· preview) +type: feat +status: proposed +date: 2026-06-15 +revised: 2026-06-15 (post design-review β€” full add/remove incl. tree rebuild; inspector transforms; preview toggle) +written_against_commit: 22856862 +part_of: 2026-06-15-000-feat-island-editor-engine-overview.md +plan_index: 003 +depends_on: [001, 002] +--- + +# Island Editor β€” dev-gated authoring surface + +## Overview + +The developer/designer UI on top of 001 (layout model) + 002 (selection + numeric transform + command +stack): a dev-gated React panel to **add** from a palette, **delete**, edit a selected object in a +**numeric inspector** (x/z/yaw/scale/species/locked), **undo/redo**, **revert to default** (with a +"diverged" badge), and **toggle the preview** bare↔populated β€” plus the engine-side **add/remove +spawn/despawn for all kinds, including the tree `InstancedMesh` rebuild**. + +When this lands, a dev at `/#editor` can fully author placement: drop/move/rotate/scale/delete any +authored object and see it live. Export β†’ committed default is plan 004; species recolor is plan 005 +(its controls mount in this same panel). + +> Read `…-000-…-overview.md`; confirm 001 + 002 merged. Locked: dev-gated; numeric inspector (no +> gizmo); **full live add/remove incl. trees** (simple rebuild, brief flash OK); preview toggle. + +--- + +## Preconditions / drift check (DO FIRST) + +1. **001 + 002 merged.** `IslandLayout` exposes `addObject/removeObject/updateObject/list/listByKind/ + get/isDiverged/revertToDefault/subscribe` + events; `EditController` exposes `activate/deactivate/ + applyTransform/selection`; `CommandStack` exists; `editableViews` adapters exist (with **declared** + `spawn`/`remove`). Use as-merged names. +2. Anchors: `EngineHost.tsx:319` (`{import.meta.env.DEV && game ? : null}` β€” the + mount precedent); `IslandProgressionOverlay.tsx` (`WorldIconButton`, the `game as unknown as + {state?…}` cast, `useState`/subscribe); `useEngine` / `useEngineSliceVersion`; `Debug.js:33-36` gate; + `Tree._placeAll:415` + `hideAll:510` + `_leafMeshes`/`_leafMeshBySpecies`/`entries`; + `Flowers.flowers`+`_buildOne` + the dispose idiom `Flowers.js:448-456`; `Fruits.entries`+`_placeBushes`; + `Mailbox.dispose:249`/`Telescope.dispose:188`; `Tree/Flowers/Fruits.showAll` (mature-island preview). +3. **STOP and report** if the 003-panel or 002-controller APIs are absent, or `import.meta.env.DEV` + isn't the project's dev-build flag. + +--- + +## Requirements Trace + +- **R1.** A `IslandEditorPanel` mounts **only** under `import.meta.env.DEV` + `#editor`; never in prod / + on the SideRail; `activate()`/`deactivate()`s the 002 controller. +- **R2.** Palette: pick kind (tree/flower/fruit) + species + Add β†’ `addObject` (fresh uuid) β†’ mesh + appears; auto-select it. +- **R3.** Delete the selected object β†’ `removeObject` β†’ mesh despawns. +- **R4.** Numeric inspector for the selected object: `x/z/yaw/scale` (number fields), `species` (enum), + `locked` (toggle) β†’ `EditController.applyTransform` / `updateObject`; edits reflect on the mesh. +- **R5.** **Engine add/remove reconcile for all kinds**, driven by layout `objectAdded/objectRemoved/ + layoutReplaced` events: flowers/fruits per-instance spawn/despawn; **trees via teardown + `_placeAll` + rebuild** (brief flash accepted); mailbox/telescope are singletons (reposition only β€” no add/remove). +- **R6.** Undo/redo buttons (002 `CommandStack`); a **"diverged from default" badge** + **revert** + (001 `isDiverged`/`revertToDefault`). +- **R7.** **Preview toggle** bare authored stage ↔ populated (reuse `Tree/Flowers/Fruits.showAll`). +- **R8.** Panel reflects the live layout via `useEngineSliceVersion(state.islandLayout)`. +- **R9.** Clean activate/deactivate on mount/unmount; production bundle excludes the panel (DEV-stripped). +- **R10.** Tests: dev-gate, addβ†’spawn, deleteβ†’despawn, inspectorβ†’updateObjectβ†’mesh, undo of add/delete, + **tree rebuild**, preview toggle, revert. `pnpm check`+`pnpm test`+`pnpm build` pass. + +--- + +## Scope Boundaries + +**In:** the panel (palette/add, delete, numeric inspector, undo/redo, revert+badge, preview toggle), +the engine add/remove reconcile (incl. tree rebuild), tests. +**Not in:** export / committed default (004); species recolor model + controls (005 β€” its controls +mount here later); a second mailbox/telescope; new-species/asset import; student exposure / prod; +multi-select / drag-from-palette (click-to-add suffices); terrain. + +--- + +## Key Technical Decisions + +1. **Reactive reconcile:** UI mutates the layout; the 002 `EditController` subscribes to structural + events and calls `editableViews[kind].ensureFromLayout(listByKind(kind))`. One flow: UI β†’ layout β†’ + controller β†’ view. +2. **`ensureFromLayout` per kind.** Flowers/fruits: add groups for new ids, dispose+remove for gone ids + (cheap; reuse `Flowers.js:448-456` dispose idiom). **Trees: full teardown + `_placeAll` rebuild** + (the leaf `InstancedMesh` is count-sized; rebuild beats index surgery; brief flash accepted per the + locked decision). Mailbox/telescope: reposition the singleton only. +3. **Dev-only, hash-invoked** (`import.meta.env.DEV && hash includes 'editor'`). No prod surface, no + SideRail. Verify `pnpm build` strips it. +4. **Inspector is the transform UI** (numeric), calling 002's `applyTransform`; coarse move via 002's + drag. No gizmo. +5. **All edits undoable** (002 stack): add⇄remove inverse commands; inspector edits push transform + commands; the panel's undo/redo drive the stack. +6. **Preview toggle reuses `showAll`** (the existing mature-island dev preview) β€” cheap; default bare. + +--- + +## Implementation Units + +### U1 β€” Panel shell + activation + preview toggle +**Files:** create `src/components/student-space/editor/IslandEditorPanel.tsx`; modify `EngineHost.tsx` +(mount beside `CameraTuneBridge`: `{import.meta.env.DEV && game ? : +null}`). +Gate on `location.hash` includes `editor` (read + `hashchange`); render `null` otherwise. `useEffect`: +`activate()` on mount, `deactivate()` on unmount. Reach slice/controller via the `game as unknown as +{…}` cast; subscribe with `useEngineSliceVersion(layoutSlice)`. Fixed-corner dev panel, `pointer-events-auto`, +local `ui/*` styling. **Preview toggle:** a checkbox that calls `view.tree/flowers/fruits.showAll()` +(on) / the bare reveal-prep (off). + +### U2 β€” Palette (add) +**Files:** `IslandEditorPanel.tsx`. Kind + species selectors (species enums sourced from the view +modules β€” export `FRUIT_SPECIES` keys, `Flowers.SPECIES` ids, oak/cherry). Add β†’ `{ id: \`${kind}- +${uuid}\`, kind, species, x, z, yaw:0, scale:1 }` at the camera-target XZ (or `0,0`) clamped to +`isPlaceable` β†’ `addObject` + push an add/remove command; auto-select. + +### U3 β€” Engine add/remove reconcile (incl. tree rebuild) +**Files:** `Tree.js`, `Flowers.js`, `Fruits.js` (reconcile + teardown), `Mailbox.js`/`Telescope.js` +(reposition), `EditController.js` (subscribe), `editableViews.js` (route `spawn`/`remove`). +- **EditController:** on layout `objectAdded`/`objectRemoved`/`layoutReplaced`, call the affected kind's + `ensureFromLayout(listByKind(kind))`. (002's `objectUpdated`β†’`applyTransform` path stays.) +- **Flowers.ensureFromLayout(objs):** diff `this.flowers` by layout `id`; build new via + `_buildFlowerFromObject(obj)` (extracted from `_buildOne`); dispose+remove gone ones. Reveal new ones. +- **Fruits.ensureFromLayout(objs):** same on `this.entries` (`_buildBushFromObject`). +- **Tree.ensureFromLayout(objs):** `Tree._teardownPlacements()` (scene.remove + dispose each + `entry.group` trunk; remove+dispose every `_leafMeshes` InstancedMesh; clear `entries`/ + `_leafMeshBySpecies`/`_leafMeshes`) then `_placeAll()` (layout-driven, 001); re-apply hide/show. Brief + flash OK. **Escape hatch:** if teardown leaks GPU resources or `_placeAll` throws (e.g. disposed + `leafCloudGeo`), STOP & report β€” do **not** do incremental InstancedMesh surgery. +- **Mailbox/Telescope:** `move()` to the object's x,z; ignore add/remove. +- **editableViews:** `spawn`/`remove` delegate to the kind's `ensureFromLayout`. + +### U4 β€” Inspector + delete +**Files:** `IslandEditorPanel.tsx`. Observe 002 `Selection` β†’ read `layout.get(id)`. Number fields +`x/z/yaw/scale` (Base UI `NumberField` or styled inputs) + species `Select` + `locked` toggle; on +(debounced) change β†’ `EditController.applyTransform(id, patch)` (transform) or `updateObject` (species/ +locked); species change β†’ reconcile (treat as remove+add of that object via `ensureFromLayout`). Delete +button β†’ `removeObject(id)` + command. Read-only `id`/`kind`. + +### U5 β€” Undo/redo + divergence badge + revert +**Files:** `IslandEditorPanel.tsx`. β†Ά/β†· β†’ `commandStack.undo/redo` (disabled when empty). A badge when +`layout.isDiverged()` true ("Local edits β€” differs from committed default"); a "Revert to default" +button β†’ `layout.revertToDefault()` (confirm first). Optional `g`-free keyboard: `cmd/ctrl+z` undo. + +### U6 β€” Tests + gates +**Files:** `test/engine/IslandEditor.spawn.test.ts`, `test/components/IslandEditorPanel.test.tsx`. +**Scenarios:** dev-gate (renders under `#editor`+DEV, else `null`); add β†’ `addObject` + `ensureFromLayout` +spawns (assert `Flowers.flowers`/`Fruits.entries` grew, group in scene); delete β†’ despawn (group removed+ +disposed); inspector edit β†’ `updateObject` + mesh moves; species swap reconciles; **tree rebuild**: +`Tree.ensureFromLayout` after an add yields a new `entries` member + a rebuilt InstancedMesh, no stale +groups; undo/redo of add/delete; preview toggle calls `showAll`; revert restores default + clears badge; +unmount calls `deactivate()`. +**Verify:** +```bash +pnpm test test/engine/IslandEditor.spawn.test.ts test/components/IslandEditorPanel.test.tsx +pnpm test ; pnpm check +pnpm build # succeeds AND excludes the panel (DEV-stripped) +pnpm dev # /#editor: add/move/rotate/scale/inspect/delete/undo/revert/preview all work; reload persists; / unaffected +``` +Patterns: `test/components/*.test.tsx`, `test/engine/Sprouts.pickPlant.test.ts`. + +--- + +## System-Wide Impact + +- **Production safety:** `import.meta.env.DEV` mount gate + hash gate; `pnpm build` must strip it. No + SideRail, no student exposure. +- **Student pick-and-plant:** untouched (002 guards it off under `#editor`). +- **Tree rebuild cost:** infrequent dev action; brief flash accepted. If thrashing during rapid edits, + debounce in `ensureFromLayout`. +- **Persistence:** edits flow to the 001 working copy; revert/badge reflect divergence. Export = 004. + +## Risks +| Risk | Mitigation | +|---|---| +| Tree InstancedMesh rebuild leaks/throws | U3 escape hatch (STOP+report; never index-surgery); dispose idiom from Mailbox/Telescope/Flowers | +| Editor ships to prod | DEV mount gate + `pnpm build` strip verification | +| Species swap is structural | route via `ensureFromLayout` (remove+add of the one object) | +| shadcn rule | Base UI + local `ui/*`; no shadcn | + +## Done Criteria +1. `pnpm check`+`pnpm test`+`pnpm build` green; new tests pass; prior suites unaffected; bundle excludes + the panel. 2. `/#editor`: add/move/rotate/scale/inspect/delete/undo/redo/revert/preview all work and + survive reload (001 working copy). 3. `/` (no hash): panel absent, student experience unchanged. + +## Sources +Overview/001/002. Mount `EngineHost.tsx:319`; overlay/`WorldIconButton` `IslandProgressionOverlay.tsx`; +`use-engine.ts`, `use-engine-slice-version.ts`; gate `Debug.js:33-36`. Build entry points `Tree.js:415/510`, +`Flowers.js:378/448-456`, `Fruits.js:92`; dispose `Mailbox.js:249`/`Telescope.js:188`; preview `*.showAll`. +Tests `test/components/*.test.tsx`, `test/engine/Sprouts.pickPlant.test.ts`. CLAUDE.md (Base UI, no shadcn). diff --git a/docs/plans/2026-06-15-004-feat-island-layout-export-default-pipeline-plan.md b/docs/plans/2026-06-15-004-feat-island-layout-export-default-pipeline-plan.md new file mode 100644 index 0000000..f645017 --- /dev/null +++ b/docs/plans/2026-06-15-004-feat-island-layout-export-default-pipeline-plan.md @@ -0,0 +1,174 @@ +--- +title: Island Editor β€” layout export, committed default & decorOffsets uuid re-key +type: feat +status: proposed +date: 2026-06-15 +revised: 2026-06-15 (post design-review β€” offset re-key promoted to in-scope; two artifacts) +written_against_commit: 22856862 +part_of: 2026-06-15-000-feat-island-editor-engine-overview.md +plan_index: 004 +depends_on: [001, 003] +--- + +# Island Editor β€” layout export β†’ committed default (+ offset re-key) + +## Overview + +Close the loop for the **placement** artifact: **export** the edited `IslandLayout` to JSON, ship it +as the committed **`defaultIslandLayout.json`** the app boots from (repointing 001's base), and +**re-key the per-student `decorOffsets` from index β†’ stable uuid** so a student's moved objects survive +a designer adding/removing/reordering the defaults. (The **species palette** artifact + its export are +plan 005 β€” separate file, independent.) + +> Read `…-000-…-overview.md`; confirm 001 + 003 merged. Locked: dev tool + committed file; **two +> separate artifacts**; **offset re-key is in-scope** (not optional) because full add/remove makes the +> index desync real; **uuid ids**. + +--- + +## Preconditions / drift check (DO FIRST) + +1. **001 + 003 merged.** `IslandLayout` has `serialize/setLayout/revertToDefault/list`; objects carry + **stable uuid** ids; `Game/Data/islandLayout.js` exports `defaultIslandLayout()` + + `defaultIslandLayoutFromConstants()`; `IslandEditorPanel.tsx` exists. +2. Anchors: `Persistence._exportJson:153`/`_importJson:168` (Blob download / file-input reload pattern); + `Sprouts.decorOffsets:100` (index-keyed `{trees,flowers,fruits,mailbox,telescope}`), + `getDecorOffset:282`/`setDecorOffset:263`/`serialize:490`/`hydrate:424`; `View/Sprouts.js:618-681` + (applies by index); each view record carries its layout `id` (001 U5/U6); `IslandSnapshotBridge.js` + (`{v,sprouts}` POST); `schema.ts:583` (`vipsIslandSnapshots`, free-form `payload_json`). +3. **STOP and report** if `decorOffsets` is already id-keyed, or the editor panel/layout APIs are absent. + +--- + +## Requirements Trace + +- **R1.** Editor **Export layout** (download the live `IslandLayout` JSON) + **Import layout** (load β†’ + `setLayout`), reusing the `Persistence` export/import idiom. +- **R2.** A committed **`defaultIslandLayout.json`** exists; `defaultIslandLayout()` returns it (merged + through `mergeIslandLayout`), falling back to `defaultIslandLayoutFromConstants()` if empty/invalid. +- **R3.** Replacing the committed JSON with an exported edit changes the island every user boots, with + no other code change; equal to the seed β†’ visual no-op. +- **R4.** **`decorOffsets` re-keyed index β†’ stable layout uuid**, with a one-time hydrate migration; + the shipped pick-and-plant keeps working; `Sprouts.pickPlant.test.ts` + `IslandSnapshotBridge.test.ts` + stay green; offsets whose object was deleted are dropped. +- **R5.** Tests cover export round-trip, default-from-JSON, parity guard, and the offset migration. + `pnpm check`+`pnpm test`+`pnpm build` pass. +- **R6. (Deferred / forward-looking, not built here):** the server snapshot payload *may* later carry + the layout (`{v,sprouts,islandLayout}`) β€” documented, not implemented (committed file + local working + copy meet the dev-tool goal). + +--- + +## Scope Boundaries + +**In:** layout export/import; committed `defaultIslandLayout.json` + load + parity guard; the +`decorOffsets` uuid re-key migration. +**Not in:** the species palette artifact/export (005); a server authoring API; per-student authored +layouts as a feature; asset import; terrain. + +--- + +## Key Technical Decisions + +1. **Authored default = committed file; per-student edits = local/server override layer.** Reviewed, + versioned code artifact β€” appropriate for something that defines the island for every student. +2. **Export is layout-only** (`{v,objects}`); import calls `setLayout` live (no reload). +3. **Parity guard, not equality.** Committed JSON starts equal to `defaultIslandLayoutFromConstants()` + (verified once, then the seed-equality assertion is `it.skip`-guarded so an intentional edit passes); + the ongoing test asserts the JSON is a **valid, non-empty** layout containing `mailbox-0` + + `telescope-0` and β‰₯1 of each editable kind, so a corrupt/empty file fails. +4. **Offset re-key is in-scope** (locked): change `decorOffsets` to a flat **id-keyed** map; migrate + legacy index-keyed snapshots once on hydrate. Keeps the authored-base (layout) and per-student-override + (`decorOffsets`) **layers separate** β€” only the override's *addressing* changes (index β†’ uuid). + +--- + +## Implementation Units + +### U1 β€” Export / Import layout JSON +**Files:** `IslandEditorPanel.tsx` (+ optional `src/lib/student-space/island-layout-io.ts`). +**Export:** `state.islandLayout.serialize()` β†’ `JSON.stringify(…, null, 2)` β†’ download +`island-layout-.json` (Blob/`` recipe from `Persistence._exportJson`). **Import:** +file input β†’ `FileReader` β†’ `JSON.parse` β†’ `state.islandLayout.setLayout(parsed)` (slice's +`mergeIslandLayout` validates; `layoutReplaced` triggers the 003 reconcile β€” no reload). Two buttons in +the panel header (sit alongside 005's palette export/import). + +### U2 β€” Committed default + load + parity guard +**Files:** create `Game/Data/defaultIslandLayout.json` (seed = serialized +`defaultIslandLayoutFromConstants()` β€” generate via a one-off test/script and paste; its uuids become +the **frozen** default ids); modify `Game/Data/islandLayout.js`: +```js +import committed from './defaultIslandLayout.json' +import { mergeIslandLayout } from '../State/schema.js' +export function defaultIslandLayout() { + const m = mergeIslandLayout(committed) + return (m && m.objects.length > 0) ? m : defaultIslandLayoutFromConstants() +} +``` +Create `test/engine/defaultIslandLayout.json.test.ts`: valid + non-empty + contains `mailbox-0`/ +`telescope-0` + β‰₯1 per editable kind; a seed-parity assertion (`it.skip`-guarded, with a comment) that +the committed JSON deep-equals `defaultIslandLayoutFromConstants()` at seed time. +**Done:** boots identically from the JSON (no-op vs 001); replacing it with an exported edit changes the +boot island with no other code change. **Escape hatch:** if Vite doesn't bundle the JSON import in prod, +switch to a `.js` module exporting the object; verify with `pnpm build`. + +### U3 β€” `decorOffsets` re-key (index β†’ uuid) + migration *(in-scope; HIGH-touch)* +**Files:** `State/Sprouts.js` (`decorOffsets` shape + `get/setDecorOffset` + `serialize` + `hydrate` +migration), `State/schema.js` (offset merge), `View/Sprouts.js:618-681` (apply by id). +- Change `decorOffsets` from `{ trees:{0:{x,z}} }` to flat `{ 'tree-0':{x,z}, 'flower-11':{x,z} }` + (layout uuid). `setDecorOffset(id,pos)`/`getDecorOffset(id)`. +- **Migration in `Sprouts.hydrate`:** detect the legacy `{trees:…}` shape and convert + `{kind}[index] β†’ the layout id for that (kind,index)` (the default's frozen id, e.g. + `tree-${index}`). Drop entries whose object no longer exists. Keep a one-release read of the legacy + shape as a safety net. +- `View/Sprouts.js`: when applying offsets (`_installDecorHitTargets` / `_applyDecorMove`), look up each + entry's layout `id` (carried from 001) and read/write by id. +- The snapshot payload now carries id-keyed offsets β€” backward-compatible because the server stores it + opaquely and the migration handles old reads. +**Escape hatch:** if the re-key ripples beyond these files, or `Sprouts.pickPlant.test.ts` can't stay +green with a contained change, **STOP** β€” ship 004 without U3 (index model keeps working until a +*default* object is removed/reordered in a shipped layout) and document the limitation in the overview; +U3 becomes its own plan. +**Tests:** legacy index-keyed snapshot migrates to id-keyed; a moved object stays put after the layout +adds another of the same kind; existing pick-and-plant scenarios pass re-keyed. + +### U4 β€” Tests + gates +**Files:** `test/engine/IslandLayout.export.test.ts`, `defaultIslandLayout.json.test.ts` (U2), U3 tests. +**Verify:** +```bash +pnpm test test/engine/IslandLayout.export.test.ts test/engine/defaultIslandLayout.json.test.ts +pnpm test # Sprouts.pickPlant.test.ts + IslandSnapshotBridge.test.ts MUST stay green +pnpm check ; pnpm build +pnpm dev # /#editor: edit β†’ Export β†’ replace defaultIslandLayout.json β†’ reload (no #editor) β†’ new island boots +``` +Patterns: `test/engine/Sprouts.test.ts`, `IslandSnapshotBridge.test.ts`. + +--- + +## System-Wide Impact + +- **Boot default** comes from the committed JSON; a bad/empty file falls back to the constants seed + (never boots empty). +- **Pick-and-plant** continues working; offsets are now uuid-keyed and survive default add/remove/reorder. +- **Snapshot** payload now carries id-keyed offsets (opaque to the server; migration handles old reads). +- **Provenance/release gate:** no assets added; ambient-visual rebuild untouched. + +## Risks +| Risk | Mitigation | +|---|---| +| Committed JSON corrupt/empty | U2 fallback + validity guard; review JSON like code | +| U3 breaks shipped pick-and-plant | U3 escape hatch β†’ ship 004 without U3; keep `Sprouts.pickPlant`/`IslandSnapshotBridge` green | +| JSON not bundled in prod | U2 escape hatch (`.js` module); `pnpm build` check | + +## Done Criteria +1. `pnpm check`+`pnpm test`+`pnpm build` green; new tests pass; `Sprouts.*`/`IslandSnapshotBridge.*` + green. 2. Export downloads a valid layout; Import live-updates. 3. Replacing `defaultIslandLayout.json` + with an exported edit changes the boot island (verified in `pnpm dev`, no `#editor`), no other code + changed. 4. A legacy snapshot migrates and a moved object survives an add of the same kind (or, if U3 + deferred, the limitation is documented in the overview). + +## Sources +Overview/001/003. Export/import `Persistence.js:153/168`. Default module `Game/Data/islandLayout.js`; +merge `schema.js`. `decorOffsets` `Sprouts.js:100/263/282/490/424`; apply `View/Sprouts.js:618-681`. +Snapshot `IslandSnapshotBridge.js`, `island-snapshot.handler.server.ts`, `schema.ts:583`, +`function-schemas.ts`. Tests `test/engine/Sprouts.test.ts`, `IslandSnapshotBridge.test.ts`. diff --git a/docs/plans/2026-06-15-005-feat-island-species-palette-plan.md b/docs/plans/2026-06-15-005-feat-island-species-palette-plan.md new file mode 100644 index 0000000..c0c5e9c --- /dev/null +++ b/docs/plans/2026-06-15-005-feat-island-species-palette-plan.md @@ -0,0 +1,172 @@ +--- +title: Island Editor β€” species palette (data model, live recolor, editing UI, export) +type: feat +status: proposed +date: 2026-06-15 +written_against_commit: 22856862 +part_of: 2026-06-15-000-feat-island-editor-engine-overview.md +plan_index: 005 +depends_on: [001, 003] +--- + +# Island Editor β€” species palette + +## Overview + +The second authored artifact (parallel to placement): make each species' **colors** data-driven and +editable in the dev editor β€” the oak/cherry two-tone leaves, the 6 flower palettes +(petal/centre/face), and the 6 fruit colors β€” applied **live** to materials, persisted as a +**working copy** over a committed **`defaultSpeciesPalette.json`** base, with its editing controls in +the 003 panel and its own export. + +**v1 = recolor existing species only.** No new species, no geometry, no per-instance color (those are +deferred). This mirrors 001's data-model + 003's panel + 004's export pattern, applied to appearance. + +> Read `…-000-…-overview.md`; confirm 001 + 003 merged. Locked: **colors of existing species only**; +> separate committed artifact; same working-copy/divergence/revert model as the layout. + +--- + +## Preconditions / drift check (DO FIRST) + +1. **001 + 003 merged** (slice idiom + working-copy/divergence pattern; the `IslandEditorPanel`). +2. Confirm the color anchors + material types (all live-mutable): + - **Trees** `Tree.js:50-53` `OAK_COLOR_A=0x3A7D2A`,`OAK_COLOR_B=0x8AAA35`,`CHERRY_COLOR_A=0xFF66A3`, + `CHERRY_COLOR_B=0xFFCC66`; `makeLeavesMaterial:246` β†’ `ShaderMaterial` uniforms `uColorA`/`uColorB` + (set live via `material.uniforms.uColorA.value.set(hex)`). + - **Flowers** `Flowers.js:20-27` `SPECIES` (`daisy{petal,centre}`,`tulip{petal}`,`rose{petal}`, + `lily{petal,centre}`,`pansy{petal,face}`,`hyacinth{petal}`); blooms built per-flower via + `SHAPE_BUILDERS`/`lambert(color)`. **Recolor = re-skin** (precedent: `setFirstSpeciesForEmotion:435` + rebuilds flower 0's bloom). + - **Fruits** `Fruits.js:23-32` `FRUIT_SPECIES` (`apple:0xD64242`…`berry:0xB02A5E`); per-species shared + `_berryMats[id]` `MeshLambertMaterial` (recolor live via `_berryMats[id].color.set(hex)`). +3. **STOP and report** if a `SpeciesPalette` slice / `defaultSpeciesPalette*` already exists, or the + color constants moved. + +--- + +## Requirements Trace + +- **R1.** Typed serializable `SpeciesPalette` `{ v, tree:{oak,cherry}, flower:{…}, fruit:{…} }` where each + species maps to its colors (tree `{colorA,colorB}`; flower `{petal,centre?,face?}`; fruit `{color}`). + `defaultSpeciesPalette()` reproduces today's constants exactly. +- **R2.** A `SpeciesPalette` slice (same working-copy-over-committed-base model as 001): + `get(kind,species)`, `setColor(kind,species,colors)`, `list()`, `revertToDefault()`, `isDiverged()`, + `subscribe`, `hydrate`, `serialize`; fans `paletteChanged`. +- **R3.** Views read species colors from the slice at build; on `paletteChanged`, recolor **live** β€” + fruits via `material.color`, trees via shader uniforms, flowers via bloom re-skin. +- **R4.** Palette editing controls mount in the 003 `IslandEditorPanel`: a color field per + species/slot, a "diverged" badge + revert, and Export/Import of the palette JSON. +- **R5.** A committed **`defaultSpeciesPalette.json`**; `defaultSpeciesPalette()` returns it (merged), + falling back to the constants seed. +- **R6.** Lifecycle/dispose clean; dev-gated (rides the 003 panel's `#editor` gate). Tests cover model, + default parity, slice + divergence/revert, live apply per kind, export round-trip. `pnpm check`+ + `pnpm test`+`pnpm build` pass. + +--- + +## Scope Boundaries + +**In:** colors of existing species (tree/flower/fruit) β€” model, slice, live apply, editing UI, committed +artifact + export. +**Not in:** new species / geometry; per-instance color (palette is per-species/shared); mailbox/ +telescope colors (one-offs, out of v1); non-color params (scale defaults, bloom size β€” deferred); +ambient visuals (grass/sky β€” provenance rebuild, untouched). + +--- + +## Key Technical Decisions + +1. **Per-species (shared), not per-instance** (locked B). Editing oak's colorA recolors all oaks. Stored + in the palette artifact, separate from the placement layout. +2. **Colors are live-mutable** β€” fruits trivial (`_berryMats[id].color.set`), trees easy (uniform + `.value.set`), flowers via re-skin (generalize `setFirstSpeciesForEmotion`). +3. **Same working-copy/divergence/revert + committed-file model as 001/004** β€” consistency; a dev's + recolors survive reload; Export writes `defaultSpeciesPalette.json`. +4. **Mirrors the slice ceremony** (slice Β· schema merger Β· State construct/hydrate Β· Persistence + KEY/SLICES Β· Game.dispose Β· `.d.ts`). + +--- + +## Implementation Units + +### U1 β€” Data model + default +**Files:** create `Game/Data/speciesPalette.js` (+ `.d.ts`). `defaultSpeciesPalette()` built from the +constants (R1 anchors). Export the constants from the view modules (or re-declare + parity test, as in +001 U1). Colors as `#rrggbb` strings (convert from the `0x` numbers). + +### U2 β€” Schema merger +**Files:** `State/schema.js`. `mergeSpeciesPalette(raw)` β€” lenient: known kinds/species, color strings +validated (`#rrggbb`), unknown dropped with `warn`; missing β†’ default. Mirror `mergeSprout`/ +`mergeIslandLayout`. + +### U3 β€” `SpeciesPalette` slice (working-copy model) +**Files:** create `Game/State/SpeciesPalette.js` (+ `.d.ts`). Mirror 001's slice: base = +`defaultSpeciesPalette()`; working copy in localStorage; `get/setColor/list/isDiverged/revertToDefault/ +subscribe/hydrate/serialize/_persist`; `setColor` fans `{type:'paletteChanged', kind, species, colors}`. + +### U4 β€” Persistence/State/dispose/types + live apply +**Files:** `Persistence.js` (`speciesPalette` in `KEY`/`SLICES`/`empty`); `State.js` (construct + hydrate +`this.speciesPalette`); `Game.js` dispose; `SpeciesPalette.d.ts`. **Live apply** in the views: +- Build: each view reads its species colors from `state.speciesPalette.get(kind, species)` instead of + the constant (constant becomes the default seed). +- On `paletteChanged`: **Fruits** `_berryMats[species].color.set(hex)`; **Trees** find the species' leaf + `ShaderMaterial` β†’ `uniforms.uColorA/uColorB.value.set(hex)`; **Flowers** re-skin blooms of that + species (generalize `setFirstSpeciesForEmotion`'s dispose+rebuild to all flowers whose + `species.id === changed`). +- Subscribe in each view's constructor; unsubscribe in dispose. +**Escape hatch (flowers):** if live re-skin of all flowers of a species is too invasive, apply flower +recolors on next reload (the palette persists; the build path reads it) and report β€” trees/fruits stay +live. Do not block on flowers. + +### U5 β€” Palette editing UI (in the 003 panel) +**Files:** `src/components/student-space/editor/IslandEditorPanel.tsx` (add a "Palette" section) or a +sibling `SpeciesPaletteControls.tsx` it renders. A grouped list (tree/flower/fruit β†’ species β†’ color +field(s)) bound to `useEngineSliceVersion(state.speciesPalette)`; change β†’ `setColor` (+ undo command via +002's stack); a "diverged" badge + "Revert palette"; Export/Import buttons (download/load +`species-palette-.json` β†’ `setLayout`-equivalent on the palette slice). Use Base UI / local +`ui/*` (no shadcn). + +### U6 β€” Committed default + tests + gates +**Files:** create `Game/Data/defaultSpeciesPalette.json` (seed = serialized +`defaultSpeciesPaletteFromConstants()`); repoint `defaultSpeciesPalette()` to load it with the +const-fallback + validity guard (mirror 004 U2). Tests: `test/engine/SpeciesPalette.test.ts` (default +parity vs constants; `setColor` + `paletteChanged`; divergence/revert; serialize round-trip; working-copy +hydrate), `test/engine/SpeciesPalette.apply.test.ts` (fruit `_berryMats` color updates; tree uniform +updates; flower re-skin β€” or the reload fallback), `defaultSpeciesPalette.json.test.ts` (valid+non-empty). +**Verify:** +```bash +pnpm test test/engine/SpeciesPalette.test.ts test/engine/SpeciesPalette.apply.test.ts test/engine/defaultSpeciesPalette.json.test.ts +pnpm test ; pnpm check ; pnpm build +pnpm dev # /#editor: recolor a fruit/tree/flower live; Export β†’ commit defaultSpeciesPalette.json β†’ reload β†’ new colors boot +``` +Patterns: `test/engine/Sprouts.test.ts` (slice), `IslandLayout.test.ts` (working-copy, from 001). + +--- + +## System-Wide Impact + +- **Two committed artifacts now** (layout from 004 + palette here); the panel's Export writes both. Each + loads independently with its own fallback. +- **Provenance:** the recolored materials (tree foliage MIT; flowers/fruits own) are release-clean; the + palette does not touch the must-rebuild ambient shaders. +- **Persistence:** one more working-copy slice (~small JSON). + +## Risks +| Risk | Mitigation | +|---|---| +| Flower live re-skin invasive | U4 escape hatch (reload fallback for flowers; trees/fruits live) | +| Import cycle (palette ↔ view consts) | re-declare + parity test (as 001) | +| JSON not bundled in prod | `.js` module fallback; `pnpm build` check | + +## Done Criteria +1. `pnpm check`+`pnpm test`+`pnpm build` green; new tests pass; prior suites unaffected. +2. `defaultSpeciesPalette()` reproduces today's colors (no-op). 3. `/#editor`: recoloring a tree/fruit + (and flower, or via reload fallback) updates the island live; Export β†’ committed JSON β†’ reload boots + the new colors. 4. Divergence badge + revert behave; palette is DEV-only. + +## Sources +Overview/001/003/004. Colors: `Tree.js:50-53/246`, `Flowers.js:20-27/435`, `Fruits.js:23-32` (`_berryMats`). +Slice idiom + working-copy: `Sprouts.js`, plan 001 `IslandLayout`. Persistence `Persistence.js:33/47/234`. +Panel `IslandEditorPanel.tsx` (003). Export `Persistence.js:153/168`. Tests `test/engine/Sprouts.test.ts`, +`IslandLayout.test.ts`. CLAUDE.md (Base UI, no shadcn). diff --git a/island-editor/.gitignore b/island-editor/.gitignore new file mode 100644 index 0000000..a4d699a --- /dev/null +++ b/island-editor/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.DS_Store diff --git a/island-editor/index.html b/island-editor/index.html new file mode 100644 index 0000000..3e7d90c --- /dev/null +++ b/island-editor/index.html @@ -0,0 +1,22 @@ + + + + + + Island Editor + + + +
+ + + diff --git a/island-editor/package.json b/island-editor/package.json new file mode 100644 index 0000000..5ee24d1 --- /dev/null +++ b/island-editor/package.json @@ -0,0 +1,35 @@ +{ + "name": "island-editor", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Standalone, purpose-built island shape editor (r3f + drei). Isolated from the product app; exports an engine-agnostic island spec.", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@react-three/drei": "^10.0.0", + "@react-three/fiber": "^9.0.0", + "leva": "^0.10.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "three": "^0.171.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.171.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + } +} diff --git a/island-editor/pnpm-lock.yaml b/island-editor/pnpm-lock.yaml new file mode 100644 index 0000000..259086c --- /dev/null +++ b/island-editor/pnpm-lock.yaml @@ -0,0 +1,2670 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@react-three/drei': + specifier: ^10.0.0 + version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.171.0))(@types/react@19.2.17)(@types/three@0.171.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.171.0) + '@react-three/fiber': + specifier: ^9.0.0 + version: 9.6.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.171.0) + leva: + specifier: ^0.10.0 + version: 0.10.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + three: + specifier: ^0.171.0 + version: 0.171.0 + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@types/three': + specifier: ^0.171.0 + version: 0.171.0 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.3 + vitest: + specifier: ^3.0.0 + version: 3.2.6 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mediapipe/tasks-vision@0.10.17': + resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} + + '@monogrid/gainmap-js@3.4.0': + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-arrow@1.1.9': + resolution: {integrity: sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.12': + resolution: {integrity: sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popper@1.3.0': + resolution: {integrity: sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.11': + resolution: {integrity: sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.5': + resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.5': + resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tooltip@1.2.9': + resolution: {integrity: sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.2': + resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.5': + resolution: {integrity: sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + + '@react-three/drei@10.7.7': + resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} + peerDependencies: + '@react-three/fiber': ^9.0.0 + react: ^19 + react-dom: ^19 + three: '>=0.159' + peerDependenciesMeta: + react-dom: + optional: true + + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.0': + resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.0': + resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.0': + resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.0': + resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.0': + resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.0': + resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.0': + resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.0': + resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.0': + resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.0': + resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.0': + resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.0': + resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.0': + resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.0': + resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} + cpu: [x64] + os: [win32] + + '@stitches/react@1.2.8': + resolution: {integrity: sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==} + peerDependencies: + react: '>= 16.3.0' + + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.171.0': + resolution: {integrity: sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + + '@webgpu/types@0.1.70': + resolution: {integrity: sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + assign-symbols@1.0.0: + resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==} + engines: {node: '>=0.10.0'} + + attr-accept@2.2.5: + resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} + engines: {node: '>=4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.37: + resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + camera-controls@3.1.2: + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} + engines: {node: '>=22.0.0', npm: '>=10.5.1'} + peerDependencies: + three: '>=0.126.1' + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-gpu@5.0.70: + resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + + electron-to-chromium@1.5.372: + resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend-shallow@3.0.2: + resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==} + engines: {node: '>=0.10.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + file-selector@0.5.0: + resolution: {integrity: sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==} + engines: {node: '>= 10'} + + for-in@1.0.2: + resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} + engines: {node: '>=0.10.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-value@2.0.6: + resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} + engines: {node: '>=0.10.0'} + + glsl-noise@0.0.0: + resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} + + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extendable@1.0.1: + resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==} + engines: {node: '>=0.10.0'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + leva@0.10.1: + resolution: {integrity: sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + maath@0.10.8: + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-value@1.0.0: + resolution: {integrity: sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==} + engines: {node: '>=0.10.0'} + + meshline@3.3.1: + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==} + peerDependencies: + three: '>=0.137' + + meshoptimizer@0.18.1: + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + + mixin-deep@1.3.2: + resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} + engines: {node: '>=0.10.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + + promise-worker-transferable@1.0.4: + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + react-colorful@5.7.0: + resolution: {integrity: sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-dropzone@12.1.0: + resolution: {integrity: sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rollup@4.62.0: + resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-value@2.0.1: + resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split-string@3.1.0: + resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + + three-mesh-bvh@0.8.3: + resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==} + peerDependencies: + three: '>= 0.159.0' + + three-stdlib@2.36.1: + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} + peerDependencies: + three: '>=0.128.0' + + three@0.171.0: + resolution: {integrity: sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + troika-three-text@0.52.4: + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} + peerDependencies: + three: '>=0.125.0' + + troika-three-utils@0.52.4: + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} + peerDependencies: + three: '>=0.125.0' + + troika-worker-utils@0.52.0: + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + v8n@1.5.1: + resolution: {integrity: sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webgl-constants@1.1.1: + resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} + + webgl-sdf-generator@1.1.1: + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zustand@3.7.2: + resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==} + engines: {node: '>=12.7.0'} + peerDependencies: + react: '>=16.8' + peerDependenciesMeta: + react: + optional: true + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mediapipe/tasks-vision@0.10.17': {} + + '@monogrid/gainmap-js@3.4.0(three@0.171.0)': + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.171.0 + + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-arrow@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-popper@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.2.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tooltip@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.2': {} + + '@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.171.0))(@types/react@19.2.17)(@types/three@0.171.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.171.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.171.0) + '@react-three/fiber': 9.6.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.171.0) + '@use-gesture/react': 10.3.1(react@19.2.7) + camera-controls: 3.1.2(three@0.171.0) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.6.16 + maath: 0.10.8(@types/three@0.171.0)(three@0.171.0) + meshline: 3.3.1(three@0.171.0) + react: 19.2.7 + stats-gl: 2.4.2(@types/three@0.171.0)(three@0.171.0) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@19.2.7) + three: 0.171.0 + three-mesh-bvh: 0.8.3(three@0.171.0) + three-stdlib: 2.36.1(three@0.171.0) + troika-three-text: 0.52.4(three@0.171.0) + tunnel-rat: 0.1.2(@types/react@19.2.17)(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + utility-types: 3.11.0 + zustand: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer + + '@react-three/fiber@9.6.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(three@0.171.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-use-measure: 2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.7) + three: 0.171.0 + use-sync-external-store: 1.6.0(react@19.2.7) + zustand: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.0': + optional: true + + '@rollup/rollup-android-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-x64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.0': + optional: true + + '@stitches/react@1.2.8(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@tweenjs/tween.js@23.1.3': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/draco3d@1.4.10': {} + + '@types/estree@1.0.9': {} + + '@types/offscreencanvas@2019.7.3': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react-reconciler@0.28.9(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.171.0': + dependencies: + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.70 + fflate: 0.8.3 + meshoptimizer: 0.18.1 + + '@types/webxr@0.5.24': {} + + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.2.7)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.2.7 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.6(vite@6.4.3)': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3 + + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@webgpu/types@0.1.70': {} + + assertion-error@2.0.1: {} + + assign-symbols@1.0.0: {} + + attr-accept@2.2.5: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.37: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.37 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.372 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + camera-controls@3.1.2(three@0.171.0): + dependencies: + three: 0.171.0 + + caniuse-lite@1.0.30001799: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + colord@2.9.3: {} + + convert-source-map@2.0.0: {} + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + dequal@2.0.3: {} + + detect-gpu@5.0.70: + dependencies: + webgl-constants: 1.1.1 + + draco3d@1.5.7: {} + + electron-to-chromium@1.5.372: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend-shallow@3.0.2: + dependencies: + assign-symbols: 1.0.0 + is-extendable: 1.0.1 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fflate@0.6.10: {} + + fflate@0.8.3: {} + + file-selector@0.5.0: + dependencies: + tslib: 2.8.1 + + for-in@1.0.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-value@2.0.6: {} + + glsl-noise@0.0.0: {} + + hls.js@1.6.16: {} + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + is-extendable@0.1.1: {} + + is-extendable@1.0.1: + dependencies: + is-plain-object: 2.0.4 + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-promise@2.2.2: {} + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + its-fine@2.0.0(@types/react@19.2.17)(react@19.2.7): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.17) + react: 19.2.7 + transitivePeerDependencies: + - '@types/react' + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + leva@0.10.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@stitches/react': 1.2.8(react@19.2.7) + '@use-gesture/react': 10.3.1(react@19.2.7) + colord: 2.9.3 + dequal: 2.0.3 + merge-value: 1.0.0 + react: 19.2.7 + react-colorful: 5.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + react-dropzone: 12.1.0(react@19.2.7) + v8n: 1.5.1 + zustand: 3.7.2(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + maath@0.10.8(@types/three@0.171.0)(three@0.171.0): + dependencies: + '@types/three': 0.171.0 + three: 0.171.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge-value@1.0.0: + dependencies: + get-value: 2.0.6 + is-extendable: 1.0.1 + mixin-deep: 1.3.2 + set-value: 2.0.1 + + meshline@3.3.1(three@0.171.0): + dependencies: + three: 0.171.0 + + meshoptimizer@0.18.1: {} + + mixin-deep@1.3.2: + dependencies: + for-in: 1.0.2 + is-extendable: 1.0.1 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + node-releases@2.0.47: {} + + object-assign@4.1.1: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + potpack@1.0.2: {} + + promise-worker-transferable@1.0.4: + dependencies: + is-promise: 2.2.2 + lie: 3.3.0 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + react-colorful@5.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-dropzone@12.1.0(react@19.2.7): + dependencies: + attr-accept: 2.2.5 + file-selector: 0.5.0 + prop-types: 15.8.1 + react: 19.2.7 + + react-is@16.13.1: {} + + react-refresh@0.17.0: {} + + react-use-measure@2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + require-from-string@2.0.2: {} + + rollup@4.62.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.0 + '@rollup/rollup-android-arm64': 4.62.0 + '@rollup/rollup-darwin-arm64': 4.62.0 + '@rollup/rollup-darwin-x64': 4.62.0 + '@rollup/rollup-freebsd-arm64': 4.62.0 + '@rollup/rollup-freebsd-x64': 4.62.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 + '@rollup/rollup-linux-arm-musleabihf': 4.62.0 + '@rollup/rollup-linux-arm64-gnu': 4.62.0 + '@rollup/rollup-linux-arm64-musl': 4.62.0 + '@rollup/rollup-linux-loong64-gnu': 4.62.0 + '@rollup/rollup-linux-loong64-musl': 4.62.0 + '@rollup/rollup-linux-ppc64-gnu': 4.62.0 + '@rollup/rollup-linux-ppc64-musl': 4.62.0 + '@rollup/rollup-linux-riscv64-gnu': 4.62.0 + '@rollup/rollup-linux-riscv64-musl': 4.62.0 + '@rollup/rollup-linux-s390x-gnu': 4.62.0 + '@rollup/rollup-linux-x64-gnu': 4.62.0 + '@rollup/rollup-linux-x64-musl': 4.62.0 + '@rollup/rollup-openbsd-x64': 4.62.0 + '@rollup/rollup-openharmony-arm64': 4.62.0 + '@rollup/rollup-win32-arm64-msvc': 4.62.0 + '@rollup/rollup-win32-ia32-msvc': 4.62.0 + '@rollup/rollup-win32-x64-gnu': 4.62.0 + '@rollup/rollup-win32-x64-msvc': 4.62.0 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-value@2.0.1: + dependencies: + extend-shallow: 2.0.1 + is-extendable: 0.1.1 + is-plain-object: 2.0.4 + split-string: 3.1.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + split-string@3.1.0: + dependencies: + extend-shallow: 3.0.2 + + stackback@0.0.2: {} + + stats-gl@2.4.2(@types/three@0.171.0)(three@0.171.0): + dependencies: + '@types/three': 0.171.0 + three: 0.171.0 + + stats.js@0.17.0: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + suspend-react@0.1.3(react@19.2.7): + dependencies: + react: 19.2.7 + + three-mesh-bvh@0.8.3(three@0.171.0): + dependencies: + three: 0.171.0 + + three-stdlib@2.36.1(three@0.171.0): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.24 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.171.0 + + three@0.171.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + troika-three-text@0.52.4(three@0.171.0): + dependencies: + bidi-js: 1.0.3 + three: 0.171.0 + troika-three-utils: 0.52.4(three@0.171.0) + troika-worker-utils: 0.52.0 + webgl-sdf-generator: 1.1.1 + + troika-three-utils@0.52.4(three@0.171.0): + dependencies: + three: 0.171.0 + + troika-worker-utils@0.52.0: {} + + tslib@2.8.1: {} + + tunnel-rat@0.1.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + - react + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + utility-types@3.11.0: {} + + v8n@1.5.1: {} + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.62.0 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.6: + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@6.4.3) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.3 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + webgl-constants@1.1.1: {} + + webgl-sdf-generator@1.1.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yallist@3.1.1: {} + + zustand@3.7.2(react@19.2.7): + optionalDependencies: + react: 19.2.7 + + zustand@4.5.7(@types/react@19.2.17)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/island-editor/pnpm-workspace.yaml b/island-editor/pnpm-workspace.yaml new file mode 100644 index 0000000..ce1a38b --- /dev/null +++ b/island-editor/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# island-editor is its OWN isolated pnpm workspace root, deliberately separate +# from the parent sensemaking-agents workspace. pnpm stops at the first +# pnpm-workspace.yaml walking up from the CWD, so installs run here use THIS +# file β€” the product app's lockfile, overrides, and deps are never touched. +# `allowBuilds` approves dependency build scripts for this isolated package. +allowBuilds: + esbuild: true diff --git a/island-editor/src/App.tsx b/island-editor/src/App.tsx new file mode 100644 index 0000000..d55aa69 --- /dev/null +++ b/island-editor/src/App.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { OrbitControls } from '@react-three/drei' +import { Canvas } from '@react-three/fiber' +import type { Camera, Vector3 } from 'three' +import { createCommandStack } from './editor/commandStack' +import { downloadSpec, importSpecFromFile } from './editor/exportSpec' +import { clearSaved, createAutosaver, loadSpec } from './editor/persistence' +import { Backdrop } from './scene/Backdrop' +import { CoastlineHandles } from './scene/CoastlineHandles' +import { Sea } from './scene/Sea' +import { Terrain } from './scene/Terrain' +import { applyBrush, type BrushParams } from './terrain/brush' +import { + type HeightProfile, + type IslandSpec, + type ReliefGrid, + seedFromCurrentIsland, + type Vec2, +} from './terrain/islandSpec' +import { type EditMode, ToolPanel } from './ui/ToolPanel' + +const SAVED = loadSpec() +const INITIAL: IslandSpec = SAVED ?? seedFromCurrentIsland() + +const autosave = createAutosaver() + +/** Minimal shape of the three OrbitControls instance drei forwards. */ +type OrbitControlsLike = { object: Camera; target: Vector3; update: () => void } + +export function App() { + const [mode, setMode] = useState('shape') + const [coastline, setCoastline] = useState(INITIAL.coastline) + const [profile, setProfile] = useState(INITIAL.heightProfile) + const [brush, setBrush] = useState({ radius: 3, strength: 0.3, mode: 'raise' }) + const [orbitEnabled, setOrbitEnabled] = useState(true) + + // Relief lives in a ref (mutated in place by the brush, cheaply) with a tick + // to trigger spec recompute β€” keeps brush dabs out of a React updater so + // StrictMode's double-invoke can't double-apply a stroke. + const reliefRef = useRef(INITIAL.relief) + const [reliefTick, setReliefTick] = useState(0) + const refreshRelief = useCallback(() => setReliefTick((t) => t + 1), []) + const brushRef = useRef(brush) + brushRef.current = brush + + // Mutable undo/redo history. A version counter forces the undo/redo buttons + // to re-evaluate canUndo()/canRedo() after each push/undo/redo. + const stack = useRef(createCommandStack()).current + const [, setStackVersion] = useState(0) + const bumpStack = useCallback(() => setStackVersion((v) => v + 1), []) + + const spec: IslandSpec = useMemo( + () => ({ + version: 1, + worldSize: INITIAL.worldSize, + coastline, + heightProfile: profile, + relief: { resolution: reliefRef.current.resolution, data: reliefRef.current.data }, + }), + [coastline, profile, reliefTick], + ) + + // Latest spec, kept in a ref so command-stack closures and export read the + // current value without re-subscribing. + const specRef = useRef(spec) + specRef.current = spec + + // Autosave on every spec change (debounced internally). + useEffect(() => { + autosave(spec) + }, [spec]) + + const movePoint = useCallback((index: number, next: Vec2) => { + setCoastline((pts) => pts.map((p, i) => (i === index ? next : p))) + }, []) + + // ── Coastline drag β†’ one undoable command per drag ────────────────────────── + const dragBefore = useRef(null) + const onDragChange = useCallback( + (dragging: boolean) => { + setOrbitEnabled(!dragging) + if (dragging) { + dragBefore.current = specRef.current.coastline + return + } + const before = dragBefore.current + dragBefore.current = null + if (!before) return + const after = specRef.current.coastline + if (after === before) return // no movement recorded + stack.push({ + label: 'Move coastline', + do: () => setCoastline(after), + undo: () => setCoastline(before), + }) + bumpStack() + }, + [stack, bumpStack], + ) + + // ── Brush stroke β†’ one undoable command per stroke ────────────────────────── + const strokeBefore = useRef(null) + const applyRelief = useCallback( + (data: number[]) => { + reliefRef.current = { resolution: reliefRef.current.resolution, data } + refreshRelief() + }, + [refreshRelief], + ) + const onPaintStart = useCallback(() => { + setOrbitEnabled(false) + strokeBefore.current = reliefRef.current.data.slice() + }, []) + const paint = useCallback((x: number, z: number) => { + applyBrush(reliefRef.current, INITIAL.worldSize, x, z, brushRef.current) + setReliefTick((t) => t + 1) + }, []) + const onPaintEnd = useCallback(() => { + setOrbitEnabled(true) + const before = strokeBefore.current + strokeBefore.current = null + if (!before) return + const after = reliefRef.current.data.slice() + stack.push({ + label: 'Brush stroke', + do: () => applyRelief(after), + undo: () => applyRelief(before), + }) + bumpStack() + }, [stack, bumpStack, applyRelief]) + + // ── Undo / redo ───────────────────────────────────────────────────────────── + const undo = useCallback(() => { + if (stack.undo()) bumpStack() + }, [stack, bumpStack]) + const redo = useCallback(() => { + if (stack.redo()) bumpStack() + }, [stack, bumpStack]) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null + const inEditable = + !!target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + if (inEditable) return + const mod = e.metaKey || e.ctrlKey + if (mod && e.key.toLowerCase() === 'z') { + e.preventDefault() + if (e.shiftKey) redo() + else undo() + } else if (e.ctrlKey && e.key.toLowerCase() === 'y') { + e.preventDefault() + redo() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [undo, redo]) + + // ── Reset / Export / Import ────────────────────────────────────────────────── + const reset = useCallback(() => { + clearSaved() + const fresh = seedFromCurrentIsland() + reliefRef.current = fresh.relief + setCoastline(fresh.coastline) + setProfile(fresh.heightProfile) + setReliefTick((t) => t + 1) + stack.clear() + bumpStack() + }, [stack, bumpStack]) + + const exportSpec = useCallback(() => { + downloadSpec(specRef.current) + }, []) + + const importInputRef = useRef(null) + const openImport = useCallback(() => importInputRef.current?.click(), []) + const onImportFile = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' // allow re-importing the same file + if (!file) return + try { + const imported = await importSpecFromFile(file) + reliefRef.current = imported.relief + setCoastline(imported.coastline) + setProfile(imported.heightProfile) + setReliefTick((t) => t + 1) + stack.clear() // never let undo resurrect pre-import state + bumpStack() + } catch (err) { + alert(`Could not import island: ${err instanceof Error ? err.message : String(err)}`) + } + }, + [stack, bumpStack], + ) + + // ── Top view ────────────────────────────────────────────────────────────────── + // Capture the three OrbitControls instance drei forwards via a callback ref, + // narrowed to the minimal shape we touch (no three-stdlib type dependency). + const controlsRef = useRef(null) + const setControls = useCallback((instance: OrbitControlsLike | null) => { + controlsRef.current = instance + }, []) + const topView = useCallback(() => { + const controls = controlsRef.current + if (!controls) return + const { object, target } = controls + const dist = object.position.distanceTo(target) + object.position.set(target.x, target.y + dist, target.z + 0.001) + controls.update() + }, []) + + return ( +
+ + + + + {mode === 'shape' && ( + + )} + + + + +
+ ) +} diff --git a/island-editor/src/editor/commandStack.ts b/island-editor/src/editor/commandStack.ts new file mode 100644 index 0000000..9cdb1e1 --- /dev/null +++ b/island-editor/src/editor/commandStack.ts @@ -0,0 +1,74 @@ +// Generic undo/redo command stack. +// push() records an ALREADY-APPLIED command β€” does NOT call cmd.do(). +// undo() calls the top command's undo() and moves it to the redo stack. +// redo() calls the top redo command's do() and moves it back to the undo stack. +// Capacity caps the undo stack; when exceeded the oldest entry is evicted. + +export interface Command { + label?: string + do: () => void + undo: () => void +} + +export interface CommandStack { + /** Record an already-applied command. Clears the redo stack. */ + push(cmd: Command): void + /** Undo the last command. Returns false if nothing to undo. */ + undo(): boolean + /** Redo the last undone command. Returns false if nothing to redo. */ + redo(): boolean + canUndo(): boolean + canRedo(): boolean + clear(): void + /** Number of currently-undoable commands. */ + size(): number +} + +/** @param capacity Max undo stack depth (default 200). Oldest entry is evicted when exceeded. */ +export function createCommandStack(capacity = 200): CommandStack { + const undoStack: Command[] = [] + const redoStack: Command[] = [] + + return { + push(cmd) { + undoStack.push(cmd) + if (undoStack.length > capacity) { + undoStack.shift() + } + redoStack.length = 0 + }, + + undo() { + const cmd = undoStack.pop() + if (cmd === undefined) return false + cmd.undo() + redoStack.push(cmd) + return true + }, + + redo() { + const cmd = redoStack.pop() + if (cmd === undefined) return false + cmd.do() + undoStack.push(cmd) + return true + }, + + canUndo() { + return undoStack.length > 0 + }, + + canRedo() { + return redoStack.length > 0 + }, + + clear() { + undoStack.length = 0 + redoStack.length = 0 + }, + + size() { + return undoStack.length + }, + } +} diff --git a/island-editor/src/editor/exportSpec.ts b/island-editor/src/editor/exportSpec.ts new file mode 100644 index 0000000..f783b41 --- /dev/null +++ b/island-editor/src/editor/exportSpec.ts @@ -0,0 +1,131 @@ +import type { IslandSpec, Vec2, HeightProfile, ReliefGrid } from '../terrain/islandSpec' + +// ── Serialize ──────────────────────────────────────────────────────────────── + +export function serializeSpec(spec: IslandSpec): string { + return JSON.stringify(spec, null, 2) +} + +// ── Validate + Deserialize ─────────────────────────────────────────────────── + +function isFiniteNumber(v: unknown): v is number { + return typeof v === 'number' && isFinite(v) +} + +function validateVec2(v: unknown): v is Vec2 { + if (typeof v !== 'object' || v === null) return false + const o = v as Record + return typeof o.x === 'number' && isFinite(o.x) && typeof o.z === 'number' && isFinite(o.z) +} + +function validateHeightProfile(v: unknown): v is HeightProfile { + if (typeof v !== 'object' || v === null) return false + const o = v as Record + return ( + isFiniteNumber(o.seaLevel) && + isFiniteNumber(o.plateauHeight) && + isFiniteNumber(o.coastFalloff) && + isFiniteNumber(o.cliffSteepness) && + isFiniteNumber(o.seafloorDepth) + ) +} + +function validateRelief(v: unknown): v is ReliefGrid { + if (typeof v !== 'object' || v === null) return false + const o = v as Record + if (!isFiniteNumber(o.resolution)) return false + if (!Array.isArray(o.data)) return false + const expected = (o.resolution as number) * (o.resolution as number) + if (o.data.length !== expected) return false + return (o.data as unknown[]).every((d) => typeof d === 'number') +} + +export function deserializeSpec(json: string): IslandSpec { + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch (e) { + throw new Error('Invalid island spec: malformed JSON') + } + + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Invalid island spec: root must be an object') + } + + const o = parsed as Record + + if (o.version !== 1) { + throw new Error(`Invalid island spec: version must be 1, got ${String(o.version)}`) + } + + if (!isFiniteNumber(o.worldSize)) { + throw new Error('Invalid island spec: worldSize must be a finite number') + } + + if (!Array.isArray(o.coastline) || o.coastline.length < 3) { + throw new Error( + `Invalid island spec: coastline must be an array of at least 3 points, got ${Array.isArray(o.coastline) ? o.coastline.length : typeof o.coastline}`, + ) + } + + for (let i = 0; i < (o.coastline as unknown[]).length; i++) { + if (!validateVec2((o.coastline as unknown[])[i])) { + throw new Error(`Invalid island spec: coastline[${i}] must be {x: number, z: number}`) + } + } + + if (!validateHeightProfile(o.heightProfile)) { + throw new Error( + 'Invalid island spec: heightProfile must have finite numeric fields seaLevel, plateauHeight, coastFalloff, cliffSteepness, seafloorDepth', + ) + } + + if (!validateRelief(o.relief)) { + throw new Error( + 'Invalid island spec: relief must have numeric resolution and data array of length resolution*resolution', + ) + } + + return parsed as IslandSpec +} + +// ── Download (browser-only) ────────────────────────────────────────────────── + +export function downloadSpec(spec: IslandSpec, filename?: string): void { + const json = serializeSpec(spec) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const timestamp = Date.now() + const name = filename ?? `island-${timestamp}.json` + const anchor = document.createElement('a') + anchor.href = url + anchor.download = name + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) +} + +// ── Import (browser-only) ──────────────────────────────────────────────────── + +export function importSpecFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => { + const text = e.target?.result + if (typeof text !== 'string') { + reject(new Error('Failed to read file: result is not a string')) + return + } + try { + resolve(deserializeSpec(text)) + } catch (err) { + reject(err) + } + } + reader.onerror = () => { + reject(new Error('Failed to read file')) + } + reader.readAsText(file) + }) +} diff --git a/island-editor/src/editor/persistence.ts b/island-editor/src/editor/persistence.ts new file mode 100644 index 0000000..1a69041 --- /dev/null +++ b/island-editor/src/editor/persistence.ts @@ -0,0 +1,82 @@ +import type { IslandSpec } from '../terrain/islandSpec' + +export interface StorageLike { + getItem(k: string): string | null + setItem(k: string, v: string): void + removeItem(k: string): void +} + +export const STORAGE_KEY = 'island-editor:spec:v1' + +function defaultStorage(): StorageLike | null { + if (typeof localStorage !== 'undefined') return localStorage + return null +} + +export function saveSpec(spec: IslandSpec, storage?: StorageLike | null): void { + const s = storage !== undefined ? storage : defaultStorage() + if (!s) return + s.setItem(STORAGE_KEY, JSON.stringify(spec)) +} + +function isValidSpec(obj: unknown): obj is IslandSpec { + if (typeof obj !== 'object' || obj === null) return false + const o = obj as Record + + if (o['version'] !== 1) return false + if (typeof o['worldSize'] !== 'number' || !isFinite(o['worldSize'])) return false + + if (!Array.isArray(o['coastline'])) return false + for (const pt of o['coastline'] as unknown[]) { + if (typeof pt !== 'object' || pt === null) return false + const p = pt as Record + if (typeof p['x'] !== 'number' || typeof p['z'] !== 'number') return false + } + + if (typeof o['heightProfile'] !== 'object' || o['heightProfile'] === null) return false + const hp = o['heightProfile'] as Record + for (const key of ['seaLevel', 'plateauHeight', 'coastFalloff', 'cliffSteepness', 'seafloorDepth']) { + if (typeof hp[key] !== 'number') return false + } + + if (typeof o['relief'] !== 'object' || o['relief'] === null) return false + const r = o['relief'] as Record + if (typeof r['resolution'] !== 'number') return false + if (!Array.isArray(r['data'])) return false + + return true +} + +export function loadSpec(storage?: StorageLike | null): IslandSpec | null { + try { + const s = storage !== undefined ? storage : defaultStorage() + if (!s) return null + const raw = s.getItem(STORAGE_KEY) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + if (!isValidSpec(parsed)) return null + return parsed + } catch { + return null + } +} + +export function clearSaved(storage?: StorageLike | null): void { + const s = storage !== undefined ? storage : defaultStorage() + if (!s) return + s.removeItem(STORAGE_KEY) +} + +export function createAutosaver( + delayMs = 400, + storage?: StorageLike | null, +): (spec: IslandSpec) => void { + let timer: ReturnType | null = null + return (spec: IslandSpec) => { + if (timer !== null) clearTimeout(timer) + timer = setTimeout(() => { + saveSpec(spec, storage) + timer = null + }, delayMs) + } +} diff --git a/island-editor/src/main.tsx b/island-editor/src/main.tsx new file mode 100644 index 0000000..b14b186 --- /dev/null +++ b/island-editor/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' + +createRoot(document.getElementById('root') as HTMLElement).render( + + + , +) diff --git a/island-editor/src/scene/Backdrop.tsx b/island-editor/src/scene/Backdrop.tsx new file mode 100644 index 0000000..c109ed4 --- /dev/null +++ b/island-editor/src/scene/Backdrop.tsx @@ -0,0 +1,22 @@ +import { Grid, Sky } from '@react-three/drei' + +export function Backdrop() { + return ( + <> + + + + + + + ) +} diff --git a/island-editor/src/scene/CoastlineHandles.tsx b/island-editor/src/scene/CoastlineHandles.tsx new file mode 100644 index 0000000..3708861 --- /dev/null +++ b/island-editor/src/scene/CoastlineHandles.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react' +import { type ThreeEvent, useThree } from '@react-three/fiber' +import * as THREE from 'three' +import type { Vec2 } from '../terrain/islandSpec' + +interface HandlesProps { + points: Vec2[] + seaLevel: number + onChange: (index: number, next: Vec2) => void + onDragChange: (dragging: boolean) => void +} + +export function CoastlineHandles({ points, seaLevel, onChange, onDragChange }: HandlesProps) { + return ( + <> + {points.map((pt, i) => ( + + ))} + + ) +} + +interface HandleProps { + index: number + point: Vec2 + seaLevel: number + onChange: (index: number, next: Vec2) => void + onDragChange: (dragging: boolean) => void +} + +function Handle({ index, point, seaLevel, onChange, onDragChange }: HandleProps) { + const { camera, gl } = useThree() + const [hovered, setHovered] = useState(false) + const [dragging, setDragging] = useState(false) + const y = seaLevel + 0.3 + + // While dragging, raycast the pointer onto a horizontal plane at handle + // height and write the control point. Window-level listeners so the drag + // survives the pointer leaving the small handle sphere. + useEffect(() => { + if (!dragging) return + onDragChange(true) + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -y) + const raycaster = new THREE.Raycaster() + const ndc = new THREE.Vector2() + const hit = new THREE.Vector3() + const move = (ev: PointerEvent) => { + const r = gl.domElement.getBoundingClientRect() + ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1 + ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1 + raycaster.setFromCamera(ndc, camera) + if (raycaster.ray.intersectPlane(plane, hit)) onChange(index, { x: hit.x, z: hit.z }) + } + const up = () => setDragging(false) + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + return () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + onDragChange(false) + } + }, [dragging, camera, gl, index, onChange, onDragChange, y]) + + const active = hovered || dragging + return ( + ) => { + e.stopPropagation() + setHovered(true) + }} + onPointerOut={() => setHovered(false)} + onPointerDown={(e: ThreeEvent) => { + e.stopPropagation() + setDragging(true) + }} + > + + + + ) +} diff --git a/island-editor/src/scene/Sea.tsx b/island-editor/src/scene/Sea.tsx new file mode 100644 index 0000000..c27a25a --- /dev/null +++ b/island-editor/src/scene/Sea.tsx @@ -0,0 +1,8 @@ +export function Sea({ level = 0, size = 400 }: { level?: number; size?: number }) { + return ( + + + + + ) +} diff --git a/island-editor/src/scene/Terrain.tsx b/island-editor/src/scene/Terrain.tsx new file mode 100644 index 0000000..5cafcf4 --- /dev/null +++ b/island-editor/src/scene/Terrain.tsx @@ -0,0 +1,71 @@ +import { useEffect, useMemo, useRef } from 'react' +import type { ThreeEvent } from '@react-three/fiber' +import { buildBaseField, composeGeometry, updateGeometry } from '../terrain/buildTerrainGeometry' +import type { IslandSpec } from '../terrain/islandSpec' + +interface TerrainProps { + spec: IslandSpec + segments?: number + sculptActive?: boolean + onPaintStart?: () => void + onPaint?: (x: number, z: number) => void + onPaintEnd?: () => void +} + +export function Terrain({ + spec, + segments = 80, + sculptActive = false, + onPaintStart, + onPaint, + onPaintEnd, +}: TerrainProps) { + // Expensive coastline/point-in-polygon work β€” only recomputed on shape edits. + const field = useMemo( + () => buildBaseField(spec, segments), + [spec.coastline, spec.heightProfile, spec.worldSize, segments], + ) + const geometry = useMemo(() => composeGeometry(field, spec), [field]) + + // Refresh heights + colors in place (cheap) whenever the spec changes β€” + // notably on brush strokes, which change only the relief. + useEffect(() => { + updateGeometry(geometry, field, spec) + }, [geometry, field, spec]) + useEffect(() => () => geometry.dispose(), [geometry]) + + const painting = useRef(false) + + // End the stroke even if the pointer releases off the terrain. + useEffect(() => { + if (!sculptActive) return + const up = () => { + if (!painting.current) return + painting.current = false + onPaintEnd?.() + } + window.addEventListener('pointerup', up) + return () => window.removeEventListener('pointerup', up) + }, [sculptActive, onPaintEnd]) + + const handleDown = sculptActive + ? (e: ThreeEvent) => { + e.stopPropagation() + painting.current = true + onPaintStart?.() + onPaint?.(e.point.x, e.point.z) + } + : undefined + const handleMove = sculptActive + ? (e: ThreeEvent) => { + if (!painting.current) return + onPaint?.(e.point.x, e.point.z) + } + : undefined + + return ( + + + + ) +} diff --git a/island-editor/src/terrain/brush.ts b/island-editor/src/terrain/brush.ts new file mode 100644 index 0000000..dfb9c36 --- /dev/null +++ b/island-editor/src/terrain/brush.ts @@ -0,0 +1,97 @@ +import type { ReliefGrid } from './islandSpec' + +export type BrushMode = 'raise' | 'lower' | 'smooth' | 'flatten' + +export interface BrushParams { + radius: number // world units + strength: number // per-dab intensity + mode: BrushMode +} + +/** Smooth bump falloff: 1 at the center β†’ 0 at the edge. */ +function falloff(t: number): number { + const u = Math.max(0, Math.min(1, t)) + const k = 1 - u * u + return k * k +} + +function avgAround(arr: number[], res: number, ix: number, iz: number): number { + let sum = 0 + let count = 0 + for (let dz = -1; dz <= 1; dz++) { + for (let dx = -1; dx <= 1; dx++) { + const x = ix + dx + const z = iz + dz + if (x < 0 || z < 0 || x >= res || z >= res) continue + sum += arr[z * res + x] + count++ + } + } + return count ? sum / count : arr[iz * res + ix] +} + +/** + * Apply one brush dab centered at world (cx, cz). Mutates `relief.data` in place + * (the caller bumps React state to trigger a cheap geometry update). Pure w.r.t. + * everything except the passed grid β€” headless-testable. + */ +export function applyBrush( + relief: ReliefGrid, + worldSize: number, + cx: number, + cz: number, + p: BrushParams, +): void { + const res = relief.resolution + const data = relief.data + if (res < 2 || data.length < res * res) return + + const half = worldSize / 2 + const cellW = worldSize / (res - 1) + const rCells = Math.ceil(p.radius / cellW) + 1 + const gcx = ((cx + half) / worldSize) * (res - 1) + const gcz = ((cz + half) / worldSize) * (res - 1) + const ix0 = Math.max(0, Math.floor(gcx - rCells)) + const ix1 = Math.min(res - 1, Math.ceil(gcx + rCells)) + const iz0 = Math.max(0, Math.floor(gcz - rCells)) + const iz1 = Math.min(res - 1, Math.ceil(gcz + rCells)) + const r2 = p.radius * p.radius + + // smooth needs a stable snapshot so averaging isn't order-dependent. + const snapshot = p.mode === 'smooth' ? data.slice() : null + + // flatten pulls toward the relief at the brush center. + let flattenTarget = 0 + if (p.mode === 'flatten') { + const cix = Math.max(0, Math.min(res - 1, Math.round(gcx))) + const ciz = Math.max(0, Math.min(res - 1, Math.round(gcz))) + flattenTarget = data[ciz * res + cix] + } + + for (let iz = iz0; iz <= iz1; iz++) { + for (let ix = ix0; ix <= ix1; ix++) { + const wx = -half + ix * cellW + const wz = -half + iz * cellW + const dd = (wx - cx) * (wx - cx) + (wz - cz) * (wz - cz) + if (dd > r2) continue + const w = falloff(Math.sqrt(dd) / p.radius) * p.strength + const i = iz * res + ix + switch (p.mode) { + case 'raise': + data[i] += w + break + case 'lower': + data[i] -= w + break + case 'flatten': + data[i] += (flattenTarget - data[i]) * w + break + case 'smooth': { + const avg = avgAround(snapshot as number[], res, ix, iz) + data[i] += (avg - data[i]) * w + break + } + } + } + } +} diff --git a/island-editor/src/terrain/buildTerrainGeometry.ts b/island-editor/src/terrain/buildTerrainGeometry.ts new file mode 100644 index 0000000..61b4d14 --- /dev/null +++ b/island-editor/src/terrain/buildTerrainGeometry.ts @@ -0,0 +1,126 @@ +import * as THREE from 'three' +import { + baseHeightAt, + distanceToPolygon, + type IslandSpec, + isInsidePolygon, + reliefAt, + sampleCoastline, +} from './islandSpec' + +const SEAFLOOR = new THREE.Color('#3a6b86') +const SAND = new THREE.Color('#d9c89a') +const GRASS = new THREE.Color('#5a8f4e') +const ROCK = new THREE.Color('#8a8276') + +/** + * Per-vertex base data that depends only on the coastline + height profile + * (the EXPENSIVE part: point-in-polygon + distance-to-coast). Cached so that + * brush strokes β€” which change only the relief β€” can update the mesh cheaply + * without recomputing the coastline queries. + */ +export interface BaseField { + segments: number + size: number + n: number + xs: Float32Array + zs: Float32Array + baseY: Float32Array + inside: Uint8Array + indices: number[] +} + +export function buildBaseField(spec: IslandSpec, segments = 80): BaseField { + const poly = sampleCoastline(spec.coastline) + const size = spec.worldSize + const half = size / 2 + const n = segments + 1 + const count = n * n + const xs = new Float32Array(count) + const zs = new Float32Array(count) + const baseY = new Float32Array(count) + const inside = new Uint8Array(count) + const indices: number[] = [] + + let i = 0 + for (let iz = 0; iz < n; iz++) { + for (let ix = 0; ix < n; ix++) { + const x = -half + (ix / segments) * size + const z = -half + (iz / segments) * size + const ins = isInsidePolygon(poly, x, z) + const d = distanceToPolygon(poly, x, z) + xs[i] = x + zs[i] = z + inside[i] = ins ? 1 : 0 + baseY[i] = baseHeightAt(spec.heightProfile, ins, d) + i++ + } + } + for (let iz = 0; iz < segments; iz++) { + for (let ix = 0; ix < segments; ix++) { + const a = iz * n + ix + const b = a + 1 + const c = a + n + const dd = c + 1 + indices.push(a, c, b, b, c, dd) + } + } + return { segments, size, n, xs, zs, baseY, inside, indices } +} + +export function composeGeometry(field: BaseField, spec: IslandSpec): THREE.BufferGeometry { + const count = field.n * field.n + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(count * 3), 3)) + geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(count * 3), 3)) + geo.setIndex(field.indices) + writeHeightsAndColors(geo, field, spec) + return geo +} + +/** Cheap in-place refresh of heights + colors from the current relief. */ +export function updateGeometry(geo: THREE.BufferGeometry, field: BaseField, spec: IslandSpec): void { + writeHeightsAndColors(geo, field, spec) +} + +const tmp = new THREE.Color() + +function writeHeightsAndColors(geo: THREE.BufferGeometry, field: BaseField, spec: IslandSpec): void { + const pos = geo.getAttribute('position') as THREE.BufferAttribute + const col = geo.getAttribute('color') as THREE.BufferAttribute + const posArr = pos.array as Float32Array + const colArr = col.array as Float32Array + const { seaLevel, plateauHeight } = spec.heightProfile + const count = field.n * field.n + + let p = 0 + for (let i = 0; i < count; i++) { + const ins = field.inside[i] === 1 + const h = ins ? field.baseY[i] + reliefAt(spec, field.xs[i], field.zs[i]) : field.baseY[i] + posArr[p] = field.xs[i] + posArr[p + 1] = h + posArr[p + 2] = field.zs[i] + colorFor(tmp, h, seaLevel, plateauHeight, ins) + colArr[p] = tmp.r + colArr[p + 1] = tmp.g + colArr[p + 2] = tmp.b + p += 3 + } + pos.needsUpdate = true + col.needsUpdate = true + geo.computeVertexNormals() + geo.computeBoundingSphere() +} + +function colorFor(c: THREE.Color, h: number, seaLevel: number, plateau: number, inside: boolean): void { + if (!inside || h <= seaLevel + 0.02) { + c.copy(SEAFLOOR) + return + } + const t = (h - seaLevel) / Math.max(0.001, plateau - seaLevel) + // Thin sand band at the shoreline; grass across the whole interior (incl. the + // plateau top); rock only where sculpting pushes terrain above the plateau. + if (t < 0.14) c.copy(SAND) + else if (t > 1.12) c.copy(ROCK) + else c.copy(GRASS) +} diff --git a/island-editor/src/terrain/islandSpec.ts b/island-editor/src/terrain/islandSpec.ts new file mode 100644 index 0000000..1490438 --- /dev/null +++ b/island-editor/src/terrain/islandSpec.ts @@ -0,0 +1,206 @@ +// Pure, framework-agnostic island shape model + evaluation. +// NO three/r3f imports here β€” this is the headless-testable core and the +// durable export artifact (the "island spec"). The renderer (r3f) and the +// eventual student-space migration both consume these same functions/data. + +export interface Vec2 { + x: number + z: number +} + +export interface HeightProfile { + /** World Y of the waterline β€” the coast crosses through this height. */ + seaLevel: number + /** World Y of the island interior, far from the coast. */ + plateauHeight: number + /** Horizontal distance over which land rises from seaLevel to plateauHeight. */ + coastFalloff: number + /** 0..1 β€” higher = sharper rise near the coast (cliff), lower = gentle beach. */ + cliffSteepness: number + /** World Y the terrain sinks to offshore. */ + seafloorDepth: number +} + +export interface ReliefGrid { + /** N β€” the grid is NΓ—N samples across the world bounds. */ + resolution: number + /** length resolutionΒ², additive displacement applied on land. */ + data: number[] +} + +export interface IslandSpec { + version: 1 + /** Square world bounds: X and Z each span [-worldSize/2, worldSize/2]. */ + worldSize: number + /** Ordered control points of the closed coastline curve. */ + coastline: Vec2[] + heightProfile: HeightProfile + relief: ReliefGrid +} + +// ── Coastline curve ───────────────────────────────────────────────────────── + +/** Catmull-Rom on a closed loop, sampled into a dense polygon. */ +export function sampleCoastline(points: Vec2[], perSpan = 12): Vec2[] { + const n = points.length + if (n < 3) return points.slice() + const out: Vec2[] = [] + for (let i = 0; i < n; i++) { + const p0 = points[(i - 1 + n) % n] + const p1 = points[i] + const p2 = points[(i + 1) % n] + const p3 = points[(i + 2) % n] + for (let s = 0; s < perSpan; s++) { + const t = s / perSpan + out.push(catmullRom(p0, p1, p2, p3, t)) + } + } + return out +} + +function catmullRom(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 { + const t2 = t * t + const t3 = t2 * t + const f = (a: number, b: number, c: number, d: number) => + 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3) + return { x: f(p0.x, p1.x, p2.x, p3.x), z: f(p0.z, p1.z, p2.z, p3.z) } +} + +// ── Geometry queries (operate on the sampled polygon) ──────────────────────── + +/** Even-odd ray-cast point-in-polygon. */ +export function isInsidePolygon(poly: Vec2[], x: number, z: number): boolean { + let inside = false + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i] + const b = poly[j] + const intersects = + a.z > z !== b.z > z && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x + if (intersects) inside = !inside + } + return inside +} + +/** Unsigned distance from (x,z) to the nearest polygon edge. */ +export function distanceToPolygon(poly: Vec2[], x: number, z: number): number { + let best = Infinity + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + best = Math.min(best, distToSegment(x, z, poly[j], poly[i])) + } + return best +} + +function distToSegment(px: number, pz: number, a: Vec2, b: Vec2): number { + const dx = b.x - a.x + const dz = b.z - a.z + const len2 = dx * dx + dz * dz + let t = len2 > 0 ? ((px - a.x) * dx + (pz - a.z) * dz) / len2 : 0 + t = Math.max(0, Math.min(1, t)) + const cx = a.x + t * dx + const cz = a.z + t * dz + return Math.hypot(px - cx, pz - cz) +} + +// ── Height evaluation ──────────────────────────────────────────────────────── + +function cliffEase(t: number, steepness: number): number { + // steepness 0 β†’ linear; β†’1 β†’ rises fast near the coast. + const k = 1 / (1 + steepness * 4) + return Math.pow(Math.max(0, Math.min(1, t)), k) +} + +/** Analytic base height from coastline + profile (no relief). */ +export function baseHeightAt( + profile: HeightProfile, + inside: boolean, + distToCoast: number, +): number { + const { seaLevel, plateauHeight, coastFalloff, cliffSteepness, seafloorDepth } = profile + if (inside) { + const t = cliffEase(distToCoast / coastFalloff, cliffSteepness) + return seaLevel + (plateauHeight - seaLevel) * t + } + const t = Math.max(0, Math.min(1, distToCoast / coastFalloff)) + return seaLevel + (seafloorDepth - seaLevel) * t +} + +/** Bilinear sample of the relief grid over the world bounds. */ +export function reliefAt(spec: IslandSpec, x: number, z: number): number { + const { resolution, data } = spec.relief + if (resolution < 2 || data.length < resolution * resolution) return 0 + const half = spec.worldSize / 2 + const u = ((x + half) / spec.worldSize) * (resolution - 1) + const v = ((z + half) / spec.worldSize) * (resolution - 1) + if (u < 0 || v < 0 || u > resolution - 1 || v > resolution - 1) return 0 + const x0 = Math.floor(u) + const z0 = Math.floor(v) + const x1 = Math.min(x0 + 1, resolution - 1) + const z1 = Math.min(z0 + 1, resolution - 1) + const fx = u - x0 + const fz = v - z0 + const h00 = data[z0 * resolution + x0] + const h10 = data[z0 * resolution + x1] + const h01 = data[z1 * resolution + x0] + const h11 = data[z1 * resolution + x1] + const a = h00 + (h10 - h00) * fx + const b = h01 + (h11 - h01) * fx + return a + (b - a) * fz +} + +/** Final terrain height = analytic base + sculpt relief (relief applied on land). */ +export function evaluateHeight(spec: IslandSpec, x: number, z: number): number { + const poly = sampleCoastline(spec.coastline) + const inside = isInsidePolygon(poly, x, z) + const d = distanceToPolygon(poly, x, z) + const base = baseHeightAt(spec.heightProfile, inside, d) + return inside ? base + reliefAt(spec, x, z) : base +} + +/** Convenience: is a world point on land (inside the coastline)? */ +export function isInside(spec: IslandSpec, x: number, z: number): boolean { + return isInsidePolygon(sampleCoastline(spec.coastline), x, z) +} + +// ── Seed: reproduce today's island ─────────────────────────────────────────── + +// The current student-space island silhouette (State/Island.js:31-39), +// copied (not imported) so this package stays self-contained and free of the +// three@0.149 boundary. radiusAtTheta = BASE_RADIUS * silhouetteAt(theta). +const SEED_BASE_RADIUS = 5.0 + +function silhouetteAt(theta: number): number { + return ( + 1.0 + + Math.sin(theta * 2.0 + 0.7) * 0.13 + + Math.sin(theta * 3.0 - 1.3) * 0.07 + + Math.sin(theta * 5.0 + 2.1) * 0.04 + + Math.sin(theta * 7.0 - 0.4) * 0.018 + + Math.sin(theta * 9.0 + 1.8) * 0.012 + ) +} + +/** Build the default spec by sampling today's island silhouette + profile. */ +export function seedFromCurrentIsland(controlPoints = 24, reliefResolution = 192): IslandSpec { + const coastline: Vec2[] = [] + for (let i = 0; i < controlPoints; i++) { + const theta = (i / controlPoints) * Math.PI * 2 + const r = SEED_BASE_RADIUS * silhouetteAt(theta) + coastline.push({ x: r * Math.cos(theta), z: r * Math.sin(theta) }) + } + return { + version: 1, + worldSize: 24, + coastline, + heightProfile: { + seaLevel: 0, + plateauHeight: 1.0, // matches plateauTopY + coastFalloff: 2.0, + cliffSteepness: 0.45, + seafloorDepth: -1.2, + }, + relief: { + resolution: reliefResolution, + data: new Array(reliefResolution * reliefResolution).fill(0), + }, + } +} diff --git a/island-editor/src/ui/ToolPanel.tsx b/island-editor/src/ui/ToolPanel.tsx new file mode 100644 index 0000000..9db77eb --- /dev/null +++ b/island-editor/src/ui/ToolPanel.tsx @@ -0,0 +1,150 @@ +import './panel.css' +import type { BrushMode, BrushParams } from '../terrain/brush' +import type { HeightProfile } from '../terrain/islandSpec' + +export type EditMode = 'shape' | 'sculpt' + +interface ToolPanelProps { + mode: EditMode + onModeChange: (m: EditMode) => void + profile: HeightProfile + onProfileChange: (p: HeightProfile) => void + brush: BrushParams + onBrushChange: (b: BrushParams) => void + canUndo: boolean + canRedo: boolean + onUndo: () => void + onRedo: () => void + onReset: () => void + onExport: () => void + onImport: () => void + onTopView: () => void +} + +const PROFILE_FIELDS: { key: keyof HeightProfile; label: string; min: number; max: number; step: number }[] = [ + { key: 'seaLevel', label: 'Sea level', min: -2, max: 2, step: 0.05 }, + { key: 'plateauHeight', label: 'Plateau height', min: 0, max: 4, step: 0.05 }, + { key: 'coastFalloff', label: 'Coast falloff', min: 0.2, max: 6, step: 0.1 }, + { key: 'cliffSteepness', label: 'Cliff steepness', min: 0, max: 1, step: 0.05 }, + { key: 'seafloorDepth', label: 'Seafloor depth', min: -4, max: 0, step: 0.05 }, +] +const BRUSH_MODES: BrushMode[] = ['raise', 'lower', 'smooth', 'flatten'] + +export function ToolPanel({ + mode, + onModeChange, + profile, + onProfileChange, + brush, + onBrushChange, + canUndo, + canRedo, + onUndo, + onRedo, + onReset, + onExport, + onImport, + onTopView, +}: ToolPanelProps) { + return ( +
+
Island editor
+
+
+ + +
+
+ + +
+
+ + {mode === 'shape' ? ( + <> +
Height profile
+ {PROFILE_FIELDS.map((f) => ( + + ))} +
Drag the orange handles to reshape the coastline.
+ + ) : ( + <> +
Brush
+
+ {BRUSH_MODES.map((m) => ( + + ))} +
+ + +
Drag on the island to sculpt relief. Switch to Shape to edit the coastline.
+ + )} + +
Scene
+
+ + + + +
+
+ ) +} diff --git a/island-editor/src/ui/panel.css b/island-editor/src/ui/panel.css new file mode 100644 index 0000000..6e7ae47 --- /dev/null +++ b/island-editor/src/ui/panel.css @@ -0,0 +1,126 @@ +.tool-panel { + position: fixed; + top: 12px; + right: 12px; + width: 252px; + padding: 12px 14px 14px; + border-radius: 12px; + background: rgba(16, 22, 38, 0.82); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + color: #e7ecf6; + font: 12px/1.4 ui-sans-serif, system-ui, sans-serif; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); + opacity: 0.22; + transition: opacity 160ms ease; + user-select: none; + z-index: 10; +} +.tool-panel:hover { + opacity: 1; +} + +.tool-panel__title { + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; + letter-spacing: 0.02em; +} + +.tool-panel__topbar { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 10px; +} +.tool-panel__tabs { + display: flex; + gap: 6px; + flex: 1; +} +.tool-panel__tabs button { + flex: 1; +} +.tool-panel__history { + display: flex; + gap: 4px; +} +.tool-panel__history button { + width: 26px; + padding: 5px 0; + font-size: 13px; + line-height: 1; +} +.tool-panel button:disabled { + opacity: 0.35; + cursor: default; +} +.tool-panel button:disabled:hover { + background: rgba(255, 255, 255, 0.05); +} +.tool-panel__modes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; + margin-bottom: 8px; +} +.tool-panel button { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: #cfd8ea; + padding: 5px 8px; + border-radius: 7px; + font: inherit; + font-size: 11px; + cursor: pointer; + text-transform: capitalize; + transition: background 120ms, border-color 120ms, color 120ms; +} +.tool-panel button:hover { + background: rgba(255, 255, 255, 0.1); +} +.tool-panel button.is-active { + background: #ff7b54; + border-color: #ff7b54; + color: #1a1206; + font-weight: 600; +} + +.tool-panel__section { + text-transform: uppercase; + font-size: 10px; + opacity: 0.55; + margin: 8px 0 4px; + letter-spacing: 0.08em; +} +.tool-panel__row { + display: grid; + grid-template-columns: 92px 1fr 40px; + align-items: center; + gap: 8px; + margin: 5px 0; +} +.tool-panel__label { + opacity: 0.85; +} +.tool-panel__row input[type='range'] { + width: 100%; + accent-color: #ff7b54; +} +.tool-panel__value { + text-align: right; + font-variant-numeric: tabular-nums; + opacity: 0.7; +} +.tool-panel__hint { + font-size: 10px; + opacity: 0.5; + margin-top: 10px; + line-height: 1.35; +} +.tool-panel__actions { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} diff --git a/island-editor/test/brush.test.ts b/island-editor/test/brush.test.ts new file mode 100644 index 0000000..3f28b66 --- /dev/null +++ b/island-editor/test/brush.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { applyBrush } from '../src/terrain/brush' +import type { ReliefGrid } from '../src/terrain/islandSpec' + +function emptyRelief(resolution = 65): ReliefGrid { + return { resolution, data: new Array(resolution * resolution).fill(0) } +} + +const WORLD = 24 + +describe('sculpt brush', () => { + it('raise lifts the center cell the most', () => { + const relief = emptyRelief() + applyBrush(relief, WORLD, 0, 0, { radius: 4, strength: 0.5, mode: 'raise' }) + const res = relief.resolution + const center = relief.data[Math.floor(res / 2) * res + Math.floor(res / 2)] + const corner = relief.data[0] + expect(center).toBeGreaterThan(0) + expect(center).toBeGreaterThan(corner) + expect(corner).toBe(0) // outside the brush radius + }) + + it('lower is the inverse of raise', () => { + const relief = emptyRelief() + applyBrush(relief, WORLD, 0, 0, { radius: 4, strength: 0.5, mode: 'lower' }) + const res = relief.resolution + const center = relief.data[Math.floor(res / 2) * res + Math.floor(res / 2)] + expect(center).toBeLessThan(0) + }) + + it('flatten pulls neighbors toward the center value', () => { + const relief = emptyRelief() + // first raise a bump, then flatten should reduce variance around center + applyBrush(relief, WORLD, 0, 0, { radius: 5, strength: 0.8, mode: 'raise' }) + const before = relief.data.slice() + applyBrush(relief, WORLD, 0, 0, { radius: 5, strength: 0.6, mode: 'flatten' }) + // values changed (flatten did something) + expect(relief.data).not.toEqual(before) + }) + + it('only touches cells within the radius', () => { + const relief = emptyRelief() + applyBrush(relief, WORLD, 0, 0, { radius: 2, strength: 0.5, mode: 'raise' }) + // a far cell stays zero + expect(relief.data[0]).toBe(0) + }) +}) diff --git a/island-editor/test/commandStack.test.ts b/island-editor/test/commandStack.test.ts new file mode 100644 index 0000000..dbb6632 --- /dev/null +++ b/island-editor/test/commandStack.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest' +import { createCommandStack } from '../src/editor/commandStack' +import type { Command } from '../src/editor/commandStack' + +// Helper: build a command that appends a label to a log on do/undo. +function makeCmd(log: string[], doLabel: string, undoLabel: string): Command { + return { + label: doLabel, + do: () => log.push(doLabel), + undo: () => log.push(undoLabel), + } +} + +describe('commandStack', () => { + describe('push then undo', () => { + it('runs the correct undo function and flips canUndo / canRedo', () => { + const stack = createCommandStack() + const log: string[] = [] + const cmd = makeCmd(log, 'do-a', 'undo-a') + + stack.push(cmd) + expect(stack.canUndo()).toBe(true) + expect(stack.canRedo()).toBe(false) + expect(log).toEqual([]) // push must NOT call do() + + const result = stack.undo() + expect(result).toBe(true) + expect(log).toEqual(['undo-a']) + expect(stack.canUndo()).toBe(false) + expect(stack.canRedo()).toBe(true) + }) + + it('returns false when there is nothing to undo', () => { + const stack = createCommandStack() + expect(stack.undo()).toBe(false) + }) + }) + + describe('redo', () => { + it('runs the correct do function and moves the command back to the undo stack', () => { + const stack = createCommandStack() + const log: string[] = [] + const cmd = makeCmd(log, 'do-b', 'undo-b') + + stack.push(cmd) + stack.undo() + log.length = 0 // reset log to isolate redo + + const result = stack.redo() + expect(result).toBe(true) + expect(log).toEqual(['do-b']) + expect(stack.canUndo()).toBe(true) + expect(stack.canRedo()).toBe(false) + }) + + it('returns false when there is nothing to redo', () => { + const stack = createCommandStack() + expect(stack.redo()).toBe(false) + }) + }) + + describe('push clears the redo stack', () => { + it('discards redo entries when a new command is pushed', () => { + const stack = createCommandStack() + const log: string[] = [] + const a = makeCmd(log, 'do-a', 'undo-a') + const b = makeCmd(log, 'do-b', 'undo-b') + const c = makeCmd(log, 'do-c', 'undo-c') + + stack.push(a) + stack.push(b) + stack.undo() // b moves to redo + expect(stack.canRedo()).toBe(true) + + stack.push(c) // should clear redo + expect(stack.canRedo()).toBe(false) + expect(stack.size()).toBe(2) // a and c + }) + }) + + describe('capacity eviction', () => { + it('evicts the oldest undo entry when capacity is exceeded', () => { + const stack = createCommandStack(2) + const log: string[] = [] + const a = makeCmd(log, 'do-a', 'undo-a') + const b = makeCmd(log, 'do-b', 'undo-b') + const c = makeCmd(log, 'do-c', 'undo-c') + + stack.push(a) + stack.push(b) + stack.push(c) // exceeds capacity of 2; a should be evicted + + expect(stack.size()).toBe(2) + + // Undo twice: should see c then b, NOT a + stack.undo() + stack.undo() + expect(log).toEqual(['undo-c', 'undo-b']) + expect(stack.canUndo()).toBe(false) + }) + }) + + describe('clear', () => { + it('empties both the undo and redo stacks', () => { + const stack = createCommandStack() + const log: string[] = [] + const a = makeCmd(log, 'do-a', 'undo-a') + const b = makeCmd(log, 'do-b', 'undo-b') + + stack.push(a) + stack.push(b) + stack.undo() // b β†’ redo + + stack.clear() + + expect(stack.canUndo()).toBe(false) + expect(stack.canRedo()).toBe(false) + expect(stack.size()).toBe(0) + expect(stack.undo()).toBe(false) + expect(stack.redo()).toBe(false) + }) + }) + + describe('multi-command undo/redo ordering', () => { + it('undoes commands in LIFO order and redoes in the reverse of that', () => { + const stack = createCommandStack() + const log: string[] = [] + const a = makeCmd(log, 'do-a', 'undo-a') + const b = makeCmd(log, 'do-b', 'undo-b') + const c = makeCmd(log, 'do-c', 'undo-c') + + stack.push(a) + stack.push(b) + stack.push(c) + + stack.undo() // c + stack.undo() // b + expect(log).toEqual(['undo-c', 'undo-b']) + + log.length = 0 + stack.redo() // b + stack.redo() // c + expect(log).toEqual(['do-b', 'do-c']) + }) + }) + + describe('size', () => { + it('reflects the number of undoable commands', () => { + const stack = createCommandStack() + const cmd = makeCmd([], 'do', 'undo') + + expect(stack.size()).toBe(0) + stack.push(cmd) + expect(stack.size()).toBe(1) + stack.push(cmd) + expect(stack.size()).toBe(2) + stack.undo() + expect(stack.size()).toBe(1) + stack.redo() + expect(stack.size()).toBe(2) + }) + }) +}) diff --git a/island-editor/test/exportSpec.test.ts b/island-editor/test/exportSpec.test.ts new file mode 100644 index 0000000..77608f9 --- /dev/null +++ b/island-editor/test/exportSpec.test.ts @@ -0,0 +1,96 @@ +// NOTE: downloadSpec and importSpecFromFile are browser-only (Blob, FileReader, +// document.createElement) and are intentionally not unit-tested here. +// They are exercised manually / in browser integration tests. + +import { describe, expect, it } from 'vitest' +import { seedFromCurrentIsland } from '../src/terrain/islandSpec' +import { deserializeSpec, serializeSpec } from '../src/editor/exportSpec' + +describe('exportSpec', () => { + const spec = seedFromCurrentIsland() + + describe('serializeSpec β†’ deserializeSpec round-trip', () => { + it('produces a JSON string', () => { + const json = serializeSpec(spec) + expect(typeof json).toBe('string') + expect(() => JSON.parse(json)).not.toThrow() + }) + + it('round-trips to a deep-equal spec', () => { + const json = serializeSpec(spec) + const restored = deserializeSpec(json) + expect(restored).toEqual(spec) + }) + + it('round-trips with a small custom spec', () => { + const custom = seedFromCurrentIsland(6, 4) + const restored = deserializeSpec(serializeSpec(custom)) + expect(restored).toEqual(custom) + }) + }) + + describe('deserializeSpec β€” malformed JSON', () => { + it('throws on completely invalid JSON', () => { + expect(() => deserializeSpec('not json at all')).toThrow('Invalid island spec: malformed JSON') + }) + + it('throws on truncated JSON', () => { + expect(() => deserializeSpec('{"version":1,')).toThrow( + 'Invalid island spec: malformed JSON', + ) + }) + }) + + describe('deserializeSpec β€” valid JSON, wrong shape', () => { + it('throws when version !== 1', () => { + const bad = JSON.stringify({ ...spec, version: 2 }) + expect(() => deserializeSpec(bad)).toThrow('Invalid island spec: version must be 1') + }) + + it('throws when worldSize is not finite', () => { + const bad = JSON.stringify({ ...spec, worldSize: Infinity }) + expect(() => deserializeSpec(bad)).toThrow( + 'Invalid island spec: worldSize must be a finite number', + ) + }) + + it('throws when worldSize is missing', () => { + const { worldSize: _ws, ...rest } = spec as unknown as { worldSize: number } & Record + expect(() => deserializeSpec(JSON.stringify(rest))).toThrow( + 'Invalid island spec: worldSize must be a finite number', + ) + }) + + it('throws when coastline has fewer than 3 points', () => { + const bad = JSON.stringify({ ...spec, coastline: [{ x: 0, z: 0 }, { x: 1, z: 1 }] }) + expect(() => deserializeSpec(bad)).toThrow('Invalid island spec: coastline') + }) + + it('throws when a coastline point is malformed', () => { + const bad = JSON.stringify({ + ...spec, + coastline: [{ x: 0, z: 0 }, { x: 1, z: 1 }, { x: 'oops', z: 2 }], + }) + expect(() => deserializeSpec(bad)).toThrow('Invalid island spec: coastline[2]') + }) + + it('throws when heightProfile is missing a field', () => { + const { cliffSteepness: _cs, ...hp } = spec.heightProfile as unknown as { cliffSteepness: number } & Record + const bad = JSON.stringify({ ...spec, heightProfile: hp }) + expect(() => deserializeSpec(bad)).toThrow('Invalid island spec: heightProfile') + }) + + it('throws when relief data length does not match resolutionΒ²', () => { + const bad = JSON.stringify({ + ...spec, + relief: { resolution: 4, data: [0, 1, 2] }, + }) + expect(() => deserializeSpec(bad)).toThrow('Invalid island spec: relief') + }) + + it('throws when relief is missing entirely', () => { + const { relief: _r, ...rest } = spec as unknown as { relief: unknown } & Record + expect(() => deserializeSpec(JSON.stringify(rest))).toThrow('Invalid island spec: relief') + }) + }) +}) diff --git a/island-editor/test/persistence.test.ts b/island-editor/test/persistence.test.ts new file mode 100644 index 0000000..70293f8 --- /dev/null +++ b/island-editor/test/persistence.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest' +import { seedFromCurrentIsland } from '../src/terrain/islandSpec' +import { + STORAGE_KEY, + clearSaved, + createAutosaver, + loadSpec, + saveSpec, +} from '../src/editor/persistence' +import type { StorageLike } from '../src/editor/persistence' + +function makeStorage(): StorageLike { + const store = new Map() + return { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => { store.set(k, v) }, + removeItem: (k) => { store.delete(k) }, + } +} + +describe('persistence', () => { + it('round-trips a valid IslandSpec', () => { + const storage = makeStorage() + const spec = seedFromCurrentIsland() + saveSpec(spec, storage) + const loaded = loadSpec(storage) + expect(loaded).toEqual(spec) + }) + + it('loadSpec returns null when storage is empty', () => { + const storage = makeStorage() + expect(loadSpec(storage)).toBeNull() + }) + + it('loadSpec returns null on corrupt JSON', () => { + const storage = makeStorage() + storage.setItem(STORAGE_KEY, '{not valid json}}}') + expect(loadSpec(storage)).toBeNull() + }) + + it('loadSpec returns null when version is wrong', () => { + const storage = makeStorage() + const spec = { ...seedFromCurrentIsland(), version: 2 } + storage.setItem(STORAGE_KEY, JSON.stringify(spec)) + expect(loadSpec(storage)).toBeNull() + }) + + it('loadSpec returns null when worldSize is non-finite', () => { + const storage = makeStorage() + const spec = { ...seedFromCurrentIsland(), worldSize: Infinity } + storage.setItem(STORAGE_KEY, JSON.stringify(spec)) + expect(loadSpec(storage)).toBeNull() + }) + + it('loadSpec returns null when coastline entries are missing fields', () => { + const storage = makeStorage() + const spec = { ...seedFromCurrentIsland(), coastline: [{ x: 1 }] } + storage.setItem(STORAGE_KEY, JSON.stringify(spec)) + expect(loadSpec(storage)).toBeNull() + }) + + it('loadSpec returns null when heightProfile is missing a field', () => { + const storage = makeStorage() + const base = seedFromCurrentIsland() + const { seaLevel: _dropped, ...partialProfile } = base.heightProfile + const spec = { ...base, heightProfile: partialProfile } + storage.setItem(STORAGE_KEY, JSON.stringify(spec)) + expect(loadSpec(storage)).toBeNull() + }) + + it('loadSpec returns null when relief.data is not an array', () => { + const storage = makeStorage() + const base = seedFromCurrentIsland() + const spec = { ...base, relief: { resolution: 4, data: 'bad' } } + storage.setItem(STORAGE_KEY, JSON.stringify(spec)) + expect(loadSpec(storage)).toBeNull() + }) + + it('clearSaved removes the stored spec', () => { + const storage = makeStorage() + const spec = seedFromCurrentIsland() + saveSpec(spec, storage) + expect(loadSpec(storage)).not.toBeNull() + clearSaved(storage) + expect(loadSpec(storage)).toBeNull() + }) + + it('createAutosaver debounces saves', async () => { + vi.useFakeTimers() + const storage = makeStorage() + const saver = createAutosaver(400, storage) + const spec = seedFromCurrentIsland() + + saver(spec) + saver(spec) + saver(spec) + + // Not saved yet β€” timer still pending + expect(loadSpec(storage)).toBeNull() + + vi.advanceTimersByTime(400) + expect(loadSpec(storage)).toEqual(spec) + + vi.useRealTimers() + }) + + it('saveSpec is a no-op when storage is null', () => { + // Should not throw + expect(() => saveSpec(seedFromCurrentIsland(), null)).not.toThrow() + }) + + it('clearSaved is a no-op when storage is null', () => { + expect(() => clearSaved(null)).not.toThrow() + }) +}) diff --git a/island-editor/test/terrain.test.ts b/island-editor/test/terrain.test.ts new file mode 100644 index 0000000..7afccaa --- /dev/null +++ b/island-editor/test/terrain.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + baseHeightAt, + distanceToPolygon, + evaluateHeight, + isInside, + isInsidePolygon, + seedFromCurrentIsland, +} from '../src/terrain/islandSpec' + +describe('island spec β€” pure terrain core', () => { + const spec = seedFromCurrentIsland() + + it('seeds a closed coastline of control points', () => { + expect(spec.coastline.length).toBe(24) + }) + + it('classifies inside vs outside', () => { + expect(isInside(spec, 0, 0)).toBe(true) + expect(isInside(spec, 100, 100)).toBe(false) + }) + + it('center rises to ~plateau, far offshore sinks to/below sea level', () => { + const center = evaluateHeight(spec, 0, 0) + expect(center).toBeGreaterThan(spec.heightProfile.seaLevel) + expect(center).toBeCloseTo(spec.heightProfile.plateauHeight, 1) + expect(evaluateHeight(spec, 100, 100)).toBeLessThanOrEqual(spec.heightProfile.seaLevel) + }) + + it('base height runs seaLevel at the coast β†’ plateau one falloff inland', () => { + const p = spec.heightProfile + expect(baseHeightAt(p, true, 0)).toBeCloseTo(p.seaLevel, 5) + expect(baseHeightAt(p, true, p.coastFalloff)).toBeCloseTo(p.plateauHeight, 5) + }) + + it('point-in-polygon + distance agree on a unit square', () => { + const sq = [ + { x: -1, z: -1 }, + { x: 1, z: -1 }, + { x: 1, z: 1 }, + { x: -1, z: 1 }, + ] + expect(isInsidePolygon(sq, 0, 0)).toBe(true) + expect(isInsidePolygon(sq, 2, 0)).toBe(false) + expect(distanceToPolygon(sq, 0, 0)).toBeCloseTo(1, 5) + }) +}) diff --git a/island-editor/tsconfig.json b/island-editor/tsconfig.json new file mode 100644 index 0000000..520d9f8 --- /dev/null +++ b/island-editor/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "types": ["vitest/globals"] + }, + "include": ["src", "test", "vite.config.ts"] +} diff --git a/island-editor/vite.config.ts b/island-editor/vite.config.ts new file mode 100644 index 0000000..1021179 --- /dev/null +++ b/island-editor/vite.config.ts @@ -0,0 +1,14 @@ +/// +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// Standalone editor app. Plain Vite + React (no TanStack Start / SSR). +// Isolated from the product app: its own deps, its own port. +export default defineConfig({ + plugins: [react()], + server: { port: 5180 }, + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +}) diff --git a/src/engine/student-space/Game/Data/defaultIslandLayout.json b/src/engine/student-space/Game/Data/defaultIslandLayout.json new file mode 100644 index 0000000..c1716c4 --- /dev/null +++ b/src/engine/student-space/Game/Data/defaultIslandLayout.json @@ -0,0 +1,313 @@ +{ + "v": 1, + "objects": [ + { + "id": "tree-0", + "kind": "tree", + "species": "oak", + "x": 0, + "z": 0, + "yaw": 0, + "scale": 0.78, + "locked": false + }, + { + "id": "tree-1", + "kind": "tree", + "species": "oak", + "x": -2.1, + "z": -1.6, + "yaw": 0.85, + "scale": 0.52, + "locked": false + }, + { + "id": "tree-2", + "kind": "tree", + "species": "cherry", + "x": 2.4, + "z": -1.1, + "yaw": 1.6, + "scale": 0.5, + "locked": false + }, + { + "id": "tree-3", + "kind": "tree", + "species": "cherry", + "x": -1.8, + "z": 2.1, + "yaw": -0.7, + "scale": 0.56, + "locked": false + }, + { + "id": "tree-4", + "kind": "tree", + "species": "oak", + "x": 1.6, + "z": 2.4, + "yaw": 2.35, + "scale": 0.54, + "locked": false + }, + { + "id": "tree-5", + "kind": "tree", + "species": "oak", + "x": -3.2, + "z": 0.3, + "yaw": -1.3, + "scale": 0.6, + "locked": false + }, + { + "id": "tree-6", + "kind": "tree", + "species": "cherry", + "x": 3, + "z": 0.9, + "yaw": 2.2, + "scale": 0.48, + "locked": false + }, + { + "id": "flower-0", + "kind": "flower", + "species": "daisy", + "x": -1.4, + "z": 1, + "yaw": 2.1092653076201873, + "scale": 1, + "locked": false + }, + { + "id": "flower-1", + "kind": "flower", + "species": "tulip", + "x": -2.8758599373080176, + "z": 1.3865315073905555, + "yaw": 3.1893448619243583, + "scale": 1, + "locked": false + }, + { + "id": "flower-2", + "kind": "flower", + "species": "rose", + "x": -1.2056196542577522, + "z": 1.7223150842013257, + "yaw": 5.444380068671112, + "scale": 1, + "locked": false + }, + { + "id": "flower-3", + "kind": "flower", + "species": "lily", + "x": -0.9265056038766047, + "z": 0.8516075187463108, + "yaw": 4.574787222157457, + "scale": 1, + "locked": false + }, + { + "id": "flower-4", + "kind": "flower", + "species": "pansy", + "x": 3.2186737898751656, + "z": -2.81972109159233, + "yaw": 1.4551857171427922, + "scale": 1, + "locked": false + }, + { + "id": "flower-5", + "kind": "flower", + "species": "hyacinth", + "x": 2.628259163992561, + "z": -2.736131167705439, + "yaw": 6.11605257800861, + "scale": 1, + "locked": false + }, + { + "id": "flower-6", + "kind": "flower", + "species": "daisy", + "x": 1.961298691314374, + "z": 0.2452660666470689, + "yaw": 4.734380128959818, + "scale": 1, + "locked": false + }, + { + "id": "flower-7", + "kind": "flower", + "species": "tulip", + "x": 1.6276786315074756, + "z": -3.915639190800648, + "yaw": 1.9647520455550564, + "scale": 1, + "locked": false + }, + { + "id": "flower-8", + "kind": "flower", + "species": "rose", + "x": -1.128378132058721, + "z": -2.9194045267985165, + "yaw": 0.9072919583567323, + "scale": 1, + "locked": false + }, + { + "id": "flower-9", + "kind": "flower", + "species": "lily", + "x": 1.41890598685407, + "z": -1.4278493619670383, + "yaw": 5.718326948064141, + "scale": 1, + "locked": false + }, + { + "id": "flower-10", + "kind": "flower", + "species": "pansy", + "x": -1.3948753083935188, + "z": -2.365384297325508, + "yaw": 1.2063715789784806, + "scale": 1, + "locked": false + }, + { + "id": "flower-11", + "kind": "flower", + "species": "hyacinth", + "x": 2.4013968979038176, + "z": -0.9753014604407813, + "yaw": 1.9088316963211585, + "scale": 1, + "locked": false + }, + { + "id": "flower-12", + "kind": "flower", + "species": "daisy", + "x": -1.2835937934485506, + "z": 0.6858359668465628, + "yaw": 1.411831738523253, + "scale": 1, + "locked": false + }, + { + "id": "flower-13", + "kind": "flower", + "species": "tulip", + "x": -4.057538368989501, + "z": -0.6452645846302301, + "yaw": 3.719017383319597, + "scale": 1, + "locked": false + }, + { + "id": "flower-14", + "kind": "flower", + "species": "rose", + "x": -0.26504208044249605, + "z": 0.037211498151961585, + "yaw": 5.804406586772502, + "scale": 1, + "locked": false + }, + { + "id": "flower-15", + "kind": "flower", + "species": "lily", + "x": -0.8593829744512366, + "z": -3.118269536653839, + "yaw": 2.339858208393678, + "scale": 1, + "locked": false + }, + { + "id": "flower-16", + "kind": "flower", + "species": "pansy", + "x": 1.7245655918113776, + "z": -1.167267544113291, + "yaw": 0.3820176666765188, + "scale": 1, + "locked": false + }, + { + "id": "flower-17", + "kind": "flower", + "species": "hyacinth", + "x": -0.5925352299312808, + "z": 1.970024873266905, + "yaw": 0.1564513141487717, + "scale": 1, + "locked": false + }, + { + "id": "fruit-0", + "kind": "fruit", + "species": "plum", + "x": 2.6, + "z": 0.1, + "yaw": 0, + "scale": 1, + "locked": false + }, + { + "id": "fruit-1", + "kind": "fruit", + "species": "fig", + "x": -2.4, + "z": 0.9, + "yaw": 0, + "scale": 1, + "locked": false + }, + { + "id": "fruit-2", + "kind": "fruit", + "species": "citrus", + "x": 0.8, + "z": -2.6, + "yaw": 0, + "scale": 1, + "locked": false + }, + { + "id": "fruit-3", + "kind": "fruit", + "species": "berry", + "x": -1, + "z": -2.4, + "yaw": 0, + "scale": 1, + "locked": false + }, + { + "id": "mailbox-0", + "kind": "mailbox", + "x": -0.6, + "z": 2.5, + "yaw": 0, + "scale": 1, + "locked": true + }, + { + "id": "telescope-0", + "kind": "telescope", + "x": 1.2973693188292486, + "z": 4.673257199273386, + "yaw": 0, + "scale": 1, + "locked": true + } + ] +} diff --git a/src/engine/student-space/Game/Data/defaultSpeciesPalette.json b/src/engine/student-space/Game/Data/defaultSpeciesPalette.json new file mode 100644 index 0000000..f8fb8e8 --- /dev/null +++ b/src/engine/student-space/Game/Data/defaultSpeciesPalette.json @@ -0,0 +1,56 @@ +{ + "v": 1, + "tree": { + "oak": { + "colorA": "#3A7D2A", + "colorB": "#8AAA35" + }, + "cherry": { + "colorA": "#FF66A3", + "colorB": "#FFCC66" + } + }, + "flower": { + "daisy": { + "petal": "#FF8E8E", + "centre": "#FFD45A" + }, + "tulip": { + "petal": "#FFB0D5" + }, + "rose": { + "petal": "#F0A86A" + }, + "lily": { + "petal": "#FFD45A", + "centre": "#FAF1DC" + }, + "pansy": { + "petal": "#D09EE8", + "face": "#2B2620" + }, + "hyacinth": { + "petal": "#FAF1DC" + } + }, + "fruit": { + "apple": { + "color": "#D64242" + }, + "pear": { + "color": "#C9D659" + }, + "plum": { + "color": "#7B3F8E" + }, + "fig": { + "color": "#6A3F62" + }, + "citrus": { + "color": "#F1A22F" + }, + "berry": { + "color": "#B02A5E" + } + } +} diff --git a/src/engine/student-space/Game/Data/islandLayout.d.ts b/src/engine/student-space/Game/Data/islandLayout.d.ts new file mode 100644 index 0000000..86ba768 --- /dev/null +++ b/src/engine/student-space/Game/Data/islandLayout.d.ts @@ -0,0 +1,28 @@ +// Companion declarations for islandLayout.js + +export type PlacedObjectKind = 'tree' | 'flower' | 'fruit' | 'mailbox' | 'telescope' + +export interface PlacedObject { + id: string + kind: PlacedObjectKind + species?: string + x: number + z: number + yaw?: number + scale?: number + locked?: boolean +} + +export interface IslandLayout { + v: 1 + objects: PlacedObject[] +} + +export function flowerBasePlacement( + i: number, + seed?: number, + islandRadius?: number, +): { x: number; z: number; yaw: number } + +export function defaultIslandLayout(): IslandLayout +export function defaultIslandLayoutFromConstants(): IslandLayout diff --git a/src/engine/student-space/Game/Data/islandLayout.js b/src/engine/student-space/Game/Data/islandLayout.js new file mode 100644 index 0000000..d55d7fe --- /dev/null +++ b/src/engine/student-space/Game/Data/islandLayout.js @@ -0,0 +1,215 @@ +/** + * Island Layout β€” data model + default builder. + * + * `PlacedObject` is the typed, serializable description of one authored + * placement on the island. `defaultIslandLayout()` produces a `{ v, objects }` + * snapshot that reproduces today's hard-coded constants exactly (visual no-op). + * + * The default ids (`tree-0`…`tree-6`, `flower-0`…`flower-17`, `fruit-0`…`fruit-3`, + * `mailbox-0`, `telescope-0`) are **frozen labels** β€” they do not change if objects + * are added, removed or reordered. Editor-spawned objects in later plans get fresh + * `crypto.randomUUID()` ids. + * + * Flower placements are produced by `flowerBasePlacement(i, seed=1337)` β€” exported + * from here so both `Flowers._buildOne` and the default builder share one source of + * truth (no drift). + * + * @typedef {Object} PlacedObject + * @property {string} id - stable uuid-style label, e.g. "tree-0" + * @property {'tree'|'flower'|'fruit'|'mailbox'|'telescope'} kind + * @property {string} [species] - e.g. 'oak', 'cherry', 'daisy', etc. + * @property {number} x + * @property {number} z + * @property {number} [yaw] - default 0 + * @property {number} [scale] - default 1 + * @property {boolean} [locked] - default false; mailbox/telescope are locked + */ + +import committed from './defaultIslandLayout.json' +import { mergeIslandLayout } from '../State/schema.js' + +// ── Constants mirroring the view modules ────────────────────────────────────── + +// Tree.js PLACEMENTS (lines 66-74) +const TREE_PLACEMENTS = [ + { species: 'oak', x: 0.0, z: 0.0, scale: 0.78, yaw: 0.00 }, + { species: 'oak', x: -2.1, z: -1.6, scale: 0.52, yaw: 0.85 }, + { species: 'cherry', x: 2.4, z: -1.1, scale: 0.50, yaw: 1.60 }, + { species: 'cherry', x: -1.8, z: 2.1, scale: 0.56, yaw: -0.70 }, + { species: 'oak', x: 1.6, z: 2.4, scale: 0.54, yaw: 2.35 }, + { species: 'oak', x: -3.2, z: 0.3, scale: 0.60, yaw: -1.30 }, + { species: 'cherry', x: 3.0, z: 0.9, scale: 0.48, yaw: 2.20 }, +] + +// Fruits.js BUSH_PLACEMENTS (lines 36-41) +const FRUIT_PLACEMENTS = [ + { species: 'plum', x: 2.6, z: 0.1 }, + { species: 'fig', x: -2.4, z: 0.9 }, + { species: 'citrus', x: 0.8, z: -2.6 }, + { species: 'berry', x: -1.0, z: -2.4 }, +] + +// Mailbox: x=-0.6, z=2.5 (Mailbox.js line 49) +const MAILBOX_X = -0.6 +const MAILBOX_Z = 2.5 + +// Telescope: cos(1.30)*4.85, sin(1.30)*4.85 (Telescope.js lines 27-28) +const RIM_THETA = 1.30 +const RIM_RADIUS = 4.85 + +// ── Flower placement formula ─────────────────────────────────────────────────── + +// Deterministic 32-bit hash β†’ 0..1 float. Matches Flowers.js exactly. +// seed=1337, n is the per-index salt. +const hash = (seed, n) => +{ + let h = seed | 0 + h = Math.imul(h ^ n, 2654435761) + h ^= h >>> 16 + return ((h >>> 0) % 10_000) / 10_000 +} + +// Plateau radius β€” mirrors Island.js / the Flowers.js formula +const ISLAND_RADIUS = 5.0 // Flowers.js uses `this.island.radius`; the default is 5.0 +const FLOWER_SEED = 1337 + +/** + * Return the `{ x, z, yaw }` base placement for flower index `i`. + * + * Flower 0 is pinned at `-1.4, 1.0` (the ceremony anchor). Every other + * flower uses the seeded polar formula from Flowers._buildOne. The caller + * passes the game island radius if known; defaults to 5.0. + * + * @param {number} i + * @param {number} [seed=1337] + * @param {number} [islandRadius=5.0] + * @returns {{ x: number, z: number, yaw: number }} + */ +export function flowerBasePlacement(i, seed = FLOWER_SEED, islandRadius = ISLAND_RADIUS) +{ + if(i === 0) + { + return { + x: -1.4, + z: 1.0, + yaw: hash(seed, 3000 + 0) * Math.PI * 2, + } + } + const radiusMax = islandRadius - 0.6 + const theta = hash(seed, 1000 + i) * Math.PI * 2 + const radial = Math.sqrt(hash(seed, 2000 + i)) * radiusMax + return { + x: Math.cos(theta) * radial, + z: Math.sin(theta) * radial, + yaw: hash(seed, 3000 + i) * Math.PI * 2, + } +} + +const FLOWER_SPECIES = ['daisy', 'tulip', 'rose', 'lily', 'pansy', 'hyacinth'] + +// ── Default builder ──────────────────────────────────────────────────────────── + +/** + * Return the committed default island layout. + * + * Loads from `defaultIslandLayout.json` (the authored, version-controlled + * default) and validates it through `mergeIslandLayout`. Falls back to + * `defaultIslandLayoutFromConstants()` if the file is missing or invalid, + * so the app never boots to an empty island. + * + * To update the default: edit the island in `/#editor`, click Export, and + * commit the downloaded JSON as `Game/Data/defaultIslandLayout.json`. + * + * @returns {{ v: 1, objects: PlacedObject[] }} + */ +export function defaultIslandLayout() +{ + const merged = mergeIslandLayout(committed) + if(merged && merged.objects.length > 0) return merged + return defaultIslandLayoutFromConstants() +} + +/** + * Build the canonical default layout from baked constants β€” the authoritative + * fallback if `defaultIslandLayout.json` is empty or invalid. + * + * Produces 31 objects: tree-0…tree-6, flower-0…flower-17, fruit-0…fruit-3, + * mailbox-0, telescope-0. + * + * @returns {{ v: 1, objects: PlacedObject[] }} + */ +export function defaultIslandLayoutFromConstants() +{ + /** @type {PlacedObject[]} */ + const objects = [] + + for(let i = 0; i < TREE_PLACEMENTS.length; i++) + { + const p = TREE_PLACEMENTS[i] + objects.push({ + id: `tree-${i}`, + kind: 'tree', + species: p.species, + x: p.x, + z: p.z, + yaw: p.yaw, + scale: p.scale, + locked: false, + }) + } + + for(let i = 0; i < 18; i++) + { + const { x, z, yaw } = flowerBasePlacement(i) + const species = FLOWER_SPECIES[i % FLOWER_SPECIES.length] + objects.push({ + id: `flower-${i}`, + kind: 'flower', + species, + x, + z, + yaw, + scale: 1, + locked: false, + }) + } + + for(let i = 0; i < FRUIT_PLACEMENTS.length; i++) + { + const p = FRUIT_PLACEMENTS[i] + objects.push({ + id: `fruit-${i}`, + kind: 'fruit', + species: p.species, + x: p.x, + z: p.z, + yaw: 0, + scale: 1, + locked: false, + }) + } + + objects.push({ + id: 'mailbox-0', + kind: 'mailbox', + species: undefined, + x: MAILBOX_X, + z: MAILBOX_Z, + yaw: 0, + scale: 1, + locked: true, + }) + + objects.push({ + id: 'telescope-0', + kind: 'telescope', + species: undefined, + x: Math.cos(RIM_THETA) * RIM_RADIUS, + z: Math.sin(RIM_THETA) * RIM_RADIUS, + yaw: 0, + scale: 1, + locked: true, + }) + + return { v: 1, objects } +} diff --git a/src/engine/student-space/Game/Data/speciesPalette.d.ts b/src/engine/student-space/Game/Data/speciesPalette.d.ts new file mode 100644 index 0000000..532fc37 --- /dev/null +++ b/src/engine/student-space/Game/Data/speciesPalette.d.ts @@ -0,0 +1,26 @@ +export interface TreeColors { + colorA: string + colorB: string +} + +export interface FlowerColors { + petal: string + centre?: string + face?: string +} + +export interface FruitColors { + color: string +} + +export type SpeciesColors = TreeColors | FlowerColors | FruitColors + +export interface SpeciesPaletteData { + v: 1 + tree: Record + flower: Record + fruit: Record +} + +export function defaultSpeciesPalette(): SpeciesPaletteData +export function defaultSpeciesPaletteFromConstants(): SpeciesPaletteData diff --git a/src/engine/student-space/Game/Data/speciesPalette.js b/src/engine/student-space/Game/Data/speciesPalette.js new file mode 100644 index 0000000..85eaba2 --- /dev/null +++ b/src/engine/student-space/Game/Data/speciesPalette.js @@ -0,0 +1,108 @@ +/** + * Species Palette β€” data model + default builder. + * + * Each species maps to its color slots: + * tree: { colorA: '#rrggbb', colorB: '#rrggbb' } + * flower: { petal: '#rrggbb', centre?: '#rrggbb', face?: '#rrggbb' } + * fruit: { color: '#rrggbb' } + * + * `defaultSpeciesPalette()` reproduces today's constants exactly (visual no-op). + */ + +/** @param {number} hex */ +function toHex(hex) { + return '#' + hex.toString(16).padStart(6, '0').toUpperCase() +} + +// ── Tree constants (Tree.js:50-53) ──────────────────────────────────────────── + +const OAK_COLOR_A = 0x3A7D2A +const OAK_COLOR_B = 0x8AAA35 +const CHERRY_COLOR_A = 0xFF66A3 +const CHERRY_COLOR_B = 0xFFCC66 + +// ── Flower constants (Flowers.js:20-27) ─────────────────────────────────────── + +const FLOWER_SPECIES = [ + { id: 'daisy', petal: 0xFF8E8E, centre: 0xFFD45A }, + { id: 'tulip', petal: 0xFFB0D5 }, + { id: 'rose', petal: 0xF0A86A }, + { id: 'lily', petal: 0xFFD45A, centre: 0xFAF1DC }, + { id: 'pansy', petal: 0xD09EE8, face: 0x2B2620 }, + { id: 'hyacinth', petal: 0xFAF1DC }, +] + +// ── Fruit constants (Fruits.js:23-32) ───────────────────────────────────────── + +const FRUIT_SPECIES = [ + { id: 'apple', color: 0xD64242 }, + { id: 'pear', color: 0xC9D659 }, + { id: 'plum', color: 0x7B3F8E }, + { id: 'fig', color: 0x6A3F62 }, + { id: 'citrus', color: 0xF1A22F }, + { id: 'berry', color: 0xB02A5E }, +] + +// ── Default builder ──────────────────────────────────────────────────────────── + +/** + * @typedef {{ colorA: string, colorB: string }} TreeColors + * @typedef {{ petal: string, centre?: string, face?: string }} FlowerColors + * @typedef {{ color: string }} FruitColors + * @typedef {{ v: 1, tree: Record, flower: Record, fruit: Record }} PaletteSnapshot + */ + +/** + * Build the canonical default palette from baked constants β€” the authoritative + * fallback if `defaultSpeciesPalette.json` is empty or invalid. + * + * @returns {PaletteSnapshot} + */ +export function defaultSpeciesPaletteFromConstants() +{ + /** @type {Record} */ + const tree = { + oak: { colorA: toHex(OAK_COLOR_A), colorB: toHex(OAK_COLOR_B) }, + cherry: { colorA: toHex(CHERRY_COLOR_A), colorB: toHex(CHERRY_COLOR_B) }, + } + + /** @type {Record} */ + const flower = {} + for(const s of FLOWER_SPECIES) + { + /** @type {FlowerColors} */ + const entry = { petal: toHex(s.petal) } + if(s.centre !== undefined) entry.centre = toHex(s.centre) + if(s.face !== undefined) entry.face = toHex(s.face) + flower[s.id] = entry + } + + /** @type {Record} */ + const fruit = {} + for(const s of FRUIT_SPECIES) + { + fruit[s.id] = { color: toHex(s.color) } + } + + return { v: 1, tree, flower, fruit } +} + +// ── defaultSpeciesPalette.json import (loaded in separate step after JSON exists) ── + +import committedPalette from './defaultSpeciesPalette.json' +import { mergeSpeciesPalette } from '../State/schema.js' + +/** + * Return the committed default species palette. + * + * Loads from `defaultSpeciesPalette.json`, falls back to + * `defaultSpeciesPaletteFromConstants()` if empty or invalid. + * + * @returns {PaletteSnapshot} + */ +export function defaultSpeciesPalette() +{ + const merged = mergeSpeciesPalette(committedPalette) + if(merged) return merged + return defaultSpeciesPaletteFromConstants() +} diff --git a/src/engine/student-space/Game/Game.js b/src/engine/student-space/Game/Game.js index 008eb83..f8d5899 100644 --- a/src/engine/student-space/Game/Game.js +++ b/src/engine/student-space/Game/Game.js @@ -14,6 +14,8 @@ import Choices from './State/Choices.js' import IdentityStatusOverride from './State/IdentityStatusOverride.js' import IslandSnapshotBridge from './State/IslandSnapshotBridge.js' import Auth from './State/Auth.js' +import IslandLayout from './State/IslandLayout.js' +import SpeciesPalette from './State/SpeciesPalette.js' import { HOST_BODY_CLASSES } from './host-body-classes.js' /** @@ -355,6 +357,8 @@ export default class Game IdentityStatusOverride.instance = null try { this.state?.islandSnapshots?.dispose?.() } catch(_) {} IslandSnapshotBridge.instance = null + IslandLayout.instance = null + SpeciesPalette.instance = null Game.instance = null } } diff --git a/src/engine/student-space/Game/State/IslandLayout.d.ts b/src/engine/student-space/Game/State/IslandLayout.d.ts new file mode 100644 index 0000000..b8c55a8 --- /dev/null +++ b/src/engine/student-space/Game/State/IslandLayout.d.ts @@ -0,0 +1,52 @@ +// Companion declarations for IslandLayout.js + +export type PlacedObjectKind = 'tree' | 'flower' | 'fruit' | 'mailbox' | 'telescope' + +export interface PlacedObject { + readonly id: string + readonly kind: PlacedObjectKind + readonly species?: string + readonly x: number + readonly z: number + readonly yaw?: number + readonly scale?: number + readonly locked?: boolean +} + +export interface IslandLayoutSnapshot { + readonly v: 1 + readonly objects: readonly PlacedObject[] +} + +export type IslandLayoutEvent = + | { type: 'objectAdded'; object: PlacedObject } + | { type: 'objectRemoved'; object: PlacedObject } + | { type: 'objectUpdated'; object: PlacedObject } + | { type: 'layoutReplaced'; layout: IslandLayoutSnapshot } + +export default class IslandLayout { + static instance: IslandLayout | null + static getInstance(): IslandLayout | null + + objects: PlacedObject[] + + constructor() + + list(): readonly PlacedObject[] + listByKind(kind: PlacedObjectKind): readonly PlacedObject[] + get(id: string): Readonly | undefined + + addObject(obj: unknown): void + removeObject(id: string): void + updateObject(id: string, patch: Partial): void + moveObject(id: string, pos: { x: number; z: number }): void + setLayout(layout: unknown): void + revertToDefault(): void + + isDiverged(): boolean + + subscribe(cb: (event: IslandLayoutEvent) => void): () => void + + hydrate(snapshot: unknown): void + serialize(): { v: 1; objects: PlacedObject[] } +} diff --git a/src/engine/student-space/Game/State/IslandLayout.js b/src/engine/student-space/Game/State/IslandLayout.js new file mode 100644 index 0000000..abac95b --- /dev/null +++ b/src/engine/student-space/Game/State/IslandLayout.js @@ -0,0 +1,296 @@ +/** + * IslandLayout state slice β€” singleton that owns the live authored placement + * for all five view kinds (tree, flower, fruit, mailbox, telescope). + * + * Architecture mirrors Sprouts.js: singleton, referentially-stable snapshot + * caches, `_invalidateCache` β†’ `_fan` β†’ `_persist`, lenient `hydrate`, clean + * `serialize`. + * + * Persistence model (working-copy-over-committed-base): + * - `_base` = `defaultIslandLayout()` (plan 004 will repoint to a committed file) + * - `objects` = live layout (working copy if loaded from storage; else base) + * - `isDiverged()` = objects deep-differ from `_base.objects` + * - `revertToDefault()` = reset objects to base, clear working copy + * + * The slice fans typed events (`objectAdded`, `objectRemoved`, `objectUpdated`, + * `layoutReplaced`) so downstream modules can subscribe without shape-sniffing. + */ + +import Persistence from './Persistence.js' +import { coercePosition, mergeIslandLayout, mergePlacedObject } from './schema.js' +import { defaultIslandLayout } from '../Data/islandLayout.js' + +let counter = 0 +const uuid = () => `${Date.now().toString(36)}-${(counter++).toString(36)}` + +export default class IslandLayout +{ + static instance + + static getInstance() { return IslandLayout.instance } + + constructor() + { + if(IslandLayout.instance) return IslandLayout.instance + IslandLayout.instance = this + + this._base = defaultIslandLayout() + // Clone the base objects so mutations to `this.objects` don't mutate the base. + this.objects = this._base.objects.map((o) => ({ ...o })) + this.subscribers = new Set() + + // Snapshot caches β€” invalidated on every mutation. Provides stable + // references for React's useSyncExternalStore pattern. + this._listCache = null + this._listByKindCache = new Map() + this._getCache = new Map() + } + + // ── Query API ────────────────────────────────────────────────────────── + + /** All placed objects in insertion order. Returns a stable frozen array. */ + list() + { + if(this._listCache) return this._listCache + this._listCache = Object.freeze(this.objects.map((o) => Object.freeze({ ...o }))) + return this._listCache + } + + /** + * All objects matching `kind`. Returns a stable frozen array. + * @param {string} kind + */ + listByKind(kind) + { + if(this._listByKindCache.has(kind)) return this._listByKindCache.get(kind) + const filtered = Object.freeze( + this.objects + .filter((o) => o.kind === kind) + .map((o) => Object.freeze({ ...o })), + ) + this._listByKindCache.set(kind, filtered) + return filtered + } + + /** + * Find one object by id. Returns a stable frozen object or undefined. + * @param {string} id + */ + get(id) + { + if(this._getCache.has(id)) return this._getCache.get(id) + const obj = this.objects.find((o) => o.id === id) + if(!obj) return undefined + const frozen = Object.freeze({ ...obj }) + this._getCache.set(id, frozen) + return frozen + } + + // ── Mutation API ─────────────────────────────────────────────────────── + + /** + * Add a new placed object. If `obj.id` is absent, assigns + * `${kind}-${uuid()}`. Rejects duplicate ids. Fans `objectAdded`. + * @param {object} obj + */ + addObject(obj) + { + if(!obj || typeof obj !== 'object') return + // Assign a generated id before merge so mergePlacedObject's id-required + // check passes when the caller omits id. + const withId = { ...obj } + if(!withId.id && withId.kind) withId.id = `${withId.kind}-${uuid()}` + const merged = mergePlacedObject(withId, 'addObject') + if(!merged) return + if(this.objects.some((o) => o.id === merged.id)) return + this.objects.push(merged) + this._invalidateCache() + this._fan({ type: 'objectAdded', object: merged }) + this._persist() + } + + /** + * Remove an object by id. No-op on unknown id. Fans `objectRemoved`. + * @param {string} id + */ + removeObject(id) + { + if(typeof id !== 'string') return + const idx = this.objects.findIndex((o) => o.id === id) + if(idx === -1) return + const removed = this.objects[idx] + this.objects.splice(idx, 1) + this._invalidateCache() + this._fan({ type: 'objectRemoved', object: removed }) + this._persist() + } + + /** + * Apply a partial patch to an object. `id` and `kind` are immutable. + * Fans `objectUpdated`. + * @param {string} id + * @param {object} patch + */ + updateObject(id, patch) + { + if(typeof id !== 'string' || !patch || typeof patch !== 'object') return + const obj = this.objects.find((o) => o.id === id) + if(!obj) return + // id and kind are immutable + const { id: _id, kind: _kind, ...safe } = patch + // Validate numeric fields + for(const k of ['x', 'z', 'yaw', 'scale']) + { + if(k in safe && (typeof safe[k] !== 'number' || !Number.isFinite(safe[k]))) + { + delete safe[k] + } + } + Object.assign(obj, safe) + this._invalidateCache() + this._fan({ type: 'objectUpdated', object: { ...obj } }) + this._persist() + } + + /** + * Move an object to a new `{ x, z }` position. Validates via + * `coercePosition`. Fans `objectUpdated`. + * @param {string} id + * @param {{ x: number, z: number }} pos + */ + moveObject(id, pos) + { + if(typeof id !== 'string') return + const coerced = coercePosition(pos) + if(!coerced) return + const obj = this.objects.find((o) => o.id === id) + if(!obj) return + obj.x = coerced.x + obj.z = coerced.z + this._invalidateCache() + this._fan({ type: 'objectUpdated', object: { ...obj } }) + this._persist() + } + + /** + * Replace the entire layout. Validates via `mergeIslandLayout`. + * Fans `layoutReplaced`. + * @param {{ v: 1, objects: object[] }} layout + */ + setLayout(layout) + { + const merged = mergeIslandLayout(layout) + if(!merged) return + this.objects = merged.objects + this._invalidateCache() + this._fan({ type: 'layoutReplaced', layout: merged }) + this._persist() + } + + /** + * Revert the working copy to the committed base default. Clears the + * persisted working copy. Fans `layoutReplaced`. + */ + revertToDefault() + { + this.objects = this._base.objects.map((o) => ({ ...o })) + this._invalidateCache() + // Wipe the persisted working copy so the next boot also defaults. + Persistence.getInstance()?.save('islandLayout', null) + this._fan({ type: 'layoutReplaced', layout: this._base }) + } + + /** + * True when the current live objects differ from the committed base. + * @returns {boolean} + */ + isDiverged() + { + const live = this.objects + const base = this._base.objects + if(live.length !== base.length) return true + for(let i = 0; i < live.length; i++) + { + const l = live[i] + const b = base[i] + if( + l.id !== b.id || + l.kind !== b.kind || + l.species !== b.species || + l.x !== b.x || + l.z !== b.z || + l.yaw !== b.yaw || + l.scale !== b.scale || + l.locked !== b.locked + ) return true + } + return false + } + + // ── Subscribe ────────────────────────────────────────────────────────── + + /** + * Subscribe to mutation events. Callback receives an event object. + * Returns an unsubscribe function. + * @param {(event: object) => void} cb + * @returns {() => void} + */ + subscribe(cb) + { + this.subscribers.add(cb) + return () => this.subscribers.delete(cb) + } + + // ── Persistence ──────────────────────────────────────────────────────── + + /** + * Hydrate from a persisted working-copy snapshot. If the snapshot is + * valid and non-empty, it replaces the base default. Otherwise the + * slice keeps the base. + * @param {unknown} snapshot + */ + hydrate(snapshot) + { + if(!snapshot || typeof snapshot !== 'object') return + const merged = mergeIslandLayout(snapshot) + if(!merged) return + this.objects = merged.objects + this._invalidateCache() + // Bulk hydrate does NOT fan events (same rationale as Sprouts.hydrate). + } + + /** + * Serialize the current working-copy layout. + * @returns {{ v: 1, objects: object[] }} + */ + serialize() + { + return { + v: 1, + objects: this.objects.map((o) => ({ ...o })), + } + } + + // ── Internal ─────────────────────────────────────────────────────────── + + _invalidateCache() + { + this._listCache = null + this._listByKindCache.clear() + this._getCache.clear() + } + + _fan(event) + { + for(const cb of this.subscribers) + { + try { cb(event) } + catch(err) { console.warn('[islandLayout] subscriber threw', err) } + } + } + + _persist() + { + Persistence.getInstance()?.save('islandLayout', this.serialize()) + } +} diff --git a/src/engine/student-space/Game/State/Persistence.js b/src/engine/student-space/Game/State/Persistence.js index 4741382..c6d9294 100644 --- a/src/engine/student-space/Game/State/Persistence.js +++ b/src/engine/student-space/Game/State/Persistence.js @@ -42,9 +42,11 @@ const KEY = { relationships: `${NS}:relationships`, choices: `${NS}:choices`, identityStatusOverride: `${NS}:identityStatusOverride`, + islandLayout: `${NS}:islandLayout`, + speciesPalette: `${NS}:speciesPalette`, } -const SLICES = ['moodPins', 'captures', 'profile', 'letters', 'calendar', 'onboarding', 'sprouts', 'relationships', 'choices', 'identityStatusOverride'] +const SLICES = ['moodPins', 'captures', 'profile', 'letters', 'calendar', 'onboarding', 'sprouts', 'relationships', 'choices', 'identityStatusOverride', 'islandLayout', 'speciesPalette'] const DEBOUNCE_MS = 250 /** @@ -231,7 +233,7 @@ export default class Persistence */ load() { - const empty = { moodPins: [], captures: [], profile: null, letters: [], calendar: [], onboarding: null, sprouts: null, relationships: null, choices: null, identityStatusOverride: null } + const empty = { moodPins: [], captures: [], profile: null, letters: [], calendar: [], onboarding: null, sprouts: null, relationships: null, choices: null, identityStatusOverride: null, islandLayout: null, speciesPalette: null } if(!this._available) return empty let storedV = 0 diff --git a/src/engine/student-space/Game/State/SpeciesPalette.d.ts b/src/engine/student-space/Game/State/SpeciesPalette.d.ts new file mode 100644 index 0000000..87157ab --- /dev/null +++ b/src/engine/student-space/Game/State/SpeciesPalette.d.ts @@ -0,0 +1,21 @@ +import type { SpeciesPaletteData, SpeciesColors } from '../Data/speciesPalette' + +export type PaletteEvent = + | { type: 'paletteChanged'; kind: string; species: string; colors: SpeciesColors } + | { type: 'paletteReplaced' } + +export default class SpeciesPalette { + static instance: SpeciesPalette | null + static getInstance(): SpeciesPalette | null + + constructor() + + get(kind: string, species: string): Record | null + list(): SpeciesPaletteData + setColor(kind: string, species: string, colors: Partial): void + isDiverged(): boolean + revertToDefault(): void + subscribe(cb: (event: PaletteEvent) => void): () => void + hydrate(snapshot: unknown): void + serialize(): SpeciesPaletteData +} diff --git a/src/engine/student-space/Game/State/SpeciesPalette.js b/src/engine/student-space/Game/State/SpeciesPalette.js new file mode 100644 index 0000000..115daca --- /dev/null +++ b/src/engine/student-space/Game/State/SpeciesPalette.js @@ -0,0 +1,180 @@ +/** + * SpeciesPalette β€” working-copy-over-committed-base slice for species colors. + * + * Same model as IslandLayout (plan 001): base = defaultSpeciesPalette(), + * working copy overridden per-species per setColor(), persisted in localStorage. + * Fires { type: 'paletteChanged', kind, species, colors } on setColor(). + * Fires { type: 'paletteReplaced' } on revertToDefault() / setFromSnapshot(). + */ + +import Persistence from './Persistence.js' +import { defaultSpeciesPalette, defaultSpeciesPaletteFromConstants } from '../Data/speciesPalette.js' +import { mergeSpeciesPalette } from './schema.js' + +export default class SpeciesPalette +{ + static instance = null + + static getInstance() { return SpeciesPalette.instance } + + constructor() + { + if(SpeciesPalette.instance) return SpeciesPalette.instance + SpeciesPalette.instance = this + + this._base = defaultSpeciesPalette() + this._working = null // null = not diverged + this._version = 0 + this._listeners = [] + } + + // ── Read API ─────────────────────────────────────────────────────────────── + + /** + * Return the current colors for a (kind, species) pair. + * Falls back to the committed default if the working copy doesn't override it. + * + * @param {'tree'|'flower'|'fruit'} kind + * @param {string} species + * @returns {object|null} + */ + get(kind, species) + { + const working = this._working?.[kind]?.[species] + if(working) return { ...working } + return this._base[kind]?.[species] ? { ...this._base[kind][species] } : null + } + + /** @returns {import('../Data/speciesPalette.js').PaletteSnapshot} */ + list() + { + const base = this._base + const work = this._working + + /** @param {Record} b @param {Record|undefined} w */ + function mergeKind(b, w) + { + const out = {} + for(const [id, colors] of Object.entries(b || {})) + { + out[id] = { ...colors, ...(w?.[id] || {}) } + } + return out + } + + return { + v: 1, + tree: mergeKind(base.tree, work?.tree), + flower: mergeKind(base.flower, work?.flower), + fruit: mergeKind(base.fruit, work?.fruit), + } + } + + isDiverged() + { + return this._working !== null + } + + // ── Write API ────────────────────────────────────────────────────────────── + + /** + * Update colors for a (kind, species) pair. + * Fans paletteChanged; persists; bumps version. + * + * @param {'tree'|'flower'|'fruit'} kind + * @param {string} species + * @param {object} colors β€” must contain at least one color field + */ + setColor(kind, species, colors) + { + if(!colors || typeof colors !== 'object') return false + if(!['tree', 'flower', 'fruit'].includes(kind)) return false + + if(!this._working) this._working = {} + if(!this._working[kind]) this._working[kind] = {} + this._working[kind][species] = { ...(this._working[kind][species] || {}), ...colors } + + this._invalidate() + this._fan({ type: 'paletteChanged', kind, species, colors }) + this._persist() + return true + } + + setFromSnapshot(raw) + { + const merged = mergeSpeciesPalette(raw) + if(!merged) return false + this._working = { tree: merged.tree, flower: merged.flower, fruit: merged.fruit } + this._invalidate() + this._fan({ type: 'paletteReplaced' }) + this._persist() + return true + } + + revertToDefault() + { + this._working = null + this._invalidate() + this._fan({ type: 'paletteReplaced' }) + this._persist() + } + + // ── Slice protocol ───────────────────────────────────────────────────────── + + serialize() + { + return this.list() + } + + hydrate(snapshot) + { + if(!snapshot || typeof snapshot !== 'object') return + const merged = mergeSpeciesPalette(snapshot) + if(!merged) return + + // Compare to base to determine if this represents a working-copy divergence + // or if it's equal to the default (no divergence). + const base = this._base + const isDefault = ['tree', 'flower', 'fruit'].every((k) => + JSON.stringify(merged[k]) === JSON.stringify(base[k]) + ) + + if(!isDefault) + { + this._working = { tree: merged.tree, flower: merged.flower, fruit: merged.fruit } + } + else + { + this._working = null + } + this._invalidate() + } + + /** + * @param {(event: {type: string, kind?: string, species?: string, colors?: object}) => void} cb + * @returns {() => void} + */ + subscribe(cb) + { + this._listeners.push(cb) + return () => + { + const i = this._listeners.indexOf(cb) + if(i >= 0) this._listeners.splice(i, 1) + } + } + + // ── Private ──────────────────────────────────────────────────────────────── + + _fan(event) + { + for(const cb of this._listeners.slice()) { try { cb(event) } catch(e) { console.warn('[SpeciesPalette] listener threw', e) } } + } + + _invalidate() { this._version++ } + + _persist() + { + Persistence.getInstance()?.save('speciesPalette', this.serialize()) + } +} diff --git a/src/engine/student-space/Game/State/State.js b/src/engine/student-space/Game/State/State.js index 7604d0c..6634c54 100644 --- a/src/engine/student-space/Game/State/State.js +++ b/src/engine/student-space/Game/State/State.js @@ -20,6 +20,8 @@ import Choices from './Choices.js' import IdentityStatusOverride from './IdentityStatusOverride.js' import IslandSnapshotBridge from './IslandSnapshotBridge.js' import Auth from './Auth.js' +import IslandLayout from './IslandLayout.js' +import SpeciesPalette from './SpeciesPalette.js' export default class State { @@ -81,6 +83,8 @@ export default class State this.coldStart = new ColdStart() this.sun = new Sun() this.island = new Island() + this.islandLayout = new IslandLayout() + this.speciesPalette = new SpeciesPalette() this.moodPins = new MoodPins() this.captures = new Captures() this.profile = new Profile() @@ -107,6 +111,8 @@ export default class State this.relationships.hydrate(snapshot.relationships) this.choices.hydrate(snapshot.choices) this.identityStatusOverride.hydrate(snapshot.identityStatusOverride) + this.islandLayout.hydrate(snapshot.islandLayout) + this.speciesPalette.hydrate(snapshot.speciesPalette) // Cross-slice wiring β€” Sprouts subscribes to Captures and MoodPins so // every new capture/mood grows the active sprout. The helper wraps diff --git a/src/engine/student-space/Game/State/schema.js b/src/engine/student-space/Game/State/schema.js index a6e114b..7ea2045 100644 --- a/src/engine/student-space/Game/State/schema.js +++ b/src/engine/student-space/Game/State/schema.js @@ -846,3 +846,105 @@ export function mergeChoices(raw) if(Array.isArray(raw.intentions)) out.intentions = mergeArray(raw.intentions, mergeChangeIntention, 'choices.intentions') return out } + +// ── IslandLayout ─────────────────────────────────────────────────────────────── + +const PLACED_OBJECT_KINDS = new Set(['tree', 'flower', 'fruit', 'mailbox', 'telescope']) +const KNOWN_PLACED_OBJECT_KEYS = new Set(['id', 'kind', 'species', 'x', 'z', 'yaw', 'scale', 'locked']) + +const defaultPlacedObject = () => ({ + id: '', + kind: 'tree', + species: undefined, + x: 0, + z: 0, + yaw: 0, + scale: 1, + locked: false, +}) + +export function mergePlacedObject(raw, ctx = 'placedObject') +{ + if(!raw || typeof raw !== 'object') { warn(`${ctx}: not an object`); return null } + const out = defaultPlacedObject() + for(const k of Object.keys(raw)) + { + if(!KNOWN_PLACED_OBJECT_KEYS.has(k)) { warn(`${ctx}: dropping unknown key "${k}"`); continue } + const v = raw[k] + if(k === 'kind' && !PLACED_OBJECT_KINDS.has(v)) { warn(`${ctx}.kind invalid: "${v}"`); continue } + if(k === 'id' && !isString(v)) { warn(`${ctx}.id not string`); continue } + if((k === 'x' || k === 'z' || k === 'yaw' || k === 'scale') && (typeof v !== 'number' || !Number.isFinite(v))) { warn(`${ctx}.${k} not finite number`); continue } + if(k === 'locked' && !isBool(v)) { warn(`${ctx}.locked not bool`); continue } + if(k === 'species' && v !== null && v !== undefined && !isString(v)) { warn(`${ctx}.species not string`); continue } + out[k] = v + } + if(!out.id || !out.kind) return null + return out +} + +export function mergeIslandLayout(raw) +{ + if(!raw || typeof raw !== 'object') return null + if(!Array.isArray(raw.objects)) return null + const objects = mergeArray(raw.objects, mergePlacedObject, 'islandLayout.objects') + if(objects.length === 0) return null + return { v: 1, objects } +} + +const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/ + +export function mergeSpeciesPalette(raw) +{ + if(!raw || typeof raw !== 'object') return null + + const TREE_SPECIES = ['oak', 'cherry'] + const FLOWER_SPECIES = ['daisy', 'tulip', 'rose', 'lily', 'pansy', 'hyacinth'] + const FRUIT_SPECIES = ['apple', 'pear', 'plum', 'fig', 'citrus', 'berry'] + + const isHex = (v) => typeof v === 'string' && HEX_COLOR_RE.test(v) + + /** @param {unknown} obj @param {string[]} slots */ + function mergeColors(obj, slots) + { + if(!obj || typeof obj !== 'object') return null + const out = {} + for(const slot of slots) + { + const v = obj[slot] + if(v !== undefined) + { + if(!isHex(v)) { warn(`mergeSpeciesPalette: ${slot} invalid hex "${v}"`); continue } + out[slot] = v + } + } + return out + } + + const tree = {} + const flower = {} + const fruit = {} + + const rawTree = raw.tree || {} + for(const s of TREE_SPECIES) + { + const m = mergeColors(rawTree[s], ['colorA', 'colorB']) + if(m) tree[s] = m + } + + const rawFlower = raw.flower || {} + for(const s of FLOWER_SPECIES) + { + const m = mergeColors(rawFlower[s], ['petal', 'centre', 'face']) + if(m) flower[s] = m + } + + const rawFruit = raw.fruit || {} + for(const s of FRUIT_SPECIES) + { + const m = mergeColors(rawFruit[s], ['color']) + if(m) fruit[s] = m + } + + if(Object.keys(tree).length === 0 && Object.keys(flower).length === 0 && Object.keys(fruit).length === 0) return null + return { v: 1, tree, flower, fruit } +} diff --git a/src/engine/student-space/Game/View/Flowers.js b/src/engine/student-space/Game/View/Flowers.js index fdaa7dc..cbe5e0d 100644 --- a/src/engine/student-space/Game/View/Flowers.js +++ b/src/engine/student-space/Game/View/Flowers.js @@ -366,8 +366,16 @@ export default class Flowers // flowers stay invisible during normal play so the island grows // only with the student's actual captures. this.flowers = [] - for(let i = 0; i < INSTANCES; i++) - this._buildOne(seed, i) + // Read base placements from the IslandLayout slice so the layout is + // data-driven. The slice's default reproduces the seed=1337 formula + // exactly (visual no-op). Each entry carries a layoutId for plan 002+. + const layoutPlacements = this.state.islandLayout.listByKind('flower') + const count = layoutPlacements.length > 0 ? layoutPlacements.length : INSTANCES + for(let i = 0; i < count; i++) + { + const placement = layoutPlacements[i] + this._buildOne(seed, i, placement) + } for(const f of this.flowers) { f.group.visible = false @@ -375,40 +383,62 @@ export default class Flowers } } - _buildOne(seed, i) + /** + * Build one flower instance. If `placement` is provided, its `x`, `z`, + * `yaw`, and `species` override the seeded defaults; otherwise the hash + * formula is used (backward-compatible fallback). + * + * @param {number} seed + * @param {number} i + * @param {{ id?: string, x?: number, z?: number, yaw?: number, species?: string } | undefined} [placement] + */ + _buildOne(seed, i, placement) { - const species = this.species[i % this.species.length] - - // Flower 0 is the ceremony anchor β€” pinned to a deliberate spot - // forward-left of the centre tree so IslandReveal's beat J - // close-up and beat K wide both compose cleanly. Every other - // flower samples a position uniformly inside the plateau, inset - // from the rim so they don't poke through the cliff face. - let x, z - if(i === 0) + // Species: from placement if provided, otherwise cycle through SPECIES + let speciesObj + if(placement?.species) { - x = -1.4 - z = 1.0 + speciesObj = SPECIES_BY_ID[placement.species] || this.species[i % this.species.length] + } + else + { + speciesObj = this.species[i % this.species.length] + } + + // Position: from placement if provided, otherwise seeded formula + let x, z, yaw + if(placement && typeof placement.x === 'number' && typeof placement.z === 'number') + { + x = placement.x + z = placement.z + yaw = typeof placement.yaw === 'number' ? placement.yaw : hash(seed, 3000 + i) * Math.PI * 2 + } + else if(i === 0) + { + x = -1.4 + z = 1.0 + yaw = hash(seed, 3000 + i) * Math.PI * 2 } else { const radiusMax = this.island.radius - 0.6 const theta = hash(seed, 1000 + i) * Math.PI * 2 const radial = Math.sqrt(hash(seed, 2000 + i)) * radiusMax - x = Math.cos(theta) * radial - z = Math.sin(theta) * radial + x = Math.cos(theta) * radial + z = Math.sin(theta) * radial + yaw = hash(seed, 3000 + i) * Math.PI * 2 } const y = this.island.heightAt(x, z) const flower = new THREE.Group() flower.position.set(x, y, z) - flower.rotation.y = hash(seed, 3000 + i) * Math.PI * 2 + flower.rotation.y = yaw flower.add(buildStem()) const petalGroup = new THREE.Group() petalGroup.position.y = STEM_HEIGHT - const bloom = SHAPE_BUILDERS[species.id](species) + const bloom = SHAPE_BUILDERS[speciesObj.id](speciesObj) petalGroup.add(bloom) flower.add(petalGroup) @@ -416,10 +446,11 @@ export default class Flowers this.flowers.push({ group: flower, petalGroup, - species, - index: i, + species: speciesObj, + index: i, x, z, - phase: hash(seed, 4000 + i) * Math.PI * 2, + layoutId: placement?.id, + phase: hash(seed, 4000 + i) * Math.PI * 2, }) } @@ -462,6 +493,68 @@ export default class Flowers return true } + /** + * Island editor (plan 003): reconcile the live flowers array with + * a new layout list. Adds groups for new layout ids; disposes and + * removes groups for ids that are no longer in the layout. + * + * @param {readonly import('../State/IslandLayout.js').PlacedObject[]} objs + */ + ensureFromLayout(objs) + { + const seed = 1337 + + // Build an idβ†’flower map for quick lookup. + const existing = new Map(this.flowers.map((f) => [f.layoutId, f])) + const newIds = new Set(objs.map((o) => o.id)) + + // Remove flowers whose layout id is no longer present. + const kept = [] + for(const f of this.flowers) + { + if(!f.layoutId || newIds.has(f.layoutId)) + { + kept.push(f) + } + else + { + // Dispose bloom + for(let c = f.petalGroup.children.length - 1; c >= 0; c--) + { + const child = f.petalGroup.children[c] + f.petalGroup.remove(child) + child.traverse?.((n) => + { + if(n.geometry) try { n.geometry.dispose() } catch(_) {} + if(n.material) try { n.material.dispose() } catch(_) {} + }) + } + this.group.remove(f.group) + f.group.traverse?.((n) => + { + if(n.geometry) try { n.geometry.dispose() } catch(_) {} + if(n.material) try { n.material.dispose() } catch(_) {} + }) + } + } + this.flowers = kept + + // Add flowers for new layout ids not yet in the array. + for(let i = 0; i < objs.length; i++) + { + const obj = objs[i] + if(existing.has(obj.id)) continue + this._buildOne(seed, this.flowers.length, obj) + // New flowers start visible in the editor preview. + const f = this.flowers[this.flowers.length - 1] + if(f) + { + f.group.visible = true + f.petalGroup.scale.setScalar(1) + } + } + } + /** * First-run ceremony helper. Hide every flower group so the plateau * reads as bare until bloomInstance() reveals the directed one. diff --git a/src/engine/student-space/Game/View/Fruits.js b/src/engine/student-space/Game/View/Fruits.js index 1d3b1e8..2653dce 100644 --- a/src/engine/student-space/Game/View/Fruits.js +++ b/src/engine/student-space/Game/View/Fruits.js @@ -76,6 +76,31 @@ export default class Fruits this._peduncleGeo = new THREE.CylinderGeometry(0.005, 0.006, 0.03, 5) this._peduncleMat = new THREE.MeshLambertMaterial({ color: PEDUNCLE_COLOR, flatShading: true }) + // Apply palette colors from SpeciesPalette if diverged from defaults. + const palette = this.state.speciesPalette + if(palette) + { + for(const [id] of Object.entries(FRUIT_SPECIES)) + { + const c = palette.get('fruit', id) + if(c?.color) this._berryMats[id]?.color.set(c.color) + } + this._unsubPalette = palette.subscribe((event) => + { + if((event.type === 'paletteChanged' && event.kind === 'fruit') || event.type === 'paletteReplaced') + { + const kinds = event.type === 'paletteReplaced' + ? Object.keys(FRUIT_SPECIES) + : [event.species] + for(const id of kinds) + { + const c = palette.get('fruit', id) + if(c?.color && this._berryMats[id]) this._berryMats[id].color.set(c.color) + } + } + }) + } + // Bushes reuse Tree's billboard cloud + leaves shader; placement is // deferred to update() so we wait for Tree.ready. this._placed = false @@ -89,9 +114,9 @@ export default class Fruits const leafGeo = tree.leafCloudGeo const leafMat = tree.templates.oak.leavesMat // shared shader β€” wind + sun sync for free - for(const placement of BUSH_PLACEMENTS) + for(const placement of this.state.islandLayout.listByKind('fruit')) { - const { species, x, z } = placement + const { id: layoutId, species, x, z } = placement const cfg = FRUIT_SPECIES[species] if(!cfg) continue @@ -171,6 +196,7 @@ export default class Fruits x, z, host: 'bush', index: this.entries.length, + layoutId, }) } } @@ -224,6 +250,122 @@ export default class Fruits // If hideAll was requested while we were waiting for tree.ready, // apply it now that the bushes exist. if(this._hidePending) this.hideAll() + // If ensureFromLayout was called before placement, run it now. + if(this._pendingEnsure) + { + const objs = this._pendingEnsure + this._pendingEnsure = null + this.ensureFromLayout(objs) + } + } + } + + /** + * Island editor (plan 003): reconcile live fruit entries with a new + * layout list. Adds groups for new layout ids; disposes and removes + * groups for ids no longer in the layout. + * + * Requires tree.ready (bushes use the leaf shader), so it defers if + * not yet placed. + * + * @param {readonly import('../State/IslandLayout.js').PlacedObject[]} objs + */ + ensureFromLayout(objs) + { + if(!this._placed) + { + // Not yet placed β€” schedule a reconcile after _placeBushes runs. + this._pendingEnsure = objs + return + } + + const existing = new Map(this.entries.map((e) => [e.layoutId, e])) + const newIds = new Set(objs.map((o) => o.id)) + + // Remove entries whose layout id is gone. + const kept = [] + for(const entry of this.entries) + { + if(!entry.layoutId || newIds.has(entry.layoutId)) + { + kept.push(entry) + } + else + { + this.scene.remove(entry.group) + entry.group.traverse?.((n) => + { + if(n.geometry) try { n.geometry.dispose() } catch(_) {} + if(n.material) try { n.material.dispose() } catch(_) {} + }) + } + } + this.entries = kept + + // Build bushes for new ids. + const tree = this.view.tree + if(!tree?.ready) return + const leafGeo = tree.leafCloudGeo + const leafMat = tree.templates.oak.leavesMat + + for(const obj of objs) + { + if(existing.has(obj.id)) continue + const cfg = FRUIT_SPECIES[obj.species] + if(!cfg) continue + + const { x, z } = obj + const groundY = this.island.heightAt(x, z) + const rnd = mulberry32(hashSeed(x, z, obj.species)) + + const group = new THREE.Group() + group.position.set(x, groundY, z) + group.userData.fruitBush = true + this.scene.add(group) + + const blobs = [ + { dx: 0, dz: 0, r: 0.32 + rnd() * 0.04 }, + { dx: (rnd() - 0.5) * 0.42, dz: (rnd() - 0.5) * 0.42, r: 0.20 + rnd() * 0.05 }, + ] + const matrices = blobs.map((b) => + new THREE.Matrix4().compose( + new THREE.Vector3(b.dx, b.r * 0.88, b.dz), + new THREE.Quaternion(), + new THREE.Vector3(b.r, b.r, b.r), + ) + ) + const inst = new THREE.InstancedMesh(leafGeo, leafMat, matrices.length) + inst.frustumCulled = false + for(let i = 0; i < matrices.length; i++) inst.setMatrixAt(i, matrices[i]) + inst.instanceMatrix.needsUpdate = true + group.add(inst) + + const canopy = blobs.map((b) => ({ cx: b.dx, cy: b.r * 0.88, cz: b.dz, r: b.r })) + for(let i = 0; i < FRUITS_PER_BUSH; i++) + { + const blob = canopy[Math.min(i, canopy.length - 1)] + const thetaF = rnd() * Math.PI * 2 + const phi = Math.acos(2 * rnd() - 1) + const r = blob.r * (0.94 + rnd() * 0.12) + const cluster = this._buildBerryCluster(obj.species, rnd) + cluster.position.set( + blob.cx + r * Math.sin(phi) * Math.cos(thetaF), + blob.cy + r * Math.cos(phi) - blob.r * 0.05, + blob.cz + r * Math.sin(phi) * Math.sin(thetaF), + ) + group.add(cluster) + } + + group.visible = true + this.entries.push({ + kind: 'fruit', + group, + species: obj.species, + x, z, + host: 'bush', + index: this.entries.length, + layoutId: obj.id, + }) } } diff --git a/src/engine/student-space/Game/View/Mailbox.js b/src/engine/student-space/Game/View/Mailbox.js index e86bcbe..70b8574 100644 --- a/src/engine/student-space/Game/View/Mailbox.js +++ b/src/engine/student-space/Game/View/Mailbox.js @@ -46,7 +46,12 @@ export default class Mailbox // closer to the centre line than before to keep clear of the cherry // tree at (-1.8, 2.1) β€” the previous (-1.8, 2.4) spot overlapped its // foliage envelope. - const x = -0.6, z = 2.5 + // Base placement is driven by the IslandLayout slice; fallback to the + // authored constants so the constructor never throws if the slice is not + // yet available (e.g. during isolated unit tests). + const _mailboxPlacement = this.state.islandLayout?.get('mailbox-0') + const x = _mailboxPlacement ? _mailboxPlacement.x : -0.6 + const z = _mailboxPlacement ? _mailboxPlacement.z : 2.5 const groundY = this.island.heightAt(x, z) this.position = { x, y: groundY, z } diff --git a/src/engine/student-space/Game/View/Sprouts.js b/src/engine/student-space/Game/View/Sprouts.js index d71c574..23dd816 100644 --- a/src/engine/student-space/Game/View/Sprouts.js +++ b/src/engine/student-space/Game/View/Sprouts.js @@ -351,6 +351,9 @@ export default class Sprouts this._dragGuard.downY = e.clientY if(!this._editMode) return + // Guard: island editor is exclusive while #editor is active so both + // drag systems don't fight over the same pointer events. + if(typeof window !== 'undefined' && window.location.hash.includes('editor')) return const target = this._raycastDraggable(e) if(!target) return diff --git a/src/engine/student-space/Game/View/Telescope.js b/src/engine/student-space/Game/View/Telescope.js index 5c42054..f8cf77c 100644 --- a/src/engine/student-space/Game/View/Telescope.js +++ b/src/engine/student-space/Game/View/Telescope.js @@ -40,8 +40,12 @@ export default class Telescope this.scene = this.view.scene this.island = this.state.island - const x = Math.cos(RIM_THETA) * RIM_RADIUS - const z = Math.sin(RIM_THETA) * RIM_RADIUS + // Base placement is driven by the IslandLayout slice; fallback to the + // authored rim constants so the constructor never throws if the slice is + // not yet available (e.g. during isolated unit tests). + const _telescopePlacement = this.state.islandLayout?.get('telescope-0') + const x = _telescopePlacement ? _telescopePlacement.x : Math.cos(RIM_THETA) * RIM_RADIUS + const z = _telescopePlacement ? _telescopePlacement.z : Math.sin(RIM_THETA) * RIM_RADIUS const groundY = this.island.heightAt(x, z) this.position = { x, y: groundY, z } diff --git a/src/engine/student-space/Game/View/Tree.js b/src/engine/student-space/Game/View/Tree.js index 0856cfe..4d52a6e 100644 --- a/src/engine/student-space/Game/View/Tree.js +++ b/src/engine/student-space/Game/View/Tree.js @@ -312,6 +312,32 @@ export default class Tree this._loadAndBuild() this.setDebug() + + // Subscribe to live palette changes (plan 005). + const palette = this.state.speciesPalette + if(palette) + { + this._unsubPalette = palette.subscribe((event) => + { + if((event.type === 'paletteChanged' && event.kind === 'tree') || event.type === 'paletteReplaced') + { + const species = event.type === 'paletteReplaced' ? ['oak', 'cherry'] : [event.species] + for(const s of species) this._applyTreeColors(s) + } + }) + } + } + + _applyTreeColors(species) + { + const palette = this.state.speciesPalette + if(!palette || !this.templates) return + const c = palette.get('tree', species) + if(!c) return + const tpl = this.templates[species] + if(!tpl?.leavesMat?.uniforms) return + if(c.colorA) tpl.leavesMat.uniforms.uColorA.value.set(c.colorA) + if(c.colorB) tpl.leavesMat.uniforms.uColorB.value.set(c.colorB) } async _loadAndBuild() @@ -328,6 +354,10 @@ export default class Tree cherry: this._extractTemplate(cherryGltf, CHERRY_COLOR_A, CHERRY_COLOR_B), } + // Apply any persisted palette overrides now that templates exist. + this._applyTreeColors('oak') + this._applyTreeColors('cherry') + // Shared billboard-cloud geometry (unit sphere local) β€” one mesh, // every instance reuses it. this.leafCloudGeo = buildLeafCloudGeometry() @@ -425,9 +455,9 @@ export default class Tree this._leafMeshBySpecies = {} this._leafMeshes = [] - for(const placement of PLACEMENTS) + for(const placement of this.state.islandLayout.listByKind('tree')) { - const { species, x, z, scale, yaw } = placement + const { id: layoutId, species, x, z, scale, yaw } = placement const tpl = this.templates[species] const groundY = this.island.heightAt(x, z) @@ -480,6 +510,7 @@ export default class Tree canopy, index: this.entries.length, authoredScale: scale, + layoutId, leafLocals, leafStart, leafEnd, @@ -501,6 +532,65 @@ export default class Tree } } + /** + * Island editor (plan 003): reconcile placed trees with a new layout list. + * Tears down all existing InstancedMeshes and entry groups, then calls + * _placeAll() which reads from the (already-mutated) IslandLayout slice. + * A brief flash is accepted β€” incremental InstancedMesh surgery is + * explicitly out of scope per the locked decision. + * + * No-op until assets are ready (guards against a pre-boot call). + * + * @param {readonly import('../State/IslandLayout.js').PlacedObject[]} _objs β€” provided by the + * caller for symmetry with Flowers/Fruits, but Tree reads its layout from the slice directly. + */ + ensureFromLayout(_objs) + { + if(!this.ready) return + try + { + this._teardownPlacements() + this._placeAll() + // Re-apply visibility state. If the editor was in "preview" mode, + // showAll is called by the panel; otherwise hide as normal. + if(!this._hidden) this.showAll() + } + catch(err) + { + console.error('[Tree.ensureFromLayout] rebuild threw β€” layout may be partial', err) + } + } + + /** + * Tear down all authored-placement meshes and leaf InstancedMeshes so + * _placeAll() can rebuild cleanly. Does NOT dispose the shared + * leafCloudGeo or the species templates β€” those survive rebuilds. + */ + _teardownPlacements() + { + for(const entry of (this.entries || [])) + { + if(entry.group) + { + this.scene.remove(entry.group) + entry.group.traverse((child) => + { + if(child.geometry) try { child.geometry.dispose() } catch(_) {} + if(child.material) try { child.material.dispose() } catch(_) {} + }) + } + } + for(const inst of (this._leafMeshes || [])) + { + this.scene.remove(inst) + try { inst.dispose() } catch(_) {} + } + this.entries = [] + this._leafMeshBySpecies = {} + this._leafMeshes = [] + this._hidden = false + } + /** * First-run ceremony helper. Zero every leaf instance matrix and hide * every trunk so the world reads as a bare island until growIn() reveals @@ -562,7 +652,7 @@ export default class Tree if(!this.ready) { if(!this._pendingShow) this._pendingShow = new Set() - for(let i = 0; i < PLACEMENTS.length; i++) this._pendingShow.add(i) + for(let i = 0; i < this.entries.length; i++) this._pendingShow.add(i) return } for(let i = 0; i < this.entries.length; i++) this.showIndex(i) diff --git a/test/engine/IslandLayout.export.test.ts b/test/engine/IslandLayout.export.test.ts new file mode 100644 index 0000000..206e500 --- /dev/null +++ b/test/engine/IslandLayout.export.test.ts @@ -0,0 +1,125 @@ +/** + * Plan 004 β€” export round-trip + committed-default validity tests. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { + defaultIslandLayout, + defaultIslandLayoutFromConstants, +} from '~/engine/student-space/Game/Data/islandLayout.js' +import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' +import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' + +function freshLayout() { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + new Persistence({ storage: memoryAdapter() }) + return new IslandLayout() +} + +afterEach(() => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null +}) + +// ── defaultIslandLayout.json validity ───────────────────────────────────── + +describe('defaultIslandLayout()', () => { + it('returns a non-empty layout', () => { + const layout = defaultIslandLayout() + expect(layout.v).toBe(1) + expect(Array.isArray(layout.objects)).toBe(true) + expect(layout.objects.length).toBeGreaterThan(0) + }) + + it('contains mailbox-0 and telescope-0', () => { + const layout = defaultIslandLayout() + const ids = layout.objects.map((o) => o.id) + expect(ids).toContain('mailbox-0') + expect(ids).toContain('telescope-0') + }) + + it('contains at least one tree, flower, and fruit', () => { + const layout = defaultIslandLayout() + const kinds = new Set(layout.objects.map((o) => o.kind)) + expect(kinds.has('tree')).toBe(true) + expect(kinds.has('flower')).toBe(true) + expect(kinds.has('fruit')).toBe(true) + }) + + it('every object has a non-empty id, kind, x, z', () => { + const layout = defaultIslandLayout() + for (const obj of layout.objects) { + expect(typeof obj.id).toBe('string') + expect(obj.id.length).toBeGreaterThan(0) + expect(typeof obj.kind).toBe('string') + expect(typeof obj.x).toBe('number') + expect(typeof obj.z).toBe('number') + } + }) + + // Seed parity guard: intentionally skipped so an authored edit passes CI. + // Uncomment to verify the committed JSON matches the constants seed at a + // given point in time. + it.skip('matches defaultIslandLayoutFromConstants() at seed time', () => { + const fromJson = defaultIslandLayout() + const fromConstants = defaultIslandLayoutFromConstants() + expect(fromJson.objects.length).toBe(fromConstants.objects.length) + for (let i = 0; i < fromConstants.objects.length; i++) { + expect(fromJson.objects[i]).toMatchObject(fromConstants.objects[i]!) + } + }) +}) + +// ── Export / import round-trip ───────────────────────────────────────────── + +describe('IslandLayout serialize / setLayout round-trip', () => { + it('serialize returns v + objects array', () => { + const layout = freshLayout() + const snap = layout.serialize() + expect(snap.v).toBe(1) + expect(Array.isArray(snap.objects)).toBe(true) + expect(snap.objects.length).toBeGreaterThan(0) + }) + + it('setLayout with serialized snapshot produces identical list', () => { + const layout = freshLayout() + layout.addObject({ id: 'extra-flower', kind: 'flower', species: 'daisy', x: 0.5, z: 0.5 }) + const snap = layout.serialize() + + const layout2 = freshLayout() + layout2.setLayout(snap) + const list2 = layout2.list() + + expect(list2.length).toBe(snap.objects.length) + const ids2 = new Set(list2.map((o) => o.id)) + for (const obj of snap.objects) { + expect(ids2.has(obj.id)).toBe(true) + } + }) + + it('setLayout fires layoutReplaced', () => { + const layout = freshLayout() + const snap = layout.serialize() + + const layout2 = freshLayout() + const events: string[] = [] + layout2.subscribe((e: unknown) => { + events.push((e as { type: string }).type) + }) + layout2.setLayout(snap) + + expect(events).toContain('layoutReplaced') + }) + + it('setLayout with invalid input is rejected without corrupting state', () => { + const layout = freshLayout() + const before = layout.list().length + + layout.setLayout(null) + layout.setLayout({ v: 99 }) + layout.setLayout('garbage') + + expect(layout.list().length).toBe(before) + }) +}) diff --git a/test/engine/IslandLayout.test.ts b/test/engine/IslandLayout.test.ts new file mode 100644 index 0000000..71d363b --- /dev/null +++ b/test/engine/IslandLayout.test.ts @@ -0,0 +1,361 @@ +/** + * IslandLayout state slice β€” unit tests for Plan 001 of the island-editor + * initiative. + * + * Anchors: + * - CRUD mutations + event dispatch (objectAdded / objectRemoved / objectUpdated / layoutReplaced) + * - ids stay stable across remove (removing tree-2 does not renumber tree-3) + * - serialize β†’ hydrate round-trip via memoryAdapter + * - working-copy hydrate: mutate β†’ serialize β†’ fresh hydrate restores it + * - isDiverged() true after a mutation, false after revertToDefault() + * - dispose nulls the singleton + * - subscriber crash isolation (a throwing subscriber does not abort fan-out) + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { IslandLayoutEvent } from '~/engine/student-space/Game/State/IslandLayout.js' +import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' +import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' + +function freshSetup() { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + new Persistence({ storage: memoryAdapter() }) + return new IslandLayout() +} + +afterEach(() => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null +}) + +describe('IslandLayout singleton', () => { + it('two constructions return the same instance', () => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + new Persistence({ storage: memoryAdapter() }) + const a = new IslandLayout() + const b = new IslandLayout() + expect(a).toBe(b) + }) + + it('getInstance() returns the same instance as the constructor', () => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + new Persistence({ storage: memoryAdapter() }) + const a = new IslandLayout() + expect(IslandLayout.getInstance()).toBe(a) + }) +}) + +describe('IslandLayout default state', () => { + let layout: IslandLayout + + beforeEach(() => { + layout = freshSetup() + }) + + it('list() returns 31 objects by default', () => { + expect(layout.list()).toHaveLength(31) + }) + + it('listByKind("tree") returns 7 objects', () => { + expect(layout.listByKind('tree')).toHaveLength(7) + }) + + it('listByKind("flower") returns 18 objects', () => { + expect(layout.listByKind('flower')).toHaveLength(18) + }) + + it('listByKind("fruit") returns 4 objects', () => { + expect(layout.listByKind('fruit')).toHaveLength(4) + }) + + it('listByKind("mailbox") returns 1 object', () => { + expect(layout.listByKind('mailbox')).toHaveLength(1) + }) + + it('listByKind("telescope") returns 1 object', () => { + expect(layout.listByKind('telescope')).toHaveLength(1) + }) + + it('get("mailbox-0") has locked=true', () => { + const obj = layout.get('mailbox-0') + expect(obj).toBeTruthy() + expect(obj?.locked).toBe(true) + }) + + it('get("telescope-0") has locked=true', () => { + const obj = layout.get('telescope-0') + expect(obj).toBeTruthy() + expect(obj?.locked).toBe(true) + }) + + it('isDiverged() is false on a fresh instance', () => { + expect(layout.isDiverged()).toBe(false) + }) +}) + +describe('IslandLayout CRUD', () => { + let layout: IslandLayout + + beforeEach(() => { + layout = freshSetup() + }) + + it('addObject fans objectAdded event', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.addObject({ id: 'tree-added', kind: 'tree', species: 'oak', x: 1, z: 1 }) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('objectAdded') + expect(layout.list()).toHaveLength(32) + }) + + it('addObject assigns a fallback id when none provided', () => { + layout.addObject({ kind: 'flower', species: 'daisy', x: 0.5, z: 0.5 }) + const flowers = layout.listByKind('flower') + expect(flowers).toHaveLength(19) + // The newly added flower should have a generated id + const newFlower = flowers.find((f) => !f.id.match(/^flower-\d+$/)) + expect(newFlower).toBeTruthy() + }) + + it('addObject rejects duplicate id', () => { + layout.addObject({ id: 'tree-0', kind: 'tree', species: 'oak', x: 1, z: 1 }) + expect(layout.list()).toHaveLength(31) // no duplicate added + }) + + it('removeObject fans objectRemoved event and reduces count', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.removeObject('tree-2') + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('objectRemoved') + expect(layout.listByKind('tree')).toHaveLength(6) + }) + + it('removeObject is a no-op for unknown id', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.removeObject('not-real') + expect(events).toHaveLength(0) + expect(layout.list()).toHaveLength(31) + }) + + it('removing tree-2 does not renumber tree-3', () => { + layout.removeObject('tree-2') + const tree3 = layout.get('tree-3') + expect(tree3).toBeTruthy() + expect(tree3?.id).toBe('tree-3') + }) + + it('updateObject fans objectUpdated event and does not change id/kind', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.updateObject('tree-0', { x: 9.9, id: 'should-be-ignored', kind: 'flower' as any }) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('objectUpdated') + const obj = layout.get('tree-0') + expect(obj?.id).toBe('tree-0') + expect(obj?.kind).toBe('tree') + expect(obj?.x).toBe(9.9) + }) + + it('moveObject via coercePosition and fans objectUpdated', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.moveObject('flower-0', { x: 2.5, z: -1.0 }) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('objectUpdated') + const flower = layout.get('flower-0') + expect(flower?.x).toBe(2.5) + expect(flower?.z).toBe(-1.0) + }) + + it('moveObject rejects NaN', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.moveObject('flower-0', { x: NaN, z: 1 }) + expect(events).toHaveLength(0) + }) + + it('setLayout fans layoutReplaced event', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.setLayout({ + v: 1, + objects: [{ id: 'tree-0', kind: 'tree', species: 'oak', x: 0, z: 0 }], + }) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('layoutReplaced') + expect(layout.list()).toHaveLength(1) + }) + + it('setLayout rejects invalid payload', () => { + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.setLayout(null) + layout.setLayout({ v: 1 }) + layout.setLayout({ v: 1, objects: [] }) + expect(events).toHaveLength(0) + expect(layout.list()).toHaveLength(31) + }) +}) + +describe('IslandLayout divergence + revert', () => { + let layout: IslandLayout + + beforeEach(() => { + layout = freshSetup() + }) + + it('isDiverged() is true after a moveObject', () => { + layout.moveObject('tree-0', { x: 1.0, z: 1.0 }) + expect(layout.isDiverged()).toBe(true) + }) + + it('isDiverged() is true after addObject', () => { + layout.addObject({ id: 'new-tree', kind: 'tree', species: 'oak', x: 0, z: 0 }) + expect(layout.isDiverged()).toBe(true) + }) + + it('isDiverged() is true after removeObject', () => { + layout.removeObject('tree-0') + expect(layout.isDiverged()).toBe(true) + }) + + it('revertToDefault() resets objects to base and fans layoutReplaced', () => { + layout.moveObject('tree-0', { x: 1.0, z: 1.0 }) + const events: IslandLayoutEvent[] = [] + layout.subscribe((e) => events.push(e)) + layout.revertToDefault() + expect(layout.isDiverged()).toBe(false) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe('layoutReplaced') + expect(layout.list()).toHaveLength(31) + }) +}) + +describe('IslandLayout serialize + hydrate', () => { + let layout: IslandLayout + + beforeEach(() => { + layout = freshSetup() + }) + + it('serialize returns { v: 1, objects[] }', () => { + const serialized = layout.serialize() + expect(serialized.v).toBe(1) + expect(Array.isArray(serialized.objects)).toBe(true) + expect(serialized.objects).toHaveLength(31) + }) + + it('hydrate restores a mutated working copy', () => { + layout.moveObject('tree-0', { x: 99, z: -99 }) + const snapshot = layout.serialize() + + // Start fresh and hydrate + ;(IslandLayout as unknown as { instance: unknown }).instance = null + const reborn = new IslandLayout() + reborn.hydrate(snapshot) + const tree = reborn.get('tree-0') + expect(tree?.x).toBe(99) + expect(tree?.z).toBe(-99) + }) + + it('hydrate with invalid snapshot keeps the base default', () => { + ;(IslandLayout as unknown as { instance: unknown }).instance = null + const fresh = new IslandLayout() + fresh.hydrate(null) + fresh.hydrate({ v: 1, objects: [] }) + expect(fresh.list()).toHaveLength(31) + }) + + it('round-trip: mutate β†’ serialize β†’ fresh hydrate β†’ isDiverged true', () => { + layout.addObject({ id: 'extra-tree', kind: 'tree', species: 'oak', x: 0, z: 0 }) + const snapshot = layout.serialize() + + ;(IslandLayout as unknown as { instance: unknown }).instance = null + const reborn = new IslandLayout() + reborn.hydrate(snapshot) + expect(reborn.list()).toHaveLength(32) + expect(reborn.isDiverged()).toBe(true) + }) +}) + +describe('IslandLayout snapshot cache stability', () => { + let layout: IslandLayout + + beforeEach(() => { + layout = freshSetup() + }) + + it('list() returns the same reference between mutations', () => { + const a = layout.list() + const b = layout.list() + expect(a).toBe(b) + }) + + it('list() returns a different reference after a mutation', () => { + const a = layout.list() + layout.moveObject('tree-0', { x: 1, z: 1 }) + const b = layout.list() + expect(a).not.toBe(b) + }) + + it('listByKind() returns the same reference between mutations', () => { + const a = layout.listByKind('tree') + const b = layout.listByKind('tree') + expect(a).toBe(b) + }) + + it('get() returns the same reference between mutations', () => { + const a = layout.get('tree-0') + const b = layout.get('tree-0') + expect(a).toBe(b) + }) +}) + +describe('IslandLayout subscriber safety', () => { + let layout: IslandLayout + + beforeEach(() => { + layout = freshSetup() + }) + + it('a throwing subscriber does not abort fan-out to subsequent subscribers', () => { + const seen: string[] = [] + layout.subscribe(() => { + throw new Error('boom') + }) + layout.subscribe((e) => seen.push(e.type)) + layout.moveObject('tree-0', { x: 1, z: 1 }) + expect(seen).toEqual(['objectUpdated']) + }) + + it('unsubscribe removes the callback', () => { + const seen: string[] = [] + const off = layout.subscribe((e) => seen.push(e.type)) + layout.moveObject('tree-0', { x: 1, z: 1 }) + off() + layout.moveObject('tree-1', { x: 2, z: 2 }) + expect(seen).toHaveLength(1) + }) +}) + +describe('IslandLayout dispose', () => { + it('nulling IslandLayout.instance allows a fresh construction', () => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + new Persistence({ storage: memoryAdapter() }) + const first = new IslandLayout() + first.moveObject('tree-0', { x: 5, z: 5 }) + + ;(IslandLayout as unknown as { instance: unknown }).instance = null + const second = new IslandLayout() + // Fresh instance defaults β€” tree-0 should be back at 0, 0 + expect(second.get('tree-0')?.x).toBe(0) + expect(second).not.toBe(first) + }) +}) diff --git a/test/engine/SpeciesPalette.test.ts b/test/engine/SpeciesPalette.test.ts new file mode 100644 index 0000000..626cb27 --- /dev/null +++ b/test/engine/SpeciesPalette.test.ts @@ -0,0 +1,127 @@ +/** + * Plan 005 β€” SpeciesPalette slice tests. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + defaultSpeciesPalette, + defaultSpeciesPaletteFromConstants, +} from '~/engine/student-space/Game/Data/speciesPalette.js' +import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' +import SpeciesPalette from '~/engine/student-space/Game/State/SpeciesPalette.js' + +function freshPalette() { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(SpeciesPalette as unknown as { instance: unknown }).instance = null + new Persistence({ storage: memoryAdapter() }) + return new SpeciesPalette() +} + +afterEach(() => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(SpeciesPalette as unknown as { instance: unknown }).instance = null + vi.restoreAllMocks() +}) + +// ── defaultSpeciesPalette ──────────────────────────────────────────────────── + +describe('defaultSpeciesPalette()', () => { + it('returns a non-empty palette with tree/flower/fruit', () => { + const p = defaultSpeciesPalette() + expect(p.v).toBe(1) + expect(Object.keys(p.tree).length).toBeGreaterThan(0) + expect(Object.keys(p.flower).length).toBeGreaterThan(0) + expect(Object.keys(p.fruit).length).toBeGreaterThan(0) + }) + + it('oak has colorA and colorB', () => { + const p = defaultSpeciesPalette() + expect(p.tree.oak?.colorA).toMatch(/^#[0-9A-Fa-f]{6}$/) + expect(p.tree.oak?.colorB).toMatch(/^#[0-9A-Fa-f]{6}$/) + }) + + it('every fruit has a color', () => { + const p = defaultSpeciesPalette() + for (const [, v] of Object.entries(p.fruit)) { + expect((v as { color: string }).color).toMatch(/^#[0-9A-Fa-f]{6}$/) + } + }) + + it('matches defaultSpeciesPaletteFromConstants()', () => { + const fromJson = defaultSpeciesPalette() + const fromConstants = defaultSpeciesPaletteFromConstants() + expect(JSON.stringify(fromJson)).toBe(JSON.stringify(fromConstants)) + }) +}) + +// ── SpeciesPalette slice ───────────────────────────────────────────────────── + +describe('SpeciesPalette slice', () => { + it('get(fruit, apple) returns color from default', () => { + const pal = freshPalette() + const c = pal.get('fruit', 'apple') + expect(c?.color).toMatch(/^#[0-9A-Fa-f]{6}$/) + }) + + it('setColor updates the get result', () => { + const pal = freshPalette() + pal.setColor('fruit', 'apple', { color: '#FF0000' }) + expect(pal.get('fruit', 'apple')?.color).toBe('#FF0000') + }) + + it('setColor fires paletteChanged', () => { + const pal = freshPalette() + const events: unknown[] = [] + pal.subscribe((e: unknown) => events.push(e)) + pal.setColor('tree', 'oak', { colorA: '#112233' }) + expect(events).toHaveLength(1) + expect((events[0] as { type: string }).type).toBe('paletteChanged') + expect((events[0] as { kind: string }).kind).toBe('tree') + }) + + it('isDiverged() false initially, true after setColor', () => { + const pal = freshPalette() + expect(pal.isDiverged()).toBe(false) + pal.setColor('fruit', 'plum', { color: '#AABBCC' }) + expect(pal.isDiverged()).toBe(true) + }) + + it('revertToDefault resets to base colors', () => { + const pal = freshPalette() + const original = pal.get('fruit', 'apple')?.color + pal.setColor('fruit', 'apple', { color: '#FF0000' }) + pal.revertToDefault() + expect(pal.get('fruit', 'apple')?.color).toBe(original) + expect(pal.isDiverged()).toBe(false) + }) + + it('revertToDefault fires paletteReplaced', () => { + const pal = freshPalette() + pal.setColor('fruit', 'plum', { color: '#AABBCC' }) + const events: { type: string }[] = [] + pal.subscribe((e: unknown) => events.push(e as { type: string })) + pal.revertToDefault() + expect(events.some((e) => e.type === 'paletteReplaced')).toBe(true) + }) + + it('serialize / hydrate round-trip preserves working copy', () => { + const pal = freshPalette() + pal.setColor('tree', 'cherry', { colorA: '#AABBCC' }) + const snap = pal.serialize() + + const pal2 = freshPalette() + pal2.hydrate(snap) + expect(pal2.isDiverged()).toBe(true) + expect(pal2.get('tree', 'cherry')?.colorA).toBe('#AABBCC') + }) + + it('list() merges base and working copy', () => { + const pal = freshPalette() + const originalColor = pal.get('fruit', 'berry')?.color + pal.setColor('fruit', 'berry', { color: '#FF1122' }) + const listed = pal.list() + expect((listed.fruit.berry as { color: string }).color).toBe('#FF1122') + pal.revertToDefault() + expect(pal.get('fruit', 'berry')?.color).toBe(originalColor) + }) +}) diff --git a/test/engine/islandLayout.defaults.test.ts b/test/engine/islandLayout.defaults.test.ts new file mode 100644 index 0000000..e886959 --- /dev/null +++ b/test/engine/islandLayout.defaults.test.ts @@ -0,0 +1,177 @@ +/** + * IslandLayout default parity β€” confirms that `defaultIslandLayout()` reproduces + * the hand-authored constants in the view modules exactly. + * + * Anchors: + * - 31 objects total + * - Per-kind counts: 7 trees, 18 flowers, 4 fruits, 1 mailbox, 1 telescope + * - tree-i coords/species/scale/yaw match Tree.PLACEMENTS[i] + * - flower-i x/z match flowerBasePlacement(i) + * - fruit-i species/coords match Fruits.BUSH_PLACEMENTS[i] + * - mailbox-0 and telescope-0 coords and locked=true + */ + +import { describe, expect, it } from 'vitest' +import { + defaultIslandLayout, + flowerBasePlacement, +} from '~/engine/student-space/Game/Data/islandLayout.js' + +// ── View-module constants reproduced for comparison ─────────────────────────── +// Tree.js PLACEMENTS (lines 66-74) +const TREE_PLACEMENTS = [ + { species: 'oak', x: 0.0, z: 0.0, scale: 0.78, yaw: 0.0 }, + { species: 'oak', x: -2.1, z: -1.6, scale: 0.52, yaw: 0.85 }, + { species: 'cherry', x: 2.4, z: -1.1, scale: 0.5, yaw: 1.6 }, + { species: 'cherry', x: -1.8, z: 2.1, scale: 0.56, yaw: -0.7 }, + { species: 'oak', x: 1.6, z: 2.4, scale: 0.54, yaw: 2.35 }, + { species: 'oak', x: -3.2, z: 0.3, scale: 0.6, yaw: -1.3 }, + { species: 'cherry', x: 3.0, z: 0.9, scale: 0.48, yaw: 2.2 }, +] + +// Fruits.js BUSH_PLACEMENTS (lines 36-41) +const BUSH_PLACEMENTS = [ + { species: 'plum', x: 2.6, z: 0.1 }, + { species: 'fig', x: -2.4, z: 0.9 }, + { species: 'citrus', x: 0.8, z: -2.6 }, + { species: 'berry', x: -1.0, z: -2.4 }, +] + +// Mailbox: x=-0.6, z=2.5 (Mailbox.js line 49) +const MAILBOX_X = -0.6 +const MAILBOX_Z = 2.5 + +// Telescope: cos(1.30)*4.85, sin(1.30)*4.85 (Telescope.js lines 27-28) +const RIM_THETA = 1.3 +const RIM_RADIUS = 4.85 +const TELESCOPE_X = Math.cos(RIM_THETA) * RIM_RADIUS +const TELESCOPE_Z = Math.sin(RIM_THETA) * RIM_RADIUS + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('defaultIslandLayout() parity', () => { + it('produces exactly 31 objects', () => { + const layout = defaultIslandLayout() + expect(layout.objects).toHaveLength(31) + }) + + it('v is 1', () => { + expect(defaultIslandLayout().v).toBe(1) + }) + + it('has 7 trees, 18 flowers, 4 fruits, 1 mailbox, 1 telescope', () => { + const objects = defaultIslandLayout().objects + const byKind = (k: string) => objects.filter((o) => o.kind === k) + expect(byKind('tree')).toHaveLength(7) + expect(byKind('flower')).toHaveLength(18) + expect(byKind('fruit')).toHaveLength(4) + expect(byKind('mailbox')).toHaveLength(1) + expect(byKind('telescope')).toHaveLength(1) + }) + + it('tree ids are tree-0 through tree-6', () => { + const trees = defaultIslandLayout().objects.filter((o) => o.kind === 'tree') + for (let i = 0; i < 7; i++) { + expect(trees[i]?.id).toBe(`tree-${i}`) + } + }) + + it('tree-i coords/species/scale/yaw match PLACEMENTS[i]', () => { + const trees = defaultIslandLayout().objects.filter((o) => o.kind === 'tree') + for (let i = 0; i < TREE_PLACEMENTS.length; i++) { + const t = trees[i] + const p = TREE_PLACEMENTS[i] + expect(t?.species).toBe(p?.species) + expect(t?.x).toBeCloseTo(p?.x ?? 0, 5) + expect(t?.z).toBeCloseTo(p?.z ?? 0, 5) + expect(t?.scale).toBeCloseTo(p?.scale ?? 1, 5) + expect(t?.yaw).toBeCloseTo(p?.yaw ?? 0, 5) + } + }) + + it('flower ids are flower-0 through flower-17', () => { + const flowers = defaultIslandLayout().objects.filter((o) => o.kind === 'flower') + for (let i = 0; i < 18; i++) { + expect(flowers[i]?.id).toBe(`flower-${i}`) + } + }) + + it('flower-0 is pinned at -1.4, 1.0', () => { + const f0 = defaultIslandLayout().objects.find((o) => o.id === 'flower-0') + expect(f0?.x).toBeCloseTo(-1.4, 5) + expect(f0?.z).toBeCloseTo(1.0, 5) + }) + + it('flower-i x/z match flowerBasePlacement(i)', () => { + const flowers = defaultIslandLayout().objects.filter((o) => o.kind === 'flower') + for (let i = 0; i < 18; i++) { + const f = flowers[i] + const p = flowerBasePlacement(i) + expect(f?.x).toBeCloseTo(p.x, 5) + expect(f?.z).toBeCloseTo(p.z, 5) + } + }) + + it('fruit ids are fruit-0 through fruit-3', () => { + const fruits = defaultIslandLayout().objects.filter((o) => o.kind === 'fruit') + for (let i = 0; i < 4; i++) { + expect(fruits[i]?.id).toBe(`fruit-${i}`) + } + }) + + it('fruit-i species/coords match BUSH_PLACEMENTS[i]', () => { + const fruits = defaultIslandLayout().objects.filter((o) => o.kind === 'fruit') + for (let i = 0; i < BUSH_PLACEMENTS.length; i++) { + const f = fruits[i] + const p = BUSH_PLACEMENTS[i] + expect(f?.species).toBe(p?.species) + expect(f?.x).toBeCloseTo(p?.x ?? 0, 5) + expect(f?.z).toBeCloseTo(p?.z ?? 0, 5) + } + }) + + it('mailbox-0 has correct coords and locked=true', () => { + const mb = defaultIslandLayout().objects.find((o) => o.id === 'mailbox-0') + expect(mb?.kind).toBe('mailbox') + expect(mb?.x).toBeCloseTo(MAILBOX_X, 5) + expect(mb?.z).toBeCloseTo(MAILBOX_Z, 5) + expect(mb?.locked).toBe(true) + }) + + it('telescope-0 has correct coords and locked=true', () => { + const tel = defaultIslandLayout().objects.find((o) => o.id === 'telescope-0') + expect(tel?.kind).toBe('telescope') + expect(tel?.x).toBeCloseTo(TELESCOPE_X, 5) + expect(tel?.z).toBeCloseTo(TELESCOPE_Z, 5) + expect(tel?.locked).toBe(true) + }) +}) + +describe('flowerBasePlacement', () => { + it('index 0 returns the ceremony anchor -1.4, 1.0', () => { + const p = flowerBasePlacement(0) + expect(p.x).toBeCloseTo(-1.4, 5) + expect(p.z).toBeCloseTo(1.0, 5) + }) + + it('non-zero indices return finite coords within the island radius', () => { + const ISLAND_RADIUS = 5.0 + for (let i = 1; i < 18; i++) { + const p = flowerBasePlacement(i) + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.z)).toBe(true) + const r = Math.sqrt(p.x * p.x + p.z * p.z) + expect(r).toBeLessThanOrEqual(ISLAND_RADIUS) + } + }) + + it('is deterministic: same index always returns same coords', () => { + for (let i = 0; i < 18; i++) { + const a = flowerBasePlacement(i) + const b = flowerBasePlacement(i) + expect(a.x).toBe(b.x) + expect(a.z).toBe(b.z) + expect(a.yaw).toBe(b.yaw) + } + }) +})