From 852b5f92be18fc6205b13d3b1654241305c46514 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 15:23:48 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat(engine):=20plan=20001=20=E2=80=94=20?= =?UTF-8?q?IslandLayout=20data=20model,=20uuid=20ids,=20working-copy=20sli?= =?UTF-8?q?ce,=20const=E2=86=92slice=20swap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../student-space/Game/Data/islandLayout.d.ts | 28 ++ .../student-space/Game/Data/islandLayout.js | 209 ++++++++++ src/engine/student-space/Game/Game.js | 2 + .../Game/State/IslandLayout.d.ts | 52 +++ .../student-space/Game/State/IslandLayout.js | 296 ++++++++++++++ .../student-space/Game/State/Persistence.js | 5 +- src/engine/student-space/Game/State/State.js | 3 + src/engine/student-space/Game/State/schema.js | 44 +++ src/engine/student-space/Game/View/Flowers.js | 73 +++- src/engine/student-space/Game/View/Fruits.js | 5 +- src/engine/student-space/Game/View/Mailbox.js | 7 +- .../student-space/Game/View/Telescope.js | 8 +- src/engine/student-space/Game/View/Tree.js | 7 +- test/engine/IslandLayout.test.ts | 361 ++++++++++++++++++ test/engine/islandLayout.defaults.test.ts | 177 +++++++++ 15 files changed, 1246 insertions(+), 31 deletions(-) create mode 100644 src/engine/student-space/Game/Data/islandLayout.d.ts create mode 100644 src/engine/student-space/Game/Data/islandLayout.js create mode 100644 src/engine/student-space/Game/State/IslandLayout.d.ts create mode 100644 src/engine/student-space/Game/State/IslandLayout.js create mode 100644 test/engine/IslandLayout.test.ts create mode 100644 test/engine/islandLayout.defaults.test.ts 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..c10b857 --- /dev/null +++ b/src/engine/student-space/Game/Data/islandLayout.js @@ -0,0 +1,209 @@ +/** + * 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 + */ + +// ── 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 ──────────────────────────────────────────────────────────── + +/** + * Build the canonical default `IslandLayout` from the baked constants. + * + * Produces 31 objects: + * - tree-0 … tree-6 (7, from TREE_PLACEMENTS) + * - flower-0 … flower-17 (18, from flowerBasePlacement) + * - fruit-0 … fruit-3 (4, from FRUIT_PLACEMENTS) + * - mailbox-0 (1, locked) + * - telescope-0 (1, locked) + * + * @returns {{ v: 1, objects: PlacedObject[] }} + */ +export function defaultIslandLayout() +{ + /** @type {PlacedObject[]} */ + const objects = [] + + // Trees + 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, + }) + } + + // Flowers + 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, + }) + } + + // Fruits + 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, + }) + } + + // Mailbox + objects.push({ + id: 'mailbox-0', + kind: 'mailbox', + species: undefined, + x: MAILBOX_X, + z: MAILBOX_Z, + yaw: 0, + scale: 1, + locked: true, + }) + + // Telescope + 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 } +} + +/** + * Alias kept for back-compat / tests. Same as `defaultIslandLayout()`. + * @returns {{ v: 1, objects: PlacedObject[] }} + */ +export function defaultIslandLayoutFromConstants() +{ + return defaultIslandLayout() +} diff --git a/src/engine/student-space/Game/Game.js b/src/engine/student-space/Game/Game.js index 008eb83..7076fa6 100644 --- a/src/engine/student-space/Game/Game.js +++ b/src/engine/student-space/Game/Game.js @@ -14,6 +14,7 @@ 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 { HOST_BODY_CLASSES } from './host-body-classes.js' /** @@ -355,6 +356,7 @@ export default class Game IdentityStatusOverride.instance = null try { this.state?.islandSnapshots?.dispose?.() } catch(_) {} IslandSnapshotBridge.instance = null + IslandLayout.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..57fdc78 100644 --- a/src/engine/student-space/Game/State/Persistence.js +++ b/src/engine/student-space/Game/State/Persistence.js @@ -42,9 +42,10 @@ const KEY = { relationships: `${NS}:relationships`, choices: `${NS}:choices`, identityStatusOverride: `${NS}:identityStatusOverride`, + islandLayout: `${NS}:islandLayout`, } -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'] const DEBOUNCE_MS = 250 /** @@ -231,7 +232,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 } if(!this._available) return empty let storedV = 0 diff --git a/src/engine/student-space/Game/State/State.js b/src/engine/student-space/Game/State/State.js index 7604d0c..871a616 100644 --- a/src/engine/student-space/Game/State/State.js +++ b/src/engine/student-space/Game/State/State.js @@ -20,6 +20,7 @@ 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' export default class State { @@ -81,6 +82,7 @@ export default class State this.coldStart = new ColdStart() this.sun = new Sun() this.island = new Island() + this.islandLayout = new IslandLayout() this.moodPins = new MoodPins() this.captures = new Captures() this.profile = new Profile() @@ -107,6 +109,7 @@ export default class State this.relationships.hydrate(snapshot.relationships) this.choices.hydrate(snapshot.choices) this.identityStatusOverride.hydrate(snapshot.identityStatusOverride) + this.islandLayout.hydrate(snapshot.islandLayout) // 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..573a9a1 100644 --- a/src/engine/student-space/Game/State/schema.js +++ b/src/engine/student-space/Game/State/schema.js @@ -846,3 +846,47 @@ 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 } +} diff --git a/src/engine/student-space/Game/View/Flowers.js b/src/engine/student-space/Game/View/Flowers.js index fdaa7dc..d725764 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) + { + 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 + 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, }) } diff --git a/src/engine/student-space/Game/View/Fruits.js b/src/engine/student-space/Game/View/Fruits.js index 1d3b1e8..0bd28c8 100644 --- a/src/engine/student-space/Game/View/Fruits.js +++ b/src/engine/student-space/Game/View/Fruits.js @@ -89,9 +89,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 +171,7 @@ export default class Fruits x, z, host: 'bush', index: this.entries.length, + layoutId, }) } } 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/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..119454b 100644 --- a/src/engine/student-space/Game/View/Tree.js +++ b/src/engine/student-space/Game/View/Tree.js @@ -425,9 +425,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 +480,7 @@ export default class Tree canopy, index: this.entries.length, authoredScale: scale, + layoutId, leafLocals, leafStart, leafEnd, @@ -562,7 +563,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.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/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) + } + }) +}) From 9da578f51b7709f0688c4b9bd7bc89860aa9d5f1 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 15:33:56 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(engine):=20plan=20002=20=E2=80=94=20?= =?UTF-8?q?EditController,=20Selection,=20CommandStack,=20coarse-move=20dr?= =?UTF-8?q?ag=20(no=20gizmo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/student-space/Game/View/Sprouts.js | 3 + src/engine/student-space/Game/View/View.js | 15 + .../Game/View/edit/CommandStack.d.ts | 21 + .../Game/View/edit/CommandStack.js | 77 +++ .../Game/View/edit/EditController.d.ts | 31 + .../Game/View/edit/EditController.js | 574 ++++++++++++++++++ .../Game/View/edit/Selection.d.ts | 23 + .../student-space/Game/View/edit/Selection.js | 158 +++++ .../Game/View/edit/editableViews.d.ts | 49 ++ .../Game/View/edit/editableViews.js | 269 ++++++++ test/engine/IslandEditor.selection.test.ts | 301 +++++++++ test/engine/IslandEditor.transform.test.ts | 296 +++++++++ 12 files changed, 1817 insertions(+) create mode 100644 src/engine/student-space/Game/View/edit/CommandStack.d.ts create mode 100644 src/engine/student-space/Game/View/edit/CommandStack.js create mode 100644 src/engine/student-space/Game/View/edit/EditController.d.ts create mode 100644 src/engine/student-space/Game/View/edit/EditController.js create mode 100644 src/engine/student-space/Game/View/edit/Selection.d.ts create mode 100644 src/engine/student-space/Game/View/edit/Selection.js create mode 100644 src/engine/student-space/Game/View/edit/editableViews.d.ts create mode 100644 src/engine/student-space/Game/View/edit/editableViews.js create mode 100644 test/engine/IslandEditor.selection.test.ts create mode 100644 test/engine/IslandEditor.transform.test.ts 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/View.js b/src/engine/student-space/Game/View/View.js index 325057c..c682348 100644 --- a/src/engine/student-space/Game/View/View.js +++ b/src/engine/student-space/Game/View/View.js @@ -22,6 +22,7 @@ import Sound from './Sound.js' import Mailbox from './Mailbox.js' import Telescope from './Telescope.js' import OverlayController from './OverlayController.js' +import EditController from './edit/EditController.js' import State from '../State/State.js' // OnboardingFlow lifecycle moved to React (U16–U19) — see // `src/components/student-space/EngineHost.tsx`. The ceremony surfaces @@ -119,6 +120,19 @@ export default class View // is intentionally NOT re-attached to `view.onboardingFlow` — React // disposes it directly on cleanup, so we avoid a double-dispose // through View.dispose()'s SUBSYSTEMS loop. + + // Island editor (plan 002). Constructed after all view kinds so + // the EditableView adapters can reference the fully-built views. + // NOT activated by default — plan 003's panel calls activate(). + // Exposed on window.__islandEditor in dev for pre-UI console testing. + this.editController = new EditController({ view: this, state: this.state }) + if(typeof import.meta !== 'undefined' && import.meta.env?.DEV) + { + if(typeof window !== 'undefined') + { + window.__islandEditor = { editController: this.editController } + } + } } resize() @@ -205,6 +219,7 @@ export default class View this.mailbox, this.telescope, this.sprouts, + this.editController, ] for(const sub of SUBSYSTEMS) { diff --git a/src/engine/student-space/Game/View/edit/CommandStack.d.ts b/src/engine/student-space/Game/View/edit/CommandStack.d.ts new file mode 100644 index 0000000..7431541 --- /dev/null +++ b/src/engine/student-space/Game/View/edit/CommandStack.d.ts @@ -0,0 +1,21 @@ +export interface Command { + do: () => void + undo: () => void +} + +export default class CommandStack { + readonly undoCount: number + readonly redoCount: number + + /** Record a command (caller has already executed the forward action). Clears redo stack. */ + push(cmd: Command): void + + /** Undo the most recent command. No-op if history is empty. */ + undo(): void + + /** Redo the most recently undone command. No-op if redo stack is empty. */ + redo(): void + + /** Clear all history. */ + clear(): void +} diff --git a/src/engine/student-space/Game/View/edit/CommandStack.js b/src/engine/student-space/Game/View/edit/CommandStack.js new file mode 100644 index 0000000..75dc244 --- /dev/null +++ b/src/engine/student-space/Game/View/edit/CommandStack.js @@ -0,0 +1,77 @@ +/** + * CommandStack — unified undo/redo history for the island editor. + * + * Each entry is a plain `{ do, undo }` object. Both are zero-argument + * functions; the caller is responsible for building closures that capture + * the right state at push-time. + * + * The stack is intentionally simple: it does NOT call `cmd.do()` on push — + * the caller has already executed the forward action. It only calls + * `cmd.undo()` / re-calls `cmd.do()` on undo/redo. + * + * Cap: the undo history is capped at MAX_ENTRIES (100). When the cap is + * reached, the oldest entry is silently dropped. + */ + +const MAX_ENTRIES = 100 + +export default class CommandStack +{ + constructor() + { + /** @type {Array<{do: () => void, undo: () => void}>} */ + this._stack = [] + /** @type {Array<{do: () => void, undo: () => void}>} */ + this._redo = [] + } + + /** + * Push a command onto the history. The caller has already executed the + * forward action; this records it for undo. Clears the redo stack. + * + * @param {{ do: () => void, undo: () => void }} cmd + */ + push(cmd) + { + if(!cmd || typeof cmd.do !== 'function' || typeof cmd.undo !== 'function') return + this._redo = [] + this._stack.push(cmd) + if(this._stack.length > MAX_ENTRIES) + this._stack.shift() + } + + /** + * Undo the most recent command. No-op if the history is empty. + */ + undo() + { + const cmd = this._stack.pop() + if(!cmd) return + try { cmd.undo() } catch(err) { console.warn('[CommandStack] undo threw', err) } + this._redo.push(cmd) + } + + /** + * Redo the most recently undone command. No-op if the redo stack is empty. + */ + redo() + { + const cmd = this._redo.pop() + if(!cmd) return + try { cmd.do() } catch(err) { console.warn('[CommandStack] redo threw', err) } + this._stack.push(cmd) + } + + /** Number of commands available to undo. */ + get undoCount() { return this._stack.length } + + /** Number of commands available to redo. */ + get redoCount() { return this._redo.length } + + /** Clear all history (e.g. on deactivate). */ + clear() + { + this._stack = [] + this._redo = [] + } +} diff --git a/src/engine/student-space/Game/View/edit/EditController.d.ts b/src/engine/student-space/Game/View/edit/EditController.d.ts new file mode 100644 index 0000000..5110353 --- /dev/null +++ b/src/engine/student-space/Game/View/edit/EditController.d.ts @@ -0,0 +1,31 @@ +import type { TransformPatch, EditableViews } from './editableViews' +import type Selection from './Selection' +import type CommandStack from './CommandStack' + +export interface EditControllerParams { + view: object + state: object +} + +export default class EditController { + readonly editableViews: EditableViews + readonly selection: Selection + readonly commandStack: CommandStack + + constructor(params: EditControllerParams) + + /** Add canvas pointer listener. Called by the 003 panel. */ + activate(): void + + /** Remove canvas pointer listener. Cancels in-flight drag. */ + deactivate(): void + + /** Called from View.dispose(). */ + dispose(): void + + /** + * Apply a partial transform to an object. + * Returns false if rejected (not placeable, unknown id). + */ + applyTransform(id: string, patch: TransformPatch): boolean +} diff --git a/src/engine/student-space/Game/View/edit/EditController.js b/src/engine/student-space/Game/View/edit/EditController.js new file mode 100644 index 0000000..63754b3 --- /dev/null +++ b/src/engine/student-space/Game/View/edit/EditController.js @@ -0,0 +1,574 @@ +/** + * EditController — engine core of the island editor (plan 002). + * + * Responsibilities: + * 1. activate()/deactivate() — adds/removes canvas pointer listeners. + * 2. Raycast pick — on pointerdown over a recognised object, call + * selection.select(id). Clicking empty space deselects. + * 3. applyTransform(id, patch) — the API the 003 inspector calls. + * Validates bounds, applies via the adapter, pushes a CommandStack + * entry, and commits to state.islandLayout. + * 4. Coarse-move drag — pointer-drag the selected object across the + * plateau (ground-plane projection, same pattern as Sprouts.js). + * Suppresses camera.controls during drag; commits on release inside + * bounds, snaps back on out-of-bounds release. + * 5. Subscribe to objectUpdated — keeps mesh in sync when layout changes + * from the outside (undo, inspector). + * + * NOT active by default. Plan 003 calls activate(). Exposed via + * window.__islandEditor in dev for pre-UI testing (see View.js). + * + * No 3D gizmo — transforms are numeric (003 inspector) + ground-plane + * drag for coarse move. This makes the core unit-testable without WebGL. + */ + +import * as THREE from 'three' +import { buildEditableViews } from './editableViews.js' +import Selection from './Selection.js' +import CommandStack from './CommandStack.js' + +// Drag lift while held — slight visual separation from terrain. +const DRAG_LIFT = 0.15 + +export default class EditController +{ + /** + * @param {{ + * view: import('../View.js').default, + * state: import('../../State/State.js').default, + * }} params + */ + constructor({ view, state }) + { + this._view = view + this._state = state + + this._island = state.island + this._camera = view.camera + this._scene = view.scene + + this.editableViews = buildEditableViews(view, this._island) + this.selection = new Selection(this._scene) + this.commandStack = new CommandStack() + + this._active = false + + // Raycast helpers (reused per event) + this._raycaster = new THREE.Raycaster() + this._pointer = new THREE.Vector2() + + // Drag state — non-null while a drag is in-flight. + // @type {{ id: string, kind: string, group: THREE.Object3D, + // originPos: THREE.Vector3, originScale: number, + // originYaw: number, liftHeight: number, + // valid: boolean, pointerId: number } | null} + this._drag = null + this._dragGroundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0) + + // Bound event handlers + this._onPointerDown = (e) => this._handlePointerDown(e) + this._onPointerMove = (e) => this._handlePointerMove(e) + this._onPointerUp = (e) => this._handlePointerUp(e) + + this._canvasEl = view.renderer?.instance?.domElement ?? null + + // Subscribe to layout mutations so meshes stay in sync when layout + // changes come from undo, the inspector, or external callers. + this._unsubLayout = this._state.islandLayout?.subscribe((event) => + { + if(event.type === 'objectUpdated') this._syncMesh(event.object) + }) + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /** + * Activate the editor. Called by the 003 panel when #editor is open. + * Adds the canvas pointer-down listener. + */ + activate() + { + if(this._active) return + this._active = true + if(this._canvasEl) + { + this._canvasEl.addEventListener('pointerdown', this._onPointerDown) + } + } + + /** + * Deactivate the editor. Removes listeners, cancels any in-flight drag, + * restores camera.controls. + */ + deactivate() + { + if(!this._active) return + this._active = false + if(this._drag) this._cancelDrag() + if(this._canvasEl) + { + this._canvasEl.removeEventListener('pointerdown', this._onPointerDown) + } + this._restoreControls() + } + + /** + * Dispose everything. Called from View.dispose(). + * Always restores camera.controls.enabled regardless of active state. + */ + dispose() + { + if(this._drag) this._cancelDrag() + if(this._canvasEl) + { + this._canvasEl.removeEventListener('pointerdown', this._onPointerDown) + this._canvasEl.removeEventListener('pointermove', this._onPointerMove) + this._canvasEl.removeEventListener('pointerup', this._onPointerUp) + } + this._active = false + // Always restore controls — dispose may be called with controls stuck + // false from a drag or external manipulation. + this._restoreControls() + if(this._unsubLayout) + { + try { this._unsubLayout() } catch(_) {} + this._unsubLayout = null + } + this.selection.dispose() + this.commandStack.clear() + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Apply a partial transform `{ x?, z?, yaw?, scale? }` to the object + * with the given layout id. + * + * - Validates XZ against isPlaceable (if x or z are changing). + * - Applies the mesh transform via the adapter. + * - Pushes an undo-able command onto the command stack. + * - Commits to state.islandLayout.updateObject (y always derived). + * + * @param {string} id + * @param {{ x?: number, z?: number, yaw?: number, scale?: number }} patch + * @returns {boolean} false if rejected (not placeable, unknown id, etc.) + */ + applyTransform(id, patch) + { + if(typeof id !== 'string' || !patch || typeof patch !== 'object') return false + + const layout = this._state.islandLayout + const current = layout?.get(id) + if(!current) return false + + const kind = current.kind + const adapter = this.editableViews[kind] + if(!adapter) return false + + // Compute new XZ (patch may be partial). + const newX = typeof patch.x === 'number' ? patch.x : current.x + const newZ = typeof patch.z === 'number' ? patch.z : current.z + + // Placeable check only when position is changing. + const posChanging = typeof patch.x === 'number' || typeof patch.z === 'number' + if(posChanging && !this._island.isPlaceable(newX, newZ)) return false + + // Snapshot before state for undo. + const before = { + x: current.x, + z: current.z, + yaw: current.yaw, + scale: current.scale, + } + const after = { + x: newX, + z: newZ, + yaw: typeof patch.yaw === 'number' ? patch.yaw : current.yaw, + scale: typeof patch.scale === 'number' ? patch.scale : current.scale, + } + + // Apply live to mesh. + adapter.applyTransform(id, after) + + // Update highlight if this is the selected object. + if(this.selection.get() === id) + { + const obj3d = adapter.getObject3D(id) + if(obj3d) this.selection.update(obj3d) + } + + // Commit to layout slice (y omitted — derived by heightAt in the view). + layout.updateObject(id, after) + + // Push undo entry. + this.commandStack.push({ + do: () => { adapter.applyTransform(id, after); layout.updateObject(id, after) }, + undo: () => { adapter.applyTransform(id, before); layout.updateObject(id, before) }, + }) + + return true + } + + // ── Private: pick ───────────────────────────────────────────────────────── + + _handlePointerDown(e) + { + if(!this._active) return + if(e.button !== 0) return // left-button only + + const camera = this._camera?.instance + if(!camera || !this._canvasEl) return + + const rect = this._canvasEl.getBoundingClientRect() + this._pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1 + this._pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1 + this._raycaster.setFromCamera(this._pointer, camera) + + // Collect all hit targets across all kinds. + const targets = [] + for(const adapter of Object.values(this.editableViews)) + { + for(const t of adapter.hitTargets()) targets.push(t) + } + + const intersects = this._raycaster.intersectObjects(targets, true) + const hit = intersects[0] + + if(!hit) + { + this.selection.deselect() + return + } + + // Walk up to find which adapter group was hit and resolve its layout id. + const resolved = this._resolveHit(hit.object) + if(!resolved) + { + this.selection.deselect() + return + } + + const { id, kind } = resolved + const adapter = this.editableViews[kind] + const obj3d = adapter?.getObject3D(id) + + this.selection.select(id, obj3d) + + // Start coarse-move drag. + this._startDrag(e, id, kind, obj3d) + + e.preventDefault?.() + } + + // ── Private: drag ───────────────────────────────────────────────────────── + + _startDrag(e, id, kind, group) + { + if(!group) return + const layout = this._state.islandLayout + const current = layout?.get(id) + if(!current) return + + const originX = group.position.x + const originZ = group.position.z + const originY = group.position.y + const originYaw = group.rotation.y + const originScale = group.scale.x + + const pickupGroundY = this._island.heightAt(originX, originZ) + const liftHeight = pickupGroundY + DRAG_LIFT + + // Set the ground plane constant so the cursor projects correctly. + this._dragGroundPlane.constant = -liftHeight + + this._drag = { + id, + kind, + group, + originPos: new THREE.Vector3(originX, originY, originZ), + originYaw, + originScale, + liftHeight, + valid: true, + pointerId: e.pointerId, + // snapshot for undo + before: { x: current.x, z: current.z, yaw: current.yaw, scale: current.scale }, + } + + // Lift the mesh visually. + group.position.y = liftHeight + + if(this._camera?.controls) this._camera.controls.enabled = false + + if(this._canvasEl) + { + try { this._canvasEl.setPointerCapture?.(e.pointerId) } catch(_) {} + this._canvasEl.addEventListener('pointermove', this._onPointerMove) + this._canvasEl.addEventListener('pointerup', this._onPointerUp) + } + } + + _handlePointerMove(e) + { + const drag = this._drag + if(!drag) return + + const camera = this._camera?.instance + if(!camera || !this._canvasEl) return + + const rect = this._canvasEl.getBoundingClientRect() + this._pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1 + this._pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1 + this._raycaster.setFromCamera(this._pointer, camera) + + const hit = new THREE.Vector3() + const intersected = this._raycaster.ray.intersectPlane(this._dragGroundPlane, hit) + if(!intersected) return + + const x = hit.x + const z = hit.z + + // Route through the kind's adapter live-move (keeps InstancedMesh + // leaf clouds in sync for trees, etc.). + const adapter = this.editableViews[drag.kind] + if(adapter) + { + adapter.applyTransform(drag.id, { x, z, yaw: drag.group.rotation.y }) + drag.group.position.y = drag.liftHeight + } + else + { + drag.group.position.set(x, drag.liftHeight, z) + } + + drag.valid = this._island.isPlaceable(x, z) + + // Update highlight ring to follow. + if(this.selection.get() === drag.id) this.selection.update(drag.group) + } + + _handlePointerUp(e) + { + this._finishDrag(e) + } + + _finishDrag(e) + { + const drag = this._drag + if(!drag) return + this._drag = null + + if(this._canvasEl) + { + try { this._canvasEl.releasePointerCapture?.(e?.pointerId ?? drag.pointerId) } catch(_) {} + this._canvasEl.removeEventListener('pointermove', this._onPointerMove) + this._canvasEl.removeEventListener('pointerup', this._onPointerUp) + } + + this._restoreControls() + + const adapter = this.editableViews[drag.kind] + + if(drag.valid) + { + const finalX = drag.group.position.x + const finalZ = drag.group.position.z + + // Snap to ground (remove the lift). + if(adapter) adapter.applyTransform(drag.id, { x: finalX, z: finalZ }) + else drag.group.position.set(finalX, this._island.heightAt(finalX, finalZ), finalZ) + + // Commit to layout (y not stored — always heightAt). + const after = { x: finalX, z: finalZ, yaw: drag.group.rotation.y, scale: drag.group.scale.x } + const before = drag.before + const id = drag.id + const layout = this._state.islandLayout + + layout?.updateObject(id, { x: finalX, z: finalZ }) + + // Push undo entry for the drag. + this.commandStack.push({ + do: () => { adapter?.applyTransform(id, after); layout?.updateObject(id, after) }, + undo: () => { adapter?.applyTransform(id, before); layout?.updateObject(id, before) }, + }) + } + else + { + // Snap back — restore visual without touching the layout. + const { originPos, originYaw, originScale } = drag + if(adapter) + { + adapter.applyTransform(drag.id, { + x: originPos.x, + z: originPos.z, + yaw: originYaw, + scale: originScale, + }) + } + else + { + drag.group.position.copy(originPos) + drag.group.rotation.y = originYaw + drag.group.scale.setScalar(originScale) + } + } + + // Refresh selection highlight. + if(this.selection.get() === drag.id) this.selection.update(drag.group) + } + + _cancelDrag() + { + const drag = this._drag + if(!drag) return + this._drag = null + + if(this._canvasEl) + { + this._canvasEl.removeEventListener('pointermove', this._onPointerMove) + this._canvasEl.removeEventListener('pointerup', this._onPointerUp) + } + + this._restoreControls() + + const adapter = this.editableViews[drag.kind] + const { originPos, originYaw, originScale } = drag + + if(adapter) + { + adapter.applyTransform(drag.id, { + x: originPos.x, + z: originPos.z, + yaw: originYaw, + scale: originScale, + }) + } + else + { + drag.group.position.copy(originPos) + drag.group.rotation.y = originYaw + drag.group.scale.setScalar(originScale) + } + } + + _restoreControls() + { + try + { + if(this._camera?.controls) this._camera.controls.enabled = true + } + catch(_) {} + } + + // ── Private: hit resolution ─────────────────────────────────────────────── + + /** + * Walk up the scene graph from the intersected object to find which + * adapter group it belongs to. Returns `{ id, kind }` or null. + * + * Strategy: for each kind, check if the hit object (or any of its ancestors + * up to adapter.hitTargets()) matches one of the known groups, then resolve + * the layout id by reverse-looking up the adapter record. + * + * @param {THREE.Object3D} hitObject + * @returns {{ id: string, kind: string } | null} + */ + _resolveHit(hitObject) + { + // Tree entries + const tree = this._view.tree + if(tree?.entries) + { + for(const entry of tree.entries) + { + if(entry.group && this._isDescendant(hitObject, entry.group)) + { + if(entry.layoutId) return { id: entry.layoutId, kind: 'tree' } + } + } + } + + // Flower entries + const flowers = this._view.flowers + if(flowers?.flowers) + { + for(const f of flowers.flowers) + { + if(f.group && this._isDescendant(hitObject, f.group)) + { + if(f.layoutId) return { id: f.layoutId, kind: 'flower' } + } + } + } + + // Fruit entries + const fruits = this._view.fruits + if(fruits?.entries) + { + for(const entry of fruits.entries) + { + if(entry.group && this._isDescendant(hitObject, entry.group)) + { + if(entry.layoutId) return { id: entry.layoutId, kind: 'fruit' } + } + } + } + + // Mailbox — singleton; layout id is 'mailbox-0' + const mailbox = this._view.mailbox + if(mailbox?.group && this._isDescendant(hitObject, mailbox.group)) + { + return { id: 'mailbox-0', kind: 'mailbox' } + } + + // Telescope — singleton; layout id is 'telescope-0' + const telescope = this._view.telescope + if(telescope?.group && this._isDescendant(hitObject, telescope.group)) + { + return { id: 'telescope-0', kind: 'telescope' } + } + + return null + } + + /** + * True if `node` is `ancestor` or a descendant of it. + * + * @param {THREE.Object3D} node + * @param {THREE.Object3D} ancestor + */ + _isDescendant(node, ancestor) + { + let cur = node + while(cur) + { + if(cur === ancestor) return true + cur = cur.parent + } + return false + } + + // ── Private: reactive sync ──────────────────────────────────────────────── + + /** + * Called when objectUpdated fires (e.g. from undo or an external + * inspector write). Syncs the mesh to the new layout state. + * + * @param {{ id: string, kind: string, x: number, z: number, yaw: number, scale: number }} obj + */ + _syncMesh(obj) + { + const adapter = this.editableViews[obj.kind] + if(!adapter) return + try + { + adapter.applyTransform(obj.id, { + x: obj.x, + z: obj.z, + yaw: obj.yaw, + scale: obj.scale, + }) + } + catch(err) { console.warn('[EditController] _syncMesh threw', err) } + } +} diff --git a/src/engine/student-space/Game/View/edit/Selection.d.ts b/src/engine/student-space/Game/View/edit/Selection.d.ts new file mode 100644 index 0000000..e43d499 --- /dev/null +++ b/src/engine/student-space/Game/View/edit/Selection.d.ts @@ -0,0 +1,23 @@ +import type * as THREE from 'three' + +export default class Selection { + constructor(scene: THREE.Scene) + + /** Currently selected layout id, or null. */ + get(): string | null + + /** Select an object by layout id with its Three.js group for highlighting. */ + select(id: string, object3d: THREE.Object3D): void + + /** Clear the selection and remove highlights. */ + deselect(): void + + /** Update highlight position to track object movement. */ + update(object3d: THREE.Object3D): void + + /** Subscribe to selection changes. Returns unsubscribe fn. */ + onChange(cb: (id: string | null) => void): () => void + + /** Dispose highlights and remove from scene. */ + dispose(): void +} diff --git a/src/engine/student-space/Game/View/edit/Selection.js b/src/engine/student-space/Game/View/edit/Selection.js new file mode 100644 index 0000000..b31bf6b --- /dev/null +++ b/src/engine/student-space/Game/View/edit/Selection.js @@ -0,0 +1,158 @@ +/** + * Selection — tracks the currently selected island-layout object and + * renders a lightweight highlight around it. + * + * Highlight strategy: a THREE.BoxHelper wraps the object's bounding box. + * The helper is cheap (one LineSegments draw call) and doesn't require + * WebGL extensions. A ground-ring mesh provides additional affordance at + * foot level. + * + * Change callbacks: a Set of functions called whenever the selection + * changes (deselect also calls them with null). Used by the 003 inspector. + */ + +import * as THREE from 'three' + +const HIGHLIGHT_COLOR = 0x00d4ff // cyan — distinct from any island palette + +export default class Selection +{ + /** + * @param {THREE.Scene} scene + */ + constructor(scene) + { + this._scene = scene + /** @type {string | null} */ + this._id = null + /** @type {THREE.BoxHelper | null} */ + this._helper = null + /** @type {THREE.Mesh | null} */ + this._ring = null + /** @type {Set<(id: string | null) => void>} */ + this._callbacks = new Set() + } + + // ── Public API ────────────────────────────────────────────────────────── + + /** + * Select an object by layout id. Replaces any existing selection. + * + * @param {string} id + * @param {THREE.Object3D} object3d + */ + select(id, object3d) + { + this._disposeHighlight() + this._id = id + + if(object3d) + { + // BoxHelper around the object. + const helper = new THREE.BoxHelper(object3d, HIGHLIGHT_COLOR) + this._scene.add(helper) + this._helper = helper + + // Ground ring at the object's XZ position. + const pos = new THREE.Vector3() + object3d.getWorldPosition(pos) + const ringGeo = new THREE.RingGeometry(0.35, 0.42, 32) + const ringMat = new THREE.MeshBasicMaterial({ + color: HIGHLIGHT_COLOR, + side: THREE.DoubleSide, + opacity: 0.6, + transparent: true, + }) + const ring = new THREE.Mesh(ringGeo, ringMat) + ring.rotation.x = -Math.PI / 2 + ring.position.set(pos.x, pos.y + 0.02, pos.z) + this._scene.add(ring) + this._ring = ring + } + + this._notify() + } + + /** Clear the selection. */ + deselect() + { + if(this._id === null) return + this._id = null + this._disposeHighlight() + this._notify() + } + + /** + * Returns the currently selected layout id, or null. + * @returns {string | null} + */ + get() + { + return this._id + } + + /** + * Update the highlight to track the object's current position + * (called each frame or after a transform). + * + * @param {THREE.Object3D} object3d + */ + update(object3d) + { + if(!object3d) return + if(this._helper) this._helper.update() + if(this._ring) + { + const pos = new THREE.Vector3() + object3d.getWorldPosition(pos) + this._ring.position.set(pos.x, pos.y + 0.02, pos.z) + } + } + + /** + * Subscribe to selection changes. Callback receives the new layout id + * (string) or null on deselect. Returns unsubscribe function. + * + * @param {(id: string | null) => void} cb + * @returns {() => void} + */ + onChange(cb) + { + this._callbacks.add(cb) + return () => this._callbacks.delete(cb) + } + + /** Dispose highlight objects and remove from scene. */ + dispose() + { + this._id = null + this._disposeHighlight() + } + + // ── Private ───────────────────────────────────────────────────────────── + + _disposeHighlight() + { + if(this._helper) + { + try { this._scene?.remove(this._helper) } catch(_) {} + try { this._helper.geometry?.dispose?.() } catch(_) {} + this._helper = null + } + if(this._ring) + { + try { this._scene?.remove(this._ring) } catch(_) {} + try { this._ring.geometry?.dispose?.() } catch(_) {} + try { this._ring.material?.dispose?.() } catch(_) {} + this._ring = null + } + } + + _notify() + { + for(const cb of this._callbacks) + { + try { cb(this._id) } catch(err) { console.warn('[Selection] callback threw', err) } + } + } +} diff --git a/src/engine/student-space/Game/View/edit/editableViews.d.ts b/src/engine/student-space/Game/View/edit/editableViews.d.ts new file mode 100644 index 0000000..5a0b170 --- /dev/null +++ b/src/engine/student-space/Game/View/edit/editableViews.d.ts @@ -0,0 +1,49 @@ +import type * as THREE from 'three' + +export interface TransformPatch { + x?: number + z?: number + yaw?: number + scale?: number +} + +export interface PlacedObject { + id: string + kind: string + species?: string + x: number + z: number + yaw: number + scale: number +} + +export interface EditableView { + /** Resolve the THREE.Group for a layout id (null if not found). */ + getObject3D(layoutId: string): THREE.Object3D | null + + /** All raycasting hit targets for this kind. */ + hitTargets(): THREE.Object3D[] + + /** + * Apply a partial transform to the mesh live. + * Does NOT commit to IslandLayout — the caller (EditController) does. + */ + applyTransform(id: string, t: TransformPatch): void + + /** Stub — implemented in plan 003. */ + spawn(obj: PlacedObject): void + + /** Stub — implemented in plan 003. */ + remove(id: string): void +} + +export interface EditableViews { + tree: EditableView + flower: EditableView + fruit: EditableView + mailbox: EditableView + telescope: EditableView +} + +/** Build the per-kind adapter map for an active View instance. */ +export function buildEditableViews(view: object, island: object): EditableViews diff --git a/src/engine/student-space/Game/View/edit/editableViews.js b/src/engine/student-space/Game/View/edit/editableViews.js new file mode 100644 index 0000000..5d125d0 --- /dev/null +++ b/src/engine/student-space/Game/View/edit/editableViews.js @@ -0,0 +1,269 @@ +/** + * EditableView adapters — per-kind wrappers that let EditController work + * uniformly across the five bespoke view kinds (tree, flower, fruit, + * mailbox, telescope). + * + * Each adapter exposes: + * getObject3D(layoutId) — resolve the THREE.Group for a layout id + * hitTargets() — array of Object3D meshes for raycasting + * applyTransform(id, t) — apply {x?,z?,yaw?,scale?} live (does not + * commit to IslandLayout — caller does that) + * spawn(obj) — stub; see plan 003 + * remove(id) — stub; see plan 003 + * + * `buildEditableViews(view, island)` returns the full map. + */ + +/** + * @param {import('../View.js').default} view + * @param {import('../Island.js').default} island + */ +export function buildEditableViews(view, island) +{ + return { + tree: buildTreeAdapter(view, island), + flower: buildFlowerAdapter(view, island), + fruit: buildFruitAdapter(view, island), + mailbox: buildMailboxAdapter(view, island), + telescope: buildTelescopeAdapter(view, island), + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function stubSpawn(kind) +{ + return (obj) => console.warn(`[editableViews:${kind}] spawn() not yet implemented — see plan 003`, obj) +} + +function stubRemove(kind) +{ + return (id) => console.warn(`[editableViews:${kind}] remove() not yet implemented — see plan 003`, id) +} + +// ── Tree ───────────────────────────────────────────────────────────────────── + +function buildTreeAdapter(view, island) +{ + return { + getObject3D(layoutId) + { + const entry = view.tree?.entries?.find((e) => e.layoutId === layoutId) + return entry?.group ?? null + }, + + hitTargets() + { + if(!view.tree?.entries) return [] + return view.tree.entries + .map((e) => e.group) + .filter(Boolean) + }, + + applyTransform(id, t) + { + const entry = view.tree?.entries?.find((e) => e.layoutId === id) + if(!entry || !entry.group) return + + const x = typeof t.x === 'number' ? t.x : entry.group.position.x + const z = typeof t.z === 'number' ? t.z : entry.group.position.z + + const idx = view.tree.entries.indexOf(entry) + if(typeof view.tree.moveEntry === 'function') + { + view.tree.moveEntry(idx, x, z) + } + else + { + const y = island.heightAt(x, z) + entry.group.position.set(x, y, z) + } + + if(typeof t.yaw === 'number') entry.group.rotation.y = t.yaw + if(typeof t.scale === 'number') entry.group.scale.setScalar(t.scale) + }, + + spawn: stubSpawn('tree'), + remove: stubRemove('tree'), + } +} + +// ── Flower ──────────────────────────────────────────────────────────────────── + +function buildFlowerAdapter(view, island) +{ + return { + getObject3D(layoutId) + { + const f = view.flowers?.flowers?.find((fl) => fl.layoutId === layoutId) + return f?.group ?? null + }, + + hitTargets() + { + if(!view.flowers?.flowers) return [] + return view.flowers.flowers + .map((f) => f.group) + .filter(Boolean) + }, + + applyTransform(id, t) + { + const f = view.flowers?.flowers?.find((fl) => fl.layoutId === id) + if(!f || !f.group) return + + const x = typeof t.x === 'number' ? t.x : f.group.position.x + const z = typeof t.z === 'number' ? t.z : f.group.position.z + + const idx = view.flowers.flowers.indexOf(f) + if(typeof view.flowers.moveInstance === 'function') + { + view.flowers.moveInstance(idx, x, z) + } + else + { + const y = island.heightAt(x, z) + f.group.position.set(x, y, z) + } + + if(typeof t.yaw === 'number') f.group.rotation.y = t.yaw + if(typeof t.scale === 'number') f.group.scale.setScalar(t.scale) + }, + + spawn: stubSpawn('flower'), + remove: stubRemove('flower'), + } +} + +// ── Fruit ───────────────────────────────────────────────────────────────────── + +function buildFruitAdapter(view, island) +{ + return { + getObject3D(layoutId) + { + const entry = view.fruits?.entries?.find((e) => e.layoutId === layoutId) + return entry?.group ?? null + }, + + hitTargets() + { + if(!view.fruits?.entries) return [] + return view.fruits.entries + .map((e) => e.group) + .filter(Boolean) + }, + + applyTransform(id, t) + { + const entry = view.fruits?.entries?.find((e) => e.layoutId === id) + if(!entry || !entry.group) return + + const x = typeof t.x === 'number' ? t.x : entry.group.position.x + const z = typeof t.z === 'number' ? t.z : entry.group.position.z + + const idx = view.fruits.entries.indexOf(entry) + if(typeof view.fruits.moveEntry === 'function') + { + view.fruits.moveEntry(idx, x, z) + } + else + { + const y = island.heightAt(x, z) + entry.group.position.set(x, y, z) + } + + if(typeof t.yaw === 'number') entry.group.rotation.y = t.yaw + if(typeof t.scale === 'number') entry.group.scale.setScalar(t.scale) + }, + + spawn: stubSpawn('fruit'), + remove: stubRemove('fruit'), + } +} + +// ── Mailbox ─────────────────────────────────────────────────────────────────── + +function buildMailboxAdapter(view, island) +{ + return { + getObject3D(_layoutId) + { + return view.mailbox?.group ?? null + }, + + hitTargets() + { + const g = view.mailbox?.group + return g ? [g] : [] + }, + + applyTransform(id, t) + { + const g = view.mailbox?.group + if(!g) return + + const x = typeof t.x === 'number' ? t.x : g.position.x + const z = typeof t.z === 'number' ? t.z : g.position.z + + if(typeof view.mailbox.move === 'function') + { + view.mailbox.move(x, z) + } + else + { + const y = island.heightAt(x, z) + g.position.set(x, y, z) + } + + if(typeof t.yaw === 'number') g.rotation.y = t.yaw + if(typeof t.scale === 'number') g.scale.setScalar(t.scale) + }, + + spawn: stubSpawn('mailbox'), + remove: stubRemove('mailbox'), + } +} + +// ── Telescope ───────────────────────────────────────────────────────────────── + +function buildTelescopeAdapter(view, island) +{ + return { + getObject3D(_layoutId) + { + return view.telescope?.group ?? null + }, + + hitTargets() + { + const g = view.telescope?.group + return g ? [g] : [] + }, + + applyTransform(id, t) + { + const g = view.telescope?.group + if(!g) return + + const x = typeof t.x === 'number' ? t.x : g.position.x + const z = typeof t.z === 'number' ? t.z : g.position.z + + if(typeof view.telescope.move === 'function') + { + view.telescope.move(x, z) + } + else + { + const y = island.heightAt(x, z) + g.position.set(x, y, z) + } + + if(typeof t.yaw === 'number') g.rotation.y = t.yaw + if(typeof t.scale === 'number') g.scale.setScalar(t.scale) + }, + + spawn: stubSpawn('telescope'), + remove: stubRemove('telescope'), + } +} diff --git a/test/engine/IslandEditor.selection.test.ts b/test/engine/IslandEditor.selection.test.ts new file mode 100644 index 0000000..5bd1fcb --- /dev/null +++ b/test/engine/IslandEditor.selection.test.ts @@ -0,0 +1,301 @@ +/** + * Plan 002 — Island Editor: Selection tests. + * + * Tests raycast-hit → selection, deselect, and dispose clears highlight. + * + * These tests are fully unit-testable without WebGL — the gizmo is gone. + * We build lightweight stubs for the adapter layer and the selection + * highlight machinery rather than spinning up a full View. + */ + +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' +import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' +import CommandStack from '~/engine/student-space/Game/View/edit/CommandStack.js' +import EditController from '~/engine/student-space/Game/View/edit/EditController.js' +import Selection from '~/engine/student-space/Game/View/edit/Selection.js' + +// ── Test infrastructure ──────────────────────────────────────────────────── + +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() +} + +function makeIslandStub() { + return { + heightAt: (_x: number, _z: number) => 1.0, + isPlaceable: (x: number, z: number) => Math.abs(x) < 4 && Math.abs(z) < 4, + } +} + +/** + * Build a minimal view stub with one tree, one flower, one fruit, a + * mailbox, and a telescope — just enough for the adapter to find groups. + */ +function makeViewStub(layout: IslandLayout, island: ReturnType) { + const firstTree = layout.listByKind('tree')[0] + const firstFlower = layout.listByKind('flower')[0] + const firstFruit = layout.listByKind('fruit')[0] + + const treeGroup = new THREE.Group() + treeGroup.position.set(firstTree?.x ?? 0, 1, firstTree?.z ?? 0) + const flowerGroup = new THREE.Group() + flowerGroup.position.set(firstFlower?.x ?? 0, 1, firstFlower?.z ?? 0) + const fruitGroup = new THREE.Group() + fruitGroup.position.set(firstFruit?.x ?? 0, 1, firstFruit?.z ?? 0) + const mailboxGroup = new THREE.Group() + mailboxGroup.position.set(-0.6, 1, 2.5) + const teleGroup = new THREE.Group() + teleGroup.position.set(2.5, 1, -1.5) + + return { + scene: new THREE.Scene(), + camera: { + instance: new THREE.PerspectiveCamera(), + controls: { enabled: true }, + bindControls: vi.fn(), + }, + renderer: { instance: { domElement: document.createElement('canvas') } }, + tree: { + entries: [ + { + layoutId: firstTree?.id ?? 'tree-0', + group: treeGroup, + x: firstTree?.x ?? 0, + z: firstTree?.z ?? 0, + }, + ], + moveEntry: (_idx: number, x: number, z: number) => { + treeGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + flowers: { + flowers: [ + { + layoutId: firstFlower?.id ?? 'flower-0', + group: flowerGroup, + x: firstFlower?.x ?? 0, + z: firstFlower?.z ?? 0, + }, + ], + moveInstance: (_idx: number, x: number, z: number) => { + flowerGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + fruits: { + entries: [ + { + layoutId: firstFruit?.id ?? 'fruit-0', + group: fruitGroup, + kind: 'fruit', + x: firstFruit?.x ?? 0, + z: firstFruit?.z ?? 0, + }, + ], + moveEntry: (_idx: number, x: number, z: number) => { + fruitGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + mailbox: { + group: mailboxGroup, + position: { x: -0.6, y: 1, z: 2.5 }, + move: (x: number, z: number) => { + mailboxGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + telescope: { + group: teleGroup, + move: (x: number, z: number) => { + teleGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + } +} + +function makeStateStub(layout: IslandLayout, island: ReturnType) { + return { + island, + islandLayout: layout, + } +} + +afterEach(() => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + vi.restoreAllMocks() +}) + +// ── CommandStack ─────────────────────────────────────────────────────────── + +describe('CommandStack', () => { + it('push and undo restores state', () => { + const stack = new CommandStack() + let val = 'original' + stack.push({ + do: () => { + val = 'modified' + }, + undo: () => { + val = 'original' + }, + }) + expect(stack.undoCount).toBe(1) + expect(stack.redoCount).toBe(0) + stack.undo() + expect(val).toBe('original') + expect(stack.undoCount).toBe(0) + expect(stack.redoCount).toBe(1) + }) + + it('redo re-applies', () => { + const stack = new CommandStack() + let val = 0 + stack.push({ + do: () => { + val = 1 + }, + undo: () => { + val = 0 + }, + }) + stack.undo() + expect(val).toBe(0) + stack.redo() + expect(val).toBe(1) + }) + + it('push clears redo stack', () => { + const stack = new CommandStack() + stack.push({ do: vi.fn(), undo: vi.fn() }) + stack.undo() + expect(stack.redoCount).toBe(1) + stack.push({ do: vi.fn(), undo: vi.fn() }) + expect(stack.redoCount).toBe(0) + }) + + it('caps at 100 entries', () => { + const stack = new CommandStack() + for (let i = 0; i < 110; i++) { + stack.push({ do: vi.fn(), undo: vi.fn() }) + } + expect(stack.undoCount).toBe(100) + }) + + it('undo on empty stack is a no-op', () => { + const stack = new CommandStack() + expect(() => stack.undo()).not.toThrow() + }) + + it('clear removes everything', () => { + const stack = new CommandStack() + stack.push({ do: vi.fn(), undo: vi.fn() }) + stack.clear() + expect(stack.undoCount).toBe(0) + }) +}) + +// ── Selection ────────────────────────────────────────────────────────────── + +describe('Selection', () => { + it('select stores the id', () => { + const scene = new THREE.Scene() + const sel = new Selection(scene) + const obj = new THREE.Group() + sel.select('tree-0', obj) + expect(sel.get()).toBe('tree-0') + }) + + it('deselect clears the id', () => { + const scene = new THREE.Scene() + const sel = new Selection(scene) + const obj = new THREE.Group() + sel.select('tree-0', obj) + sel.deselect() + expect(sel.get()).toBeNull() + }) + + it('onChange fires on select', () => { + const scene = new THREE.Scene() + const sel = new Selection(scene) + const ids: Array = [] + sel.onChange((id: string | null) => ids.push(id)) + sel.select('tree-0', new THREE.Group()) + expect(ids).toEqual(['tree-0']) + }) + + it('onChange fires on deselect with null', () => { + const scene = new THREE.Scene() + const sel = new Selection(scene) + const ids: Array = [] + sel.onChange((id: string | null) => ids.push(id)) + sel.select('tree-0', new THREE.Group()) + sel.deselect() + expect(ids).toEqual(['tree-0', null]) + }) + + it('dispose clears id and removes helper from scene', () => { + const scene = new THREE.Scene() + const sel = new Selection(scene) + sel.select('tree-0', new THREE.Group()) + const childCountAfterSelect = scene.children.length + expect(childCountAfterSelect).toBeGreaterThan(0) + sel.dispose() + expect(sel.get()).toBeNull() + // Helpers removed — scene children should decrease. + expect(scene.children.length).toBeLessThan(childCountAfterSelect) + }) + + it('unsubscribe removes callback', () => { + const scene = new THREE.Scene() + const sel = new Selection(scene) + let count = 0 + const unsub = sel.onChange(() => count++) + sel.select('tree-0', new THREE.Group()) + unsub() + sel.deselect() + expect(count).toBe(1) // only the select fired + }) +}) + +// ── EditController — selection ───────────────────────────────────────────── + +describe('EditController — selection', () => { + let layout: IslandLayout + let island: ReturnType + let viewStub: ReturnType + let stateStub: ReturnType + let controller: EditController + + beforeEach(() => { + layout = freshLayout() + island = makeIslandStub() + viewStub = makeViewStub(layout, island) + stateStub = makeStateStub(layout, island) + controller = new EditController({ view: viewStub as never, state: stateStub as never }) + }) + + afterEach(() => { + controller.dispose() + }) + + it('starts inactive with no selection', () => { + expect(controller.selection.get()).toBeNull() + }) + + it('activate/deactivate do not throw', () => { + expect(() => { + controller.activate() + controller.deactivate() + }).not.toThrow() + }) + + it('dispose restores camera.controls.enabled', () => { + viewStub.camera.controls.enabled = false + controller.dispose() + expect(viewStub.camera.controls.enabled).toBe(true) + }) +}) diff --git a/test/engine/IslandEditor.transform.test.ts b/test/engine/IslandEditor.transform.test.ts new file mode 100644 index 0000000..75ff8ae --- /dev/null +++ b/test/engine/IslandEditor.transform.test.ts @@ -0,0 +1,296 @@ +/** + * Plan 002 — Island Editor: applyTransform, undo/redo, drag controls. + * + * Tests: + * - applyTransform writes {x,z,yaw,scale} to layout.updateObject + * - y is not stored (always derived from heightAt) + * - off-plateau translate rejected + * - undo restores before, redo re-applies after + * - drag toggles camera.controls.enabled and restores on finish + * - dispose restores controls.enabled + */ + +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' +import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' +import EditController from '~/engine/student-space/Game/View/edit/EditController.js' + +// ── Shared test helpers ──────────────────────────────────────────────────── + +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() +} + +function makeIslandStub() { + return { + heightAt: (_x: number, _z: number) => 1.0, + isPlaceable: (x: number, z: number) => Math.abs(x) < 4 && Math.abs(z) < 4, + } +} + +function makeViewStub(layout: IslandLayout, island: ReturnType) { + const firstTree = layout.listByKind('tree')[0]! + const treeGroup = new THREE.Group() + treeGroup.position.set(firstTree.x, 1, firstTree.z) + + const firstFlower = layout.listByKind('flower')[0]! + const flowerGroup = new THREE.Group() + flowerGroup.position.set(firstFlower.x, 1, firstFlower.z) + + const firstFruit = layout.listByKind('fruit')[0]! + const fruitGroup = new THREE.Group() + fruitGroup.position.set(firstFruit.x, 1, firstFruit.z) + + const mailboxGroup = new THREE.Group() + mailboxGroup.position.set(-0.6, 1, 2.5) + + const teleGroup = new THREE.Group() + teleGroup.position.set(2.5, 1, -1.5) + + return { + scene: new THREE.Scene(), + camera: { + instance: new THREE.PerspectiveCamera(), + controls: { enabled: true }, + }, + renderer: { instance: { domElement: document.createElement('canvas') } }, + tree: { + entries: [ + { + layoutId: firstTree.id, + group: treeGroup, + x: firstTree.x, + z: firstTree.z, + }, + ], + moveEntry: (_idx: number, x: number, z: number) => { + treeGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + flowers: { + flowers: [ + { + layoutId: firstFlower.id, + group: flowerGroup, + x: firstFlower.x, + z: firstFlower.z, + }, + ], + moveInstance: (_idx: number, x: number, z: number) => { + flowerGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + fruits: { + entries: [ + { + layoutId: firstFruit.id, + group: fruitGroup, + kind: 'fruit', + x: firstFruit.x, + z: firstFruit.z, + }, + ], + moveEntry: (_idx: number, x: number, z: number) => { + fruitGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + mailbox: { + group: mailboxGroup, + position: { x: -0.6, y: 1, z: 2.5 }, + move: (x: number, z: number) => { + mailboxGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + telescope: { + group: teleGroup, + move: (x: number, z: number) => { + teleGroup.position.set(x, island.heightAt(x, z), z) + }, + }, + } +} + +function makeStateStub(layout: IslandLayout, island: ReturnType) { + return { island, islandLayout: layout } +} + +afterEach(() => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + vi.restoreAllMocks() +}) + +// ── applyTransform ───────────────────────────────────────────────────────── + +describe('EditController.applyTransform', () => { + let layout: IslandLayout + let island: ReturnType + let controller: EditController + let treeId: string + + beforeEach(() => { + layout = freshLayout() + island = makeIslandStub() + const view = makeViewStub(layout, island) + const state = makeStateStub(layout, island) + controller = new EditController({ view: view as never, state: state as never }) + treeId = layout.listByKind('tree')[0]!.id + }) + + afterEach(() => controller.dispose()) + + it('writes x and z to layout', () => { + const ok = controller.applyTransform(treeId, { x: 1.5, z: -0.5 }) + expect(ok).toBe(true) + const updated = layout.get(treeId) + expect(updated?.x).toBeCloseTo(1.5) + expect(updated?.z).toBeCloseTo(-0.5) + }) + + it('does not store y (y is always derived)', () => { + controller.applyTransform(treeId, { x: 1, z: 1 }) + const updated = layout.get(treeId) + expect((updated as Record).y).toBeUndefined() + }) + + it('writes yaw to layout', () => { + controller.applyTransform(treeId, { yaw: 1.23 }) + expect(layout.get(treeId)?.yaw).toBeCloseTo(1.23) + }) + + it('writes scale to layout', () => { + controller.applyTransform(treeId, { scale: 2.0 }) + expect(layout.get(treeId)?.scale).toBeCloseTo(2.0) + }) + + it('off-plateau translate is rejected and layout unchanged', () => { + const before = { x: layout.get(treeId)!.x, z: layout.get(treeId)!.z } + const ok = controller.applyTransform(treeId, { x: 10, z: 10 }) + expect(ok).toBe(false) + const after = layout.get(treeId) + expect(after?.x).toBeCloseTo(before.x) + expect(after?.z).toBeCloseTo(before.z) + }) + + it('unknown id is rejected', () => { + const ok = controller.applyTransform('nonexistent-id', { x: 1, z: 1 }) + expect(ok).toBe(false) + }) + + it('invalid patch types are rejected', () => { + const ok = controller.applyTransform(null as never, { x: 1 }) + expect(ok).toBe(false) + }) +}) + +// ── undo / redo ──────────────────────────────────────────────────────────── + +describe('EditController undo / redo', () => { + let layout: IslandLayout + let controller: EditController + let treeId: string + + beforeEach(() => { + layout = freshLayout() + const island = makeIslandStub() + const view = makeViewStub(layout, island) + const state = makeStateStub(layout, island) + controller = new EditController({ view: view as never, state: state as never }) + treeId = layout.listByKind('tree')[0]!.id + }) + + afterEach(() => controller.dispose()) + + it('undo restores the before state', () => { + const original = { ...layout.get(treeId)! } + controller.applyTransform(treeId, { x: 1.5, z: -0.5 }) + expect(layout.get(treeId)?.x).toBeCloseTo(1.5) + + controller.commandStack.undo() + expect(layout.get(treeId)?.x).toBeCloseTo(original.x) + expect(layout.get(treeId)?.z).toBeCloseTo(original.z) + }) + + it('redo re-applies the after state', () => { + controller.applyTransform(treeId, { x: 1.5, z: -0.5 }) + controller.commandStack.undo() + controller.commandStack.redo() + expect(layout.get(treeId)?.x).toBeCloseTo(1.5) + expect(layout.get(treeId)?.z).toBeCloseTo(-0.5) + }) + + it('multiple transforms track in correct order', () => { + const orig = layout.get(treeId)!.x + controller.applyTransform(treeId, { x: 1.0, z: 0 }) + controller.applyTransform(treeId, { x: 2.0, z: 0 }) + controller.commandStack.undo() + expect(layout.get(treeId)?.x).toBeCloseTo(1.0) + controller.commandStack.undo() + expect(layout.get(treeId)?.x).toBeCloseTo(orig) + }) +}) + +// ── drag controls ────────────────────────────────────────────────────────── + +describe('EditController drag — camera.controls restored', () => { + let layout: IslandLayout + let controller: EditController + let viewStub: ReturnType + + beforeEach(() => { + layout = freshLayout() + const island = makeIslandStub() + viewStub = makeViewStub(layout, island) + const state = makeStateStub(layout, island) + controller = new EditController({ view: viewStub as never, state: state as never }) + controller.activate() + }) + + afterEach(() => controller.dispose()) + + it('dispose restores camera.controls.enabled to true', () => { + // Manually mark controls disabled as if a drag is in progress. + viewStub.camera.controls.enabled = false + controller.dispose() + expect(viewStub.camera.controls.enabled).toBe(true) + }) + + it('deactivate restores camera.controls.enabled', () => { + viewStub.camera.controls.enabled = false + controller.deactivate() + expect(viewStub.camera.controls.enabled).toBe(true) + }) + + it('deactivate while active adds and removes canvas listener without error', () => { + expect(() => controller.deactivate()).not.toThrow() + }) +}) + +// ── reactive sync via objectUpdated ─────────────────────────────────────── + +describe('EditController reactive sync', () => { + it('layout.updateObject fans objectUpdated and controller syncs mesh', () => { + const layout = freshLayout() + const island = makeIslandStub() + const viewS = makeViewStub(layout, island) + const stateS = makeStateStub(layout, island) + const ctrl = new EditController({ view: viewS as never, state: stateS as never }) + + const treeId = layout.listByKind('tree')[0]!.id + const treeGroup = viewS.tree.entries[0]!.group + + // Direct layout mutation (simulating undo from outside). + layout.updateObject(treeId, { x: 3.0, z: 0.5 }) + + // The controller's subscriber should have called the adapter and moved + // the group via moveEntry. + expect(treeGroup.position.x).toBeCloseTo(3.0) + expect(treeGroup.position.z).toBeCloseTo(0.5) + + ctrl.dispose() + }) +}) From 5119017e9cbc8d79764bdacfa1f66578231893b5 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 15:44:26 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat(engine/ui):=20plan=20003=20=E2=80=94?= =?UTF-8?q?=20IslandEditorPanel,=20add/remove=20reconcile,=20inspector,=20?= =?UTF-8?q?undo/redo,=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tree/Flowers/Fruits: ensureFromLayout reconciles live scene after addObject/removeObject - Tree: _teardownPlacements() for clean rebuild; ensureFromLayout calls _placeAll() + re-applies visibility - Flowers: diff by layoutId, disposes gone entries, _buildOne for new entries - Fruits: diff by layoutId, builds new bushes, defers via _pendingEnsure if not yet placed - editableViews: spawn/remove now call ensureFromLayout instead of no-op stubs - EditController: subscribes to objectAdded/objectRemoved/layoutReplaced; _reconcileAfterStructural delegates to correct view kind - IslandEditorPanel: dev-only hash-gated React panel (Add palette, Inspector, Undo/Redo, Diverged badge, Preview toggle) - EngineHost: mounts IslandEditorPanel under import.meta.env.DEV - Tests: 5 spawn/remove reconcile tests (all pass) --- src/components/student-space/EngineHost.tsx | 2 + .../editor/IslandEditorPanel.tsx | 473 ++++++++++++++++++ src/engine/student-space/Game/View/Flowers.js | 62 +++ src/engine/student-space/Game/View/Fruits.js | 116 +++++ src/engine/student-space/Game/View/Tree.js | 59 +++ .../Game/View/edit/EditController.js | 74 ++- .../Game/View/edit/editableViews.js | 67 +-- test/engine/IslandEditor.spawn.test.ts | 185 +++++++ 8 files changed, 1008 insertions(+), 30 deletions(-) create mode 100644 src/components/student-space/editor/IslandEditorPanel.tsx create mode 100644 test/engine/IslandEditor.spawn.test.ts diff --git a/src/components/student-space/EngineHost.tsx b/src/components/student-space/EngineHost.tsx index fe1c458..93d02ca 100644 --- a/src/components/student-space/EngineHost.tsx +++ b/src/components/student-space/EngineHost.tsx @@ -19,6 +19,7 @@ import { cn } from '~/lib/utils' import { AskSheet } from './capture/AskSheet' import { CaptureChooser } from './capture/CaptureChooser' import { MoodSheet } from './capture/MoodSheet' +import { IslandEditorPanel } from './editor/IslandEditorPanel' import { MobileNav } from './navigation/MobileNav' import { SideRail } from './navigation/SideRail' import { CameraTuneHud, type CameraTuneTargets } from './onboarding/CameraTuneHud' @@ -317,6 +318,7 @@ export function EngineHost({ {showOnboardingFlow ? : null} {import.meta.env.DEV && game ? : null} + {import.meta.env.DEV && game ? : null} {game ? : null} {children} diff --git a/src/components/student-space/editor/IslandEditorPanel.tsx b/src/components/student-space/editor/IslandEditorPanel.tsx new file mode 100644 index 0000000..f5adcf3 --- /dev/null +++ b/src/components/student-space/editor/IslandEditorPanel.tsx @@ -0,0 +1,473 @@ +/** + * IslandEditorPanel — dev-only island authoring surface (plan 003). + * + * Mounts only under `import.meta.env.DEV` + `location.hash` includes "editor". + * Never shipped to production. + * + * Sections: + * 1. Add palette: kind + species selectors + "Add" button + * 2. Inspector: x/z/yaw/scale number inputs, species select, locked toggle, Delete + * 3. Undo / Redo buttons + * 4. Diverged badge + Revert to default + * 5. Preview toggle (bare / populated) + * + * Engine access: game is cast to an internal shape to reach state.islandLayout, + * view.editController, view.tree/flowers/fruits — same pattern as CameraTuneBridge + * and MatureIslandBridge in EngineHost.tsx. + */ + +import { useEffect, useState } from 'react' +import type { Game } from '~/engine/student-space/Game' +import { useEngineSliceVersion } from '~/lib/student-space/use-engine-slice-version' + +// ── Known species per kind ──────────────────────────────────────────────────── + +const TREE_SPECIES = ['oak', 'cherry'] as const +const FLOWER_SPECIES = ['daisy', 'tulip', 'rose', 'lily', 'pansy', 'hyacinth'] as const +const FRUIT_SPECIES = ['plum', 'fig', 'citrus', 'berry', 'apple', 'pear'] as const + +type Kind = 'tree' | 'flower' | 'fruit' +type Species = string + +const SPECIES_BY_KIND: Record = { + tree: TREE_SPECIES, + flower: FLOWER_SPECIES, + fruit: FRUIT_SPECIES, +} + +// ── Internal engine shape (cast target) ────────────────────────────────────── + +interface PlacedObject { + id: string + kind: string + species?: string + x: number + z: number + yaw?: number + scale?: number + locked?: boolean +} + +interface IslandLayoutSlice { + subscribe(cb: (event: unknown) => void): () => void + list(): PlacedObject[] + listByKind(kind: string): PlacedObject[] + get(id: string): PlacedObject | undefined + addObject(obj: Partial): void + removeObject(id: string): void + updateObject(id: string, patch: Partial): void + isDiverged(): boolean + revertToDefault(): void +} + +interface CommandStack { + push(cmd: { do: () => void; undo: () => void }): void + undo(): void + redo(): void + undoCount: number + redoCount: number +} + +interface EditController { + activate(): void + deactivate(): void + applyTransform( + id: string, + patch: { x?: number; z?: number; yaw?: number; scale?: number }, + ): boolean + selection: { + get(): string | null + onChange(cb: (id: string | null) => void): () => void + select?(id: string, object3d: unknown): void + } + commandStack: CommandStack +} + +interface InternalGame { + state?: { + islandLayout?: IslandLayoutSlice + island?: { isPlaceable(x: number, z: number): boolean } + } + view?: { + editController?: EditController + tree?: { showAll?: () => void; hideAll?: () => void } + flowers?: { showAll?: () => void; hideAll?: () => void } + fruits?: { showAll?: () => void; hideAll?: () => void } + } +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface IslandEditorPanelProps { + game: Game +} + +export function IslandEditorPanel({ game }: IslandEditorPanelProps) { + const [hashOk, setHashOk] = useState( + () => typeof window !== 'undefined' && window.location.hash.includes('editor'), + ) + + useEffect(() => { + const check = () => setHashOk(window.location.hash.includes('editor')) + window.addEventListener('hashchange', check) + return () => window.removeEventListener('hashchange', check) + }, []) + + if (!hashOk) return null + return +} + +function PanelInner({ game }: IslandEditorPanelProps) { + const internal = game as unknown as InternalGame + const layout = internal.state?.islandLayout + const ctrl = internal.view?.editController + + // Subscribe to layout mutations for re-render. + useEngineSliceVersion(layout ?? null) + + // Track selected object id. + const [selectedId, setSelectedId] = useState(null) + + useEffect(() => { + if (!ctrl) return + ctrl.activate() + return () => ctrl.deactivate() + }, [ctrl]) + + useEffect(() => { + if (!ctrl) return + return ctrl.selection.onChange((id) => setSelectedId(id)) + }, [ctrl]) + + // ── Add palette state ──────────────────────────────────────────────────── + const [addKind, setAddKind] = useState('flower') + const [addSpecies, setAddSpecies] = useState(FLOWER_SPECIES[0]) + + // Keep addSpecies valid when kind changes. + useEffect(() => { + const opts = SPECIES_BY_KIND[addKind] + if (!opts.includes(addSpecies)) setAddSpecies(opts[0] ?? '') + }, [addKind, addSpecies]) + + // ── Preview state ──────────────────────────────────────────────────────── + const [preview, setPreview] = useState(false) + const view = internal.view + + // ── Derived inspector data ─────────────────────────────────────────────── + const selected = selectedId ? layout?.get(selectedId) : null + + // ── Undo/redo counts (re-render on each layout change already fires) ───── + const undoCount = ctrl?.commandStack.undoCount ?? 0 + const redoCount = ctrl?.commandStack.redoCount ?? 0 + + // ── Handlers ───────────────────────────────────────────────────────────── + + function handleAdd() { + if (!layout || !ctrl) return + const id = `${addKind}-${Date.now().toString(36)}` + const obj: Partial = { + id, + kind: addKind, + species: addSpecies, + x: 0, + z: 0, + yaw: 0, + scale: 1, + } + const before = { id } + layout.addObject(obj) + ctrl.commandStack.push({ + do: () => layout.addObject({ ...obj }), + undo: () => layout.removeObject(before.id), + }) + // Auto-select. + ctrl.selection.select?.(id, null as never) + } + + function handleDelete() { + if (!layout || !ctrl || !selectedId) return + const snap = layout.get(selectedId) + if (!snap) return + layout.removeObject(selectedId) + ctrl.commandStack.push({ + do: () => layout.removeObject(selectedId), + undo: () => layout.addObject({ ...snap }), + }) + setSelectedId(null) + } + + function handleFieldChange(field: 'x' | 'z' | 'yaw' | 'scale', raw: string) { + if (!ctrl || !selectedId) return + const val = Number.parseFloat(raw) + if (!Number.isFinite(val)) return + ctrl.applyTransform(selectedId, { [field]: val }) + } + + function handleSpeciesChange(species: string) { + if (!layout || !selectedId) return + layout.updateObject(selectedId, { species }) + } + + function handleLockedChange(locked: boolean) { + if (!layout || !selectedId) return + layout.updateObject(selectedId, { locked }) + } + + function handleRevert() { + if (!layout) return + if (!window.confirm('Revert to committed default? All local edits will be lost.')) return + layout.revertToDefault() + } + + function handlePreviewToggle(on: boolean) { + setPreview(on) + if (on) { + view?.tree?.showAll?.() + view?.flowers?.showAll?.() + view?.fruits?.showAll?.() + } else { + view?.tree?.hideAll?.() + view?.flowers?.hideAll?.() + view?.fruits?.hideAll?.() + } + } + + const diverged = layout?.isDiverged() ?? false + + return ( +
+
+ ⬡ ISLAND EDITOR +
+ + {/* ── Add palette ────────────────────────────────────────────── */} +
+
ADD
+
+ {(['tree', 'flower', 'fruit'] as Kind[]).map((k) => ( + + ))} +
+
+ {SPECIES_BY_KIND[addKind].map((s) => ( + + ))} +
+ +
+ +
+ + {/* ── Inspector ──────────────────────────────────────────────── */} + {selected ? ( +
+
+ INSPECTOR —{' '} + + {selected.kind}:{selected.id.slice(-6)} + +
+ {(['x', 'z', 'yaw', 'scale'] as const).map((field) => ( + + ))} + + {/* Species */} + + + {/* Locked */} + + + +
+ ) : ( +
+ Click an object to select +
+ )} + +
+ + {/* ── Undo / Redo ────────────────────────────────────────────── */} +
+ + +
+ + {/* ── Diverged badge + revert ─────────────────────────────────── */} + {diverged && ( +
+
+ ⚠ Local edits — differs from committed default +
+ +
+ )} + + {/* ── Preview toggle ──────────────────────────────────────────── */} +
+ +
+
+ ) +} + +// ── Style helpers ───────────────────────────────────────────────────────────── + +function btnStyle(active: boolean, disabled = false): React.CSSProperties { + return { + padding: '2px 7px', + borderRadius: '4px', + border: '1px solid rgba(255,255,255,0.15)', + background: active ? 'rgba(99,102,241,0.4)' : 'rgba(255,255,255,0.06)', + color: disabled ? '#475569' : '#e2e8f0', + cursor: disabled ? 'not-allowed' : 'pointer', + fontSize: '11px', + opacity: disabled ? 0.5 : 1, + } +} + +const actionBtnStyle: React.CSSProperties = { + padding: '3px 10px', + borderRadius: '4px', + border: '1px solid rgba(255,255,255,0.15)', + background: 'rgba(99,102,241,0.3)', + color: '#e2e8f0', + cursor: 'pointer', + fontSize: '12px', + width: '100%', +} + +const inputStyle: React.CSSProperties = { + flex: 1, + background: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(255,255,255,0.12)', + borderRadius: '3px', + color: '#e2e8f0', + padding: '2px 5px', + fontSize: '11px', + fontFamily: 'monospace', +} diff --git a/src/engine/student-space/Game/View/Flowers.js b/src/engine/student-space/Game/View/Flowers.js index d725764..cbe5e0d 100644 --- a/src/engine/student-space/Game/View/Flowers.js +++ b/src/engine/student-space/Game/View/Flowers.js @@ -493,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 0bd28c8..a7daa1f 100644 --- a/src/engine/student-space/Game/View/Fruits.js +++ b/src/engine/student-space/Game/View/Fruits.js @@ -225,6 +225,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/Tree.js b/src/engine/student-space/Game/View/Tree.js index 119454b..3539237 100644 --- a/src/engine/student-space/Game/View/Tree.js +++ b/src/engine/student-space/Game/View/Tree.js @@ -502,6 +502,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 diff --git a/src/engine/student-space/Game/View/edit/EditController.js b/src/engine/student-space/Game/View/edit/EditController.js index 63754b3..fc247cb 100644 --- a/src/engine/student-space/Game/View/edit/EditController.js +++ b/src/engine/student-space/Game/View/edit/EditController.js @@ -47,7 +47,7 @@ export default class EditController this._camera = view.camera this._scene = view.scene - this.editableViews = buildEditableViews(view, this._island) + this.editableViews = buildEditableViews(view, this._island, state.islandLayout) this.selection = new Selection(this._scene) this.commandStack = new CommandStack() @@ -76,7 +76,17 @@ export default class EditController // changes come from undo, the inspector, or external callers. this._unsubLayout = this._state.islandLayout?.subscribe((event) => { - if(event.type === 'objectUpdated') this._syncMesh(event.object) + if(event.type === 'objectUpdated') + { + this._syncMesh(event.object) + return + } + + // Structural events — reconcile the appropriate view kind. + if(event.type === 'objectAdded' || event.type === 'objectRemoved' || event.type === 'layoutReplaced') + { + this._reconcileAfterStructural(event) + } }) } @@ -571,4 +581,64 @@ export default class EditController } catch(err) { console.warn('[EditController] _syncMesh threw', err) } } + + /** + * Called when objectAdded / objectRemoved / layoutReplaced fires. + * Reconciles the affected view kind(s) via ensureFromLayout. + * + * @param {{ type: string, object?: { kind: string }, kind?: string }} event + */ + _reconcileAfterStructural(event) + { + const layout = this._state.islandLayout + if(!layout) return + + // Determine which kinds to reconcile. + const kindsToReconcile = new Set() + + if(event.type === 'layoutReplaced') + { + // All editable kinds. + for(const k of ['tree', 'flower', 'fruit', 'mailbox', 'telescope']) kindsToReconcile.add(k) + } + else if(event.object?.kind) + { + kindsToReconcile.add(event.object.kind) + } + + for(const kind of kindsToReconcile) + { + const objs = layout.listByKind(kind) + try + { + // Trees and flowers/fruits have ensureFromLayout. + if(kind === 'tree') + { + this._view.tree?.ensureFromLayout?.(objs) + } + else if(kind === 'flower') + { + this._view.flowers?.ensureFromLayout?.(objs) + } + else if(kind === 'fruit') + { + this._view.fruits?.ensureFromLayout?.(objs) + } + else if(kind === 'mailbox') + { + const obj = objs[0] + if(obj) this._view.mailbox?.move?.(obj.x, obj.z) + } + else if(kind === 'telescope') + { + const obj = objs[0] + if(obj) this._view.telescope?.move?.(obj.x, obj.z) + } + } + catch(err) + { + console.warn(`[EditController] reconcile threw for kind=${kind}`, err) + } + } + } } diff --git a/src/engine/student-space/Game/View/edit/editableViews.js b/src/engine/student-space/Game/View/edit/editableViews.js index 5d125d0..6e4a1ee 100644 --- a/src/engine/student-space/Game/View/edit/editableViews.js +++ b/src/engine/student-space/Game/View/edit/editableViews.js @@ -16,34 +16,50 @@ /** * @param {import('../View.js').default} view - * @param {import('../Island.js').default} island + * @param {import('../../State/Island.js').default} island + * @param {import('../../State/IslandLayout.js').default} [layout] */ -export function buildEditableViews(view, island) +export function buildEditableViews(view, island, layout) { return { - tree: buildTreeAdapter(view, island), - flower: buildFlowerAdapter(view, island), - fruit: buildFruitAdapter(view, island), - mailbox: buildMailboxAdapter(view, island), - telescope: buildTelescopeAdapter(view, island), + tree: buildTreeAdapter(view, island, layout), + flower: buildFlowerAdapter(view, island, layout), + fruit: buildFruitAdapter(view, island, layout), + mailbox: buildMailboxAdapter(view, island, layout), + telescope: buildTelescopeAdapter(view, island, layout), } } // ── Helpers ────────────────────────────────────────────────────────────────── -function stubSpawn(kind) +/** + * Build spawn/remove helpers for a given kind that delegate to the view's + * ensureFromLayout. The layout slice has already been mutated before these + * are called, so we just trigger the reconcile. + * + * @param {object} view + * @param {import('../../State/IslandLayout.js').default} layout + * @param {string} kind + */ +function buildSpawnRemove(view, layout, kind) { - return (obj) => console.warn(`[editableViews:${kind}] spawn() not yet implemented — see plan 003`, obj) -} + const reconcile = () => + { + const objs = layout?.listByKind?.(kind) ?? [] + // Try both singular and plural names (tree→view.tree, flower→view.flowers). + const target = view[kind] ?? view[`${kind}s`] + target?.ensureFromLayout?.(objs) + } -function stubRemove(kind) -{ - return (id) => console.warn(`[editableViews:${kind}] remove() not yet implemented — see plan 003`, id) + return { + spawn: (_obj) => reconcile(), + remove: (_id) => reconcile(), + } } // ── Tree ───────────────────────────────────────────────────────────────────── -function buildTreeAdapter(view, island) +function buildTreeAdapter(view, island, layout) { return { getObject3D(layoutId) @@ -83,14 +99,13 @@ function buildTreeAdapter(view, island) if(typeof t.scale === 'number') entry.group.scale.setScalar(t.scale) }, - spawn: stubSpawn('tree'), - remove: stubRemove('tree'), + ...buildSpawnRemove(view, layout, 'tree'), } } // ── Flower ──────────────────────────────────────────────────────────────────── -function buildFlowerAdapter(view, island) +function buildFlowerAdapter(view, island, layout) { return { getObject3D(layoutId) @@ -130,14 +145,13 @@ function buildFlowerAdapter(view, island) if(typeof t.scale === 'number') f.group.scale.setScalar(t.scale) }, - spawn: stubSpawn('flower'), - remove: stubRemove('flower'), + ...buildSpawnRemove(view, layout, 'flower'), } } // ── Fruit ───────────────────────────────────────────────────────────────────── -function buildFruitAdapter(view, island) +function buildFruitAdapter(view, island, layout) { return { getObject3D(layoutId) @@ -177,14 +191,13 @@ function buildFruitAdapter(view, island) if(typeof t.scale === 'number') entry.group.scale.setScalar(t.scale) }, - spawn: stubSpawn('fruit'), - remove: stubRemove('fruit'), + ...buildSpawnRemove(view, layout, 'fruit'), } } // ── Mailbox ─────────────────────────────────────────────────────────────────── -function buildMailboxAdapter(view, island) +function buildMailboxAdapter(view, island, layout) { return { getObject3D(_layoutId) @@ -220,14 +233,13 @@ function buildMailboxAdapter(view, island) if(typeof t.scale === 'number') g.scale.setScalar(t.scale) }, - spawn: stubSpawn('mailbox'), - remove: stubRemove('mailbox'), + ...buildSpawnRemove(view, layout, 'mailbox'), } } // ── Telescope ───────────────────────────────────────────────────────────────── -function buildTelescopeAdapter(view, island) +function buildTelescopeAdapter(view, island, layout) { return { getObject3D(_layoutId) @@ -263,7 +275,6 @@ function buildTelescopeAdapter(view, island) if(typeof t.scale === 'number') g.scale.setScalar(t.scale) }, - spawn: stubSpawn('telescope'), - remove: stubRemove('telescope'), + ...buildSpawnRemove(view, layout, 'telescope'), } } diff --git a/test/engine/IslandEditor.spawn.test.ts b/test/engine/IslandEditor.spawn.test.ts new file mode 100644 index 0000000..8f510ce --- /dev/null +++ b/test/engine/IslandEditor.spawn.test.ts @@ -0,0 +1,185 @@ +/** + * Plan 003 — Island Editor: spawn / remove reconcile tests. + * + * Verifies that addObject / removeObject triggers ensureFromLayout on the + * correct view stub, and that _reconcileAfterStructural in EditController + * delegates to the right per-kind handler. + */ + +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' +import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' +import EditController from '~/engine/student-space/Game/View/edit/EditController.js' + +// ── Test infrastructure ──────────────────────────────────────────────────── + +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() +} + +function makeIsland() { + return { + heightAt: (_x: number, _z: number) => 0, + isPlaceable: (x: number, z: number) => Math.abs(x) < 5 && Math.abs(z) < 5, + } +} + +function makeViewStub(layout: IslandLayout, island: ReturnType) { + const firstTree = layout.listByKind('tree')[0] + const firstFlower = layout.listByKind('flower')[0] + const firstFruit = layout.listByKind('fruit')[0] + + const treeGroup = new THREE.Group() + const flowerGroup = new THREE.Group() + const fruitGroup = new THREE.Group() + const mailboxGroup = new THREE.Group() + const teleGroup = new THREE.Group() + + return { + scene: new THREE.Scene(), + camera: { instance: new THREE.PerspectiveCamera(), controls: { enabled: true }, bindControls: vi.fn() }, + renderer: { instance: { domElement: document.createElement('canvas') } }, + tree: { + ready: true, + entries: [{ layoutId: firstTree?.id ?? 'tree-0', group: treeGroup }], + ensureFromLayout: vi.fn(), + }, + flowers: { + flowers: [{ layoutId: firstFlower?.id ?? 'flower-0', group: flowerGroup }], + ensureFromLayout: vi.fn(), + }, + fruits: { + entries: [{ layoutId: firstFruit?.id ?? 'fruit-0', group: fruitGroup }], + ensureFromLayout: vi.fn(), + }, + mailbox: { + group: mailboxGroup, + move: vi.fn((x: number, z: number) => { + mailboxGroup.position.set(x, island.heightAt(x, z), z) + }), + }, + telescope: { + group: teleGroup, + move: vi.fn((x: number, z: number) => { + teleGroup.position.set(x, island.heightAt(x, z), z) + }), + }, + } +} + +function makeState(layout: IslandLayout, island: ReturnType) { + return { island, islandLayout: layout } +} + +afterEach(() => { + ;(Persistence as unknown as { instance: unknown }).instance = null + ;(IslandLayout as unknown as { instance: unknown }).instance = null + vi.restoreAllMocks() +}) + +// ── Spawn / remove reconcile via EditController ─────────────────────────── + +describe('EditController spawn/remove reconcile', () => { + it('addObject(flower) calls flowers.ensureFromLayout with updated list', () => { + const layout = freshLayout() + const island = makeIsland() + const view = makeViewStub(layout, island) + const state = makeState(layout, island) + + const ctrl = new EditController({ view: view as never, state: state as never }) + ctrl.activate() + + const beforeCount = layout.listByKind('flower').length + + layout.addObject({ id: 'flower-new', kind: 'flower', species: 'daisy', x: 0.5, z: 0.5 }) + + expect(layout.listByKind('flower').length).toBe(beforeCount + 1) + expect(view.flowers.ensureFromLayout).toHaveBeenCalled() + const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at(-1)![0] as { id: string }[] + expect(lastCall.some((o) => o.id === 'flower-new')).toBe(true) + + ctrl.dispose() + }) + + it('addObject(tree) calls tree.ensureFromLayout', () => { + const layout = freshLayout() + const island = makeIsland() + const view = makeViewStub(layout, island) + const state = makeState(layout, island) + + const ctrl = new EditController({ view: view as never, state: state as never }) + ctrl.activate() + + layout.addObject({ id: 'tree-new', kind: 'tree', species: 'oak', x: 1.0, z: 1.0 }) + + expect(view.tree.ensureFromLayout).toHaveBeenCalled() + + ctrl.dispose() + }) + + it('addObject(fruit) calls fruits.ensureFromLayout', () => { + const layout = freshLayout() + const island = makeIsland() + const view = makeViewStub(layout, island) + const state = makeState(layout, island) + + const ctrl = new EditController({ view: view as never, state: state as never }) + ctrl.activate() + + layout.addObject({ id: 'fruit-new', kind: 'fruit', species: 'plum', x: 2.0, z: 0.5 }) + + expect(view.fruits.ensureFromLayout).toHaveBeenCalled() + + ctrl.dispose() + }) + + it('removeObject(flower) calls flowers.ensureFromLayout with reduced list', () => { + const layout = freshLayout() + const island = makeIsland() + const view = makeViewStub(layout, island) + const state = makeState(layout, island) + + const firstFlowerId = layout.listByKind('flower')[0]!.id + + const ctrl = new EditController({ view: view as never, state: state as never }) + ctrl.activate() + ;(view.flowers.ensureFromLayout as ReturnType).mockClear() + + layout.removeObject(firstFlowerId) + + expect(view.flowers.ensureFromLayout).toHaveBeenCalled() + const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at(-1)![0] as { id: string }[] + expect(lastCall.some((o) => o.id === firstFlowerId)).toBe(false) + + ctrl.dispose() + }) + + it('revertToDefault triggers all-kind reconcile (layoutReplaced)', () => { + const layout = freshLayout() + const island = makeIsland() + const view = makeViewStub(layout, island) + const state = makeState(layout, island) + + const ctrl = new EditController({ view: view as never, state: state as never }) + ctrl.activate() + ;(view.tree.ensureFromLayout as ReturnType).mockClear() + ;(view.flowers.ensureFromLayout as ReturnType).mockClear() + ;(view.fruits.ensureFromLayout as ReturnType).mockClear() + + // Diverge then revert. + layout.addObject({ id: 'tmp-flower', kind: 'flower', species: 'daisy', x: 0, z: 0 }) + ;(view.flowers.ensureFromLayout as ReturnType).mockClear() + + layout.revertToDefault() + + expect(view.tree.ensureFromLayout).toHaveBeenCalled() + expect(view.flowers.ensureFromLayout).toHaveBeenCalled() + expect(view.fruits.ensureFromLayout).toHaveBeenCalled() + + ctrl.dispose() + }) +}) From 6a2a740a7bfb9ca6cd758a7c2f27f13293ba15b4 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 15:48:43 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat(engine/ui):=20plan=20004=20=E2=80=94?= =?UTF-8?q?=20Export/Import=20layout,=20committed=20defaultIslandLayout.js?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IslandEditorPanel: Export (↓) downloads island-layout-.json; Import (↑) loads JSON via FileReader → setLayout (live update, no reload) - islandLayout.js: defaultIslandLayout() now loads from committed defaultIslandLayout.json (merged through mergeIslandLayout), falls back to defaultIslandLayoutFromConstants() if empty/invalid - defaultIslandLayout.json: 31-object seed committed from defaultIslandLayoutFromConstants() — replacing it with an exported edit changes the boot island with no other code change - Tests: serialize/setLayout round-trip, layoutReplaced event, invalid-input rejection, JSON validity guard (8 pass, 1 seed-parity skipped) - U3 (decorOffsets index→uuid re-key) deferred: high-touch change to Sprouts state + View/Sprouts; escape hatch invoked per plan 004 spec — will be its own plan --- .../editor/IslandEditorPanel.tsx | 66 +++- .../Game/Data/defaultIslandLayout.json | 313 ++++++++++++++++++ .../student-space/Game/Data/islandLayout.js | 48 +-- test/engine/IslandEditor.spawn.test.ts | 46 +-- test/engine/IslandLayout.export.test.ts | 125 +++++++ 5 files changed, 554 insertions(+), 44 deletions(-) create mode 100644 src/engine/student-space/Game/Data/defaultIslandLayout.json create mode 100644 test/engine/IslandLayout.export.test.ts diff --git a/src/components/student-space/editor/IslandEditorPanel.tsx b/src/components/student-space/editor/IslandEditorPanel.tsx index f5adcf3..947c7a7 100644 --- a/src/components/student-space/editor/IslandEditorPanel.tsx +++ b/src/components/student-space/editor/IslandEditorPanel.tsx @@ -58,6 +58,8 @@ interface IslandLayoutSlice { updateObject(id: string, patch: Partial): void isDiverged(): boolean revertToDefault(): void + serialize(): { v: number; objects: PlacedObject[] } + setLayout(snapshot: unknown): void } interface CommandStack { @@ -160,6 +162,42 @@ function PanelInner({ game }: IslandEditorPanelProps) { const undoCount = ctrl?.commandStack.undoCount ?? 0 const redoCount = ctrl?.commandStack.redoCount ?? 0 + // ── Export / Import ────────────────────────────────────────────────────── + + function handleExport() { + if (!layout) return + const json = JSON.stringify(layout.serialize(), null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `island-layout-${Date.now().toString(36)}.json` + a.click() + URL.revokeObjectURL(url) + } + + function handleImport() { + if (!layout) return + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json,application/json' + input.onchange = () => { + const file = input.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (e) => { + try { + const parsed = JSON.parse(e.target?.result as string) + layout.setLayout(parsed) + } catch { + alert('Invalid JSON file') + } + } + reader.readAsText(file) + } + input.click() + } + // ── Handlers ───────────────────────────────────────────────────────────── function handleAdd() { @@ -258,13 +296,33 @@ function PanelInner({ game }: IslandEditorPanelProps) { >
- ⬡ ISLAND EDITOR + + ⬡ ISLAND EDITOR + + + + +
{/* ── Add palette ────────────────────────────────────────────── */} 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/islandLayout.js b/src/engine/student-space/Game/Data/islandLayout.js index c10b857..d55d7fe 100644 --- a/src/engine/student-space/Game/Data/islandLayout.js +++ b/src/engine/student-space/Game/Data/islandLayout.js @@ -25,6 +25,9 @@ * @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) @@ -107,23 +110,39 @@ const FLOWER_SPECIES = ['daisy', 'tulip', 'rose', 'lily', 'pansy', 'hyacinth'] // ── Default builder ──────────────────────────────────────────────────────────── /** - * Build the canonical default `IslandLayout` from the baked constants. + * 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. * - * Produces 31 objects: - * - tree-0 … tree-6 (7, from TREE_PLACEMENTS) - * - flower-0 … flower-17 (18, from flowerBasePlacement) - * - fruit-0 … fruit-3 (4, from FRUIT_PLACEMENTS) - * - mailbox-0 (1, locked) - * - telescope-0 (1, locked) + * 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 = [] - // Trees for(let i = 0; i < TREE_PLACEMENTS.length; i++) { const p = TREE_PLACEMENTS[i] @@ -139,7 +158,6 @@ export function defaultIslandLayout() }) } - // Flowers for(let i = 0; i < 18; i++) { const { x, z, yaw } = flowerBasePlacement(i) @@ -156,7 +174,6 @@ export function defaultIslandLayout() }) } - // Fruits for(let i = 0; i < FRUIT_PLACEMENTS.length; i++) { const p = FRUIT_PLACEMENTS[i] @@ -172,7 +189,6 @@ export function defaultIslandLayout() }) } - // Mailbox objects.push({ id: 'mailbox-0', kind: 'mailbox', @@ -184,7 +200,6 @@ export function defaultIslandLayout() locked: true, }) - // Telescope objects.push({ id: 'telescope-0', kind: 'telescope', @@ -198,12 +213,3 @@ export function defaultIslandLayout() return { v: 1, objects } } - -/** - * Alias kept for back-compat / tests. Same as `defaultIslandLayout()`. - * @returns {{ v: 1, objects: PlacedObject[] }} - */ -export function defaultIslandLayoutFromConstants() -{ - return defaultIslandLayout() -} diff --git a/test/engine/IslandEditor.spawn.test.ts b/test/engine/IslandEditor.spawn.test.ts index 8f510ce..d4367ce 100644 --- a/test/engine/IslandEditor.spawn.test.ts +++ b/test/engine/IslandEditor.spawn.test.ts @@ -29,19 +29,23 @@ function makeIsland() { } function makeViewStub(layout: IslandLayout, island: ReturnType) { - const firstTree = layout.listByKind('tree')[0] + const firstTree = layout.listByKind('tree')[0] const firstFlower = layout.listByKind('flower')[0] - const firstFruit = layout.listByKind('fruit')[0] + const firstFruit = layout.listByKind('fruit')[0] - const treeGroup = new THREE.Group() - const flowerGroup = new THREE.Group() - const fruitGroup = new THREE.Group() + const treeGroup = new THREE.Group() + const flowerGroup = new THREE.Group() + const fruitGroup = new THREE.Group() const mailboxGroup = new THREE.Group() - const teleGroup = new THREE.Group() + const teleGroup = new THREE.Group() return { scene: new THREE.Scene(), - camera: { instance: new THREE.PerspectiveCamera(), controls: { enabled: true }, bindControls: vi.fn() }, + camera: { + instance: new THREE.PerspectiveCamera(), + controls: { enabled: true }, + bindControls: vi.fn(), + }, renderer: { instance: { domElement: document.createElement('canvas') } }, tree: { ready: true, @@ -87,8 +91,8 @@ describe('EditController spawn/remove reconcile', () => { it('addObject(flower) calls flowers.ensureFromLayout with updated list', () => { const layout = freshLayout() const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) + const view = makeViewStub(layout, island) + const state = makeState(layout, island) const ctrl = new EditController({ view: view as never, state: state as never }) ctrl.activate() @@ -99,7 +103,9 @@ describe('EditController spawn/remove reconcile', () => { expect(layout.listByKind('flower').length).toBe(beforeCount + 1) expect(view.flowers.ensureFromLayout).toHaveBeenCalled() - const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at(-1)![0] as { id: string }[] + const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at( + -1, + )![0] as { id: string }[] expect(lastCall.some((o) => o.id === 'flower-new')).toBe(true) ctrl.dispose() @@ -108,8 +114,8 @@ describe('EditController spawn/remove reconcile', () => { it('addObject(tree) calls tree.ensureFromLayout', () => { const layout = freshLayout() const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) + const view = makeViewStub(layout, island) + const state = makeState(layout, island) const ctrl = new EditController({ view: view as never, state: state as never }) ctrl.activate() @@ -124,8 +130,8 @@ describe('EditController spawn/remove reconcile', () => { it('addObject(fruit) calls fruits.ensureFromLayout', () => { const layout = freshLayout() const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) + const view = makeViewStub(layout, island) + const state = makeState(layout, island) const ctrl = new EditController({ view: view as never, state: state as never }) ctrl.activate() @@ -140,8 +146,8 @@ describe('EditController spawn/remove reconcile', () => { it('removeObject(flower) calls flowers.ensureFromLayout with reduced list', () => { const layout = freshLayout() const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) + const view = makeViewStub(layout, island) + const state = makeState(layout, island) const firstFlowerId = layout.listByKind('flower')[0]!.id @@ -152,7 +158,9 @@ describe('EditController spawn/remove reconcile', () => { layout.removeObject(firstFlowerId) expect(view.flowers.ensureFromLayout).toHaveBeenCalled() - const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at(-1)![0] as { id: string }[] + const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at( + -1, + )![0] as { id: string }[] expect(lastCall.some((o) => o.id === firstFlowerId)).toBe(false) ctrl.dispose() @@ -161,8 +169,8 @@ describe('EditController spawn/remove reconcile', () => { it('revertToDefault triggers all-kind reconcile (layoutReplaced)', () => { const layout = freshLayout() const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) + const view = makeViewStub(layout, island) + const state = makeState(layout, island) const ctrl = new EditController({ view: view as never, state: state as never }) ctrl.activate() 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) + }) +}) From dfbd140bab19752212a434a99480f492bba40505 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 15:55:49 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(engine/ui):=20plan=20005=20=E2=80=94?= =?UTF-8?q?=20SpeciesPalette=20slice,=20live=20recolor,=20palette=20editor?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - speciesPalette.js: data model with defaultSpeciesPaletteFromConstants() (oak/cherry tree colors, 6 flower + 6 fruit species as #rrggbb) - defaultSpeciesPalette.json: committed default (seed from constants, visual no-op) - schema.js: mergeSpeciesPalette() — lenient merge with hex color validation per slot - SpeciesPalette.js: working-copy-over-committed-base slice (get/setColor/list/isDiverged/revertToDefault/subscribe/hydrate/serialize); fires paletteChanged / paletteReplaced - Persistence: speciesPalette added to KEY/SLICES/empty - State.js + Game.js: construct/hydrate speciesPalette, null singleton on dispose - Fruits.js: subscribes to paletteChanged → live _berryMats[species].color.set(); applies persisted overrides at boot - Tree.js: subscribes to paletteChanged → live uniforms.uColorA/uColorB.value.set(); applies after _loadAndBuild - Flowers: reload fallback (palette read at _buildOne; live re-skin deferred as U4 escape hatch) - IslandEditorPanel: Palette section with per-species color inputs, Export/Import palette JSON, diverged badge + Revert palette - Tests: 12 SpeciesPalette tests (model, setColor, paletteChanged, isDiverged, revert, serialize/hydrate round-trip) --- .../editor/IslandEditorPanel.tsx | 190 +++++++++++++++++- .../Game/Data/defaultSpeciesPalette.json | 56 ++++++ .../student-space/Game/Data/speciesPalette.js | 108 ++++++++++ src/engine/student-space/Game/Game.js | 4 +- .../student-space/Game/State/Persistence.js | 5 +- .../Game/State/SpeciesPalette.js | 180 +++++++++++++++++ src/engine/student-space/Game/State/State.js | 5 +- src/engine/student-space/Game/State/schema.js | 58 ++++++ src/engine/student-space/Game/View/Fruits.js | 25 +++ src/engine/student-space/Game/View/Tree.js | 30 +++ test/engine/SpeciesPalette.test.ts | 127 ++++++++++++ 11 files changed, 782 insertions(+), 6 deletions(-) create mode 100644 src/engine/student-space/Game/Data/defaultSpeciesPalette.json create mode 100644 src/engine/student-space/Game/Data/speciesPalette.js create mode 100644 src/engine/student-space/Game/State/SpeciesPalette.js create mode 100644 test/engine/SpeciesPalette.test.ts diff --git a/src/components/student-space/editor/IslandEditorPanel.tsx b/src/components/student-space/editor/IslandEditorPanel.tsx index 947c7a7..4a08cfa 100644 --- a/src/components/student-space/editor/IslandEditorPanel.tsx +++ b/src/components/student-space/editor/IslandEditorPanel.tsx @@ -85,9 +85,26 @@ interface EditController { commandStack: CommandStack } +interface SpeciesPaletteSlice { + get(kind: string, species: string): Record | null + setColor(kind: string, species: string, colors: Record): void + list(): { + v: number + tree: Record> + flower: Record> + fruit: Record> + } + isDiverged(): boolean + revertToDefault(): void + serialize(): unknown + setFromSnapshot(raw: unknown): void + subscribe(cb: (event: unknown) => void): () => void +} + interface InternalGame { state?: { islandLayout?: IslandLayoutSlice + speciesPalette?: SpeciesPaletteSlice island?: { isPlaceable(x: number, z: number): boolean } } view?: { @@ -122,10 +139,12 @@ export function IslandEditorPanel({ game }: IslandEditorPanelProps) { function PanelInner({ game }: IslandEditorPanelProps) { const internal = game as unknown as InternalGame const layout = internal.state?.islandLayout + const palette = internal.state?.speciesPalette const ctrl = internal.view?.editController - // Subscribe to layout mutations for re-render. + // Subscribe to layout + palette mutations for re-render. useEngineSliceVersion(layout ?? null) + useEngineSliceVersion(palette ?? null) // Track selected object id. const [selectedId, setSelectedId] = useState(null) @@ -198,6 +217,40 @@ function PanelInner({ game }: IslandEditorPanelProps) { input.click() } + function handlePaletteExport() { + if (!palette) return + const json = JSON.stringify(palette.serialize(), null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `species-palette-${Date.now().toString(36)}.json` + a.click() + URL.revokeObjectURL(url) + } + + function handlePaletteImport() { + if (!palette) return + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json,application/json' + input.onchange = () => { + const file = input.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (e) => { + try { + const parsed = JSON.parse(e.target?.result as string) + palette.setFromSnapshot(parsed) + } catch { + alert('Invalid JSON file') + } + } + reader.readAsText(file) + } + input.click() + } + // ── Handlers ───────────────────────────────────────────────────────────── function handleAdd() { @@ -479,7 +532,7 @@ function PanelInner({ game }: IslandEditorPanelProps) { )} {/* ── Preview toggle ──────────────────────────────────────────── */} -
+
+ +
+ + {/* ── Species Palette ─────────────────────────────────────────── */} + {palette && ( +
+
+ PALETTE + + + + +
+ + + + {palette.isDiverged() && ( + + )} +
+ )} + + ) +} + +// ── Palette color editor ───────────────────────────────────────────────────── + +const PALETTE_KINDS: Array<{ + kind: string + species: string[] + slots: string[] +}> = [ + { kind: 'tree', species: ['oak', 'cherry'], slots: ['colorA', 'colorB'] }, + { + kind: 'flower', + species: ['daisy', 'tulip', 'rose', 'lily', 'pansy', 'hyacinth'], + slots: ['petal', 'centre', 'face'], + }, + { + kind: 'fruit', + species: ['apple', 'pear', 'plum', 'fig', 'citrus', 'berry'], + slots: ['color'], + }, +] + +function PaletteEditor({ palette }: { palette: SpeciesPaletteSlice }) { + const paletteList = palette.list() + + return ( +
+ {PALETTE_KINDS.map(({ kind, species, slots }) => ( +
+
+ {kind.toUpperCase()} +
+ {species.map((sp) => { + const colors = ( + paletteList as unknown as Record>> + )[kind]?.[sp] + if (!colors) return null + return ( +
+ {sp} + {slots.map((slot) => { + const val = colors[slot] + if (!val) return null + return ( + + ) + })} +
+ ) + })} +
+ ))}
) } 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/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 7076fa6..f8d5899 100644 --- a/src/engine/student-space/Game/Game.js +++ b/src/engine/student-space/Game/Game.js @@ -15,6 +15,7 @@ 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' /** @@ -356,7 +357,8 @@ export default class Game IdentityStatusOverride.instance = null try { this.state?.islandSnapshots?.dispose?.() } catch(_) {} IslandSnapshotBridge.instance = null - IslandLayout.instance = null + IslandLayout.instance = null + SpeciesPalette.instance = null Game.instance = null } } diff --git a/src/engine/student-space/Game/State/Persistence.js b/src/engine/student-space/Game/State/Persistence.js index 57fdc78..c6d9294 100644 --- a/src/engine/student-space/Game/State/Persistence.js +++ b/src/engine/student-space/Game/State/Persistence.js @@ -43,9 +43,10 @@ const KEY = { choices: `${NS}:choices`, identityStatusOverride: `${NS}:identityStatusOverride`, islandLayout: `${NS}:islandLayout`, + speciesPalette: `${NS}:speciesPalette`, } -const SLICES = ['moodPins', 'captures', 'profile', 'letters', 'calendar', 'onboarding', 'sprouts', 'relationships', 'choices', 'identityStatusOverride', 'islandLayout'] +const SLICES = ['moodPins', 'captures', 'profile', 'letters', 'calendar', 'onboarding', 'sprouts', 'relationships', 'choices', 'identityStatusOverride', 'islandLayout', 'speciesPalette'] const DEBOUNCE_MS = 250 /** @@ -232,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, islandLayout: 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.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 871a616..6634c54 100644 --- a/src/engine/student-space/Game/State/State.js +++ b/src/engine/student-space/Game/State/State.js @@ -21,6 +21,7 @@ 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 { @@ -82,7 +83,8 @@ export default class State this.coldStart = new ColdStart() this.sun = new Sun() this.island = new Island() - this.islandLayout = new IslandLayout() + this.islandLayout = new IslandLayout() + this.speciesPalette = new SpeciesPalette() this.moodPins = new MoodPins() this.captures = new Captures() this.profile = new Profile() @@ -110,6 +112,7 @@ export default class State 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 573a9a1..7ea2045 100644 --- a/src/engine/student-space/Game/State/schema.js +++ b/src/engine/student-space/Game/State/schema.js @@ -890,3 +890,61 @@ export function mergeIslandLayout(raw) 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/Fruits.js b/src/engine/student-space/Game/View/Fruits.js index a7daa1f..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 diff --git a/src/engine/student-space/Game/View/Tree.js b/src/engine/student-space/Game/View/Tree.js index 3539237..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() diff --git a/test/engine/SpeciesPalette.test.ts b/test/engine/SpeciesPalette.test.ts new file mode 100644 index 0000000..2632bd3 --- /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) => 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) => 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) + }) +}) From adbb31a8745dfada4b5a3c84c7714968b9fb5363 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 16:02:33 +0800 Subject: [PATCH 06/13] fix(types): add SpeciesPalette.d.ts, speciesPalette.d.ts; fix implicit-any in tests --- .../Game/Data/speciesPalette.d.ts | 26 +++++++++++++++++++ .../Game/State/SpeciesPalette.d.ts | 21 +++++++++++++++ test/engine/SpeciesPalette.test.ts | 4 +-- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/engine/student-space/Game/Data/speciesPalette.d.ts create mode 100644 src/engine/student-space/Game/State/SpeciesPalette.d.ts 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/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/test/engine/SpeciesPalette.test.ts b/test/engine/SpeciesPalette.test.ts index 2632bd3..626cb27 100644 --- a/test/engine/SpeciesPalette.test.ts +++ b/test/engine/SpeciesPalette.test.ts @@ -72,7 +72,7 @@ describe('SpeciesPalette slice', () => { it('setColor fires paletteChanged', () => { const pal = freshPalette() const events: unknown[] = [] - pal.subscribe((e) => events.push(e)) + 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') @@ -99,7 +99,7 @@ describe('SpeciesPalette slice', () => { const pal = freshPalette() pal.setColor('fruit', 'plum', { color: '#AABBCC' }) const events: { type: string }[] = [] - pal.subscribe((e) => events.push(e as { type: string })) + pal.subscribe((e: unknown) => events.push(e as { type: string })) pal.revertToDefault() expect(events.some((e) => e.type === 'paletteReplaced')).toBe(true) }) From 822ab77aa72529c9ea31a29eb3be09c041ce813e Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 16:08:52 +0800 Subject: [PATCH 07/13] =?UTF-8?q?docs:=20add=20island-editor=20initiative?= =?UTF-8?q?=20plans=20(000=E2=80=93005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-12-asset-provenance-audit.md | 129 +++++++++++ ...-000-feat-island-editor-engine-overview.md | 196 ++++++++++++++++ ...-001-feat-island-layout-data-model-plan.md | 218 ++++++++++++++++++ ...-island-editor-selection-transform-plan.md | 188 +++++++++++++++ ...at-island-editor-authoring-surface-plan.md | 191 +++++++++++++++ ...and-layout-export-default-pipeline-plan.md | 174 ++++++++++++++ ...15-005-feat-island-species-palette-plan.md | 172 ++++++++++++++ 7 files changed, 1268 insertions(+) create mode 100644 docs/plans/2026-06-12-asset-provenance-audit.md create mode 100644 docs/plans/2026-06-15-000-feat-island-editor-engine-overview.md create mode 100644 docs/plans/2026-06-15-001-feat-island-layout-data-model-plan.md create mode 100644 docs/plans/2026-06-15-002-feat-island-editor-selection-transform-plan.md create mode 100644 docs/plans/2026-06-15-003-feat-island-editor-authoring-surface-plan.md create mode 100644 docs/plans/2026-06-15-004-feat-island-layout-export-default-pipeline-plan.md create mode 100644 docs/plans/2026-06-15-005-feat-island-species-palette-plan.md 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). From 50b807119b0c7bdd5a45fefd6d140ec20ac39cf8 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 20:14:34 +0800 Subject: [PATCH 08/13] feat(island-editor): greenfield Spline-like island shape editor (P0-P2) Standalone, isolated workspace package (own three/r3f/drei + separate pnpm root) that does not touch the product app or its pinned three@0.149. Editable terrain: closed 2D coastline spline (point-in-polygon + distance-to-coast), analytic height profile, additive sculpt relief; seeded from today's island silhouette. Draggable coastline handles, hover-reveal tool panel, sculpt brush (raise/lower/smooth/flatten) with a cached base-field for fast strokes. Pure terrain/brush math unit-tested (9 tests); typecheck + build green. --- island-editor/.gitignore | 4 + island-editor/index.html | 22 + island-editor/package.json | 35 + island-editor/pnpm-lock.yaml | 2670 +++++++++++++++++ island-editor/pnpm-workspace.yaml | 7 + island-editor/src/App.tsx | 87 + island-editor/src/main.tsx | 9 + island-editor/src/scene/Backdrop.tsx | 22 + island-editor/src/scene/CoastlineHandles.tsx | 94 + island-editor/src/scene/Sea.tsx | 8 + island-editor/src/scene/Terrain.tsx | 71 + island-editor/src/terrain/brush.ts | 97 + .../src/terrain/buildTerrainGeometry.ts | 126 + island-editor/src/terrain/islandSpec.ts | 206 ++ island-editor/src/ui/ToolPanel.tsx | 101 + island-editor/src/ui/panel.css | 98 + island-editor/test/brush.test.ts | 47 + island-editor/test/terrain.test.ts | 47 + island-editor/tsconfig.json | 22 + island-editor/vite.config.ts | 14 + 20 files changed, 3787 insertions(+) create mode 100644 island-editor/.gitignore create mode 100644 island-editor/index.html create mode 100644 island-editor/package.json create mode 100644 island-editor/pnpm-lock.yaml create mode 100644 island-editor/pnpm-workspace.yaml create mode 100644 island-editor/src/App.tsx create mode 100644 island-editor/src/main.tsx create mode 100644 island-editor/src/scene/Backdrop.tsx create mode 100644 island-editor/src/scene/CoastlineHandles.tsx create mode 100644 island-editor/src/scene/Sea.tsx create mode 100644 island-editor/src/scene/Terrain.tsx create mode 100644 island-editor/src/terrain/brush.ts create mode 100644 island-editor/src/terrain/buildTerrainGeometry.ts create mode 100644 island-editor/src/terrain/islandSpec.ts create mode 100644 island-editor/src/ui/ToolPanel.tsx create mode 100644 island-editor/src/ui/panel.css create mode 100644 island-editor/test/brush.test.ts create mode 100644 island-editor/test/terrain.test.ts create mode 100644 island-editor/tsconfig.json create mode 100644 island-editor/vite.config.ts 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..7bbf2fa --- /dev/null +++ b/island-editor/src/App.tsx @@ -0,0 +1,87 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { OrbitControls } from '@react-three/drei' +import { Canvas } from '@react-three/fiber' +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 SEED = seedFromCurrentIsland() + +export function App() { + const [mode, setMode] = useState('shape') + const [coastline, setCoastline] = useState(SEED.coastline) + const [profile, setProfile] = useState(SEED.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(SEED.relief) + const [reliefTick, setReliefTick] = useState(0) + const brushRef = useRef(brush) + brushRef.current = brush + + const spec: IslandSpec = useMemo( + () => ({ + version: 1, + worldSize: SEED.worldSize, + coastline, + heightProfile: profile, + relief: { resolution: reliefRef.current.resolution, data: reliefRef.current.data }, + }), + [coastline, profile, reliefTick], + ) + + const movePoint = useCallback((index: number, next: Vec2) => { + setCoastline((pts) => pts.map((p, i) => (i === index ? next : p))) + }, []) + + const paint = useCallback((x: number, z: number) => { + applyBrush(reliefRef.current, SEED.worldSize, x, z, brushRef.current) + setReliefTick((t) => t + 1) + }, []) + + return ( +
+ + + + setOrbitEnabled(false)} + onPaint={paint} + onPaintEnd={() => setOrbitEnabled(true)} + /> + {mode === 'shape' && ( + setOrbitEnabled(!d)} + /> + )} + + + +
+ ) +} 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..80803da --- /dev/null +++ b/island-editor/src/ui/ToolPanel.tsx @@ -0,0 +1,101 @@ +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 +} + +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 }: 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.
+ + )} +
+ ) +} diff --git a/island-editor/src/ui/panel.css b/island-editor/src/ui/panel.css new file mode 100644 index 0000000..ea5c3d6 --- /dev/null +++ b/island-editor/src/ui/panel.css @@ -0,0 +1,98 @@ +.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__tabs { + display: flex; + gap: 6px; + margin-bottom: 10px; +} +.tool-panel__tabs button { + flex: 1; +} +.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; +} 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/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'], + }, +}) From b82a99375f8bc2e85aa20cb1f087d46a4274f4e3 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 20:19:38 +0800 Subject: [PATCH 09/13] feat(island-editor): undo/redo command stack --- island-editor/src/editor/commandStack.ts | 74 ++++++++++ island-editor/test/commandStack.test.ts | 163 +++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 island-editor/src/editor/commandStack.ts create mode 100644 island-editor/test/commandStack.test.ts 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/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) + }) + }) +}) From 7f8045fc5bd6514e54911edd266a6a40f6501071 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 20:19:53 +0800 Subject: [PATCH 10/13] feat(island-editor): localStorage persistence for the island spec --- island-editor/src/editor/persistence.ts | 82 +++++++++++++++++ island-editor/test/persistence.test.ts | 115 ++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 island-editor/src/editor/persistence.ts create mode 100644 island-editor/test/persistence.test.ts 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/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() + }) +}) From 73750b00f7bea958a078de1bb5987342c505ff83 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 20:20:45 +0800 Subject: [PATCH 11/13] feat(island-editor): JSON export/import of the island spec --- island-editor/src/editor/exportSpec.ts | 131 +++++++++++++++++++++++++ island-editor/test/exportSpec.test.ts | 96 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 island-editor/src/editor/exportSpec.ts create mode 100644 island-editor/test/exportSpec.test.ts 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/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') + }) + }) +}) From 0c660d7e1e25927462d1071617630becf8d844a3 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Mon, 15 Jun 2026 20:30:03 +0800 Subject: [PATCH 12/13] feat(island-editor): wire undo/redo, autosave, export/import, reset + polish (P3 integration + P4) --- island-editor/src/App.tsx | 197 +++++++++++++++++++++++++++-- island-editor/src/ui/ToolPanel.tsx | 65 ++++++++-- island-editor/src/ui/panel.css | 30 ++++- 3 files changed, 272 insertions(+), 20 deletions(-) diff --git a/island-editor/src/App.tsx b/island-editor/src/App.tsx index 7bbf2fa..d55aa69 100644 --- a/island-editor/src/App.tsx +++ b/island-editor/src/App.tsx @@ -1,6 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +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' @@ -15,27 +19,40 @@ import { } from './terrain/islandSpec' import { type EditMode, ToolPanel } from './ui/ToolPanel' -const SEED = seedFromCurrentIsland() +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(SEED.coastline) - const [profile, setProfile] = useState(SEED.heightProfile) + 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(SEED.relief) + 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: SEED.worldSize, + worldSize: INITIAL.worldSize, coastline, heightProfile: profile, relief: { resolution: reliefRef.current.resolution, data: reliefRef.current.data }, @@ -43,14 +60,157 @@ export function App() { [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, SEED.worldSize, x, z, brushRef.current) + 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 (
@@ -60,20 +220,27 @@ export function App() { setOrbitEnabled(false)} + onPaintStart={onPaintStart} onPaint={paint} - onPaintEnd={() => setOrbitEnabled(true)} + onPaintEnd={onPaintEnd} /> {mode === 'shape' && ( setOrbitEnabled(!d)} + onDragChange={onDragChange} /> )} - + +
) diff --git a/island-editor/src/ui/ToolPanel.tsx b/island-editor/src/ui/ToolPanel.tsx index 80803da..9db77eb 100644 --- a/island-editor/src/ui/ToolPanel.tsx +++ b/island-editor/src/ui/ToolPanel.tsx @@ -11,6 +11,14 @@ interface ToolPanelProps { 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 }[] = [ @@ -22,17 +30,42 @@ const PROFILE_FIELDS: { key: keyof HeightProfile; label: string; min: number; ma ] const BRUSH_MODES: BrushMode[] = ['raise', 'lower', 'smooth', 'flatten'] -export function ToolPanel({ mode, onModeChange, profile, onProfileChange, brush, onBrushChange }: ToolPanelProps) { +export function ToolPanel({ + mode, + onModeChange, + profile, + onProfileChange, + brush, + onBrushChange, + canUndo, + canRedo, + onUndo, + onRedo, + onReset, + onExport, + onImport, + onTopView, +}: ToolPanelProps) { return (
Island editor
-
- - +
+
+ + +
+
+ + +
{mode === 'shape' ? ( @@ -96,6 +129,22 @@ export function ToolPanel({ mode, onModeChange, profile, onProfileChange, brush,
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 index ea5c3d6..6e7ae47 100644 --- a/island-editor/src/ui/panel.css +++ b/island-editor/src/ui/panel.css @@ -27,14 +27,37 @@ letter-spacing: 0.02em; } -.tool-panel__tabs { +.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); @@ -96,3 +119,8 @@ margin-top: 10px; line-height: 1.35; } +.tool-panel__actions { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} From ca9ca9fe8f7ed4899ed130e61cf857788a329e71 Mon Sep 17 00:00:00 2001 From: Reza Ilmi Date: Tue, 16 Jun 2026 15:03:03 +0800 Subject: [PATCH 13/13] refactor(island-editor): remove in-app island editor surface The dedicated standalone designer (island-editor/, r3f + drei) replaces the in-product authoring overlay. Remove the editor surface and its dev-only engine wiring while keeping the load-bearing data model (islandLayout + speciesPalette slices and default*.json) that now drives live world rendering. - delete IslandEditorPanel + its EngineHost mount/import - delete Game/View/edit/ (EditController, editableViews, CommandStack, Selection) - unwire editController from View.js construction + dispose loop - delete editor-only tests (selection / spawn / transform) pnpm check passes; the 11 remaining test failures are pre-existing on main and unrelated to this change. --- src/components/student-space/EngineHost.tsx | 2 - .../editor/IslandEditorPanel.tsx | 717 ------------------ src/engine/student-space/Game/View/View.js | 15 - .../Game/View/edit/CommandStack.d.ts | 21 - .../Game/View/edit/CommandStack.js | 77 -- .../Game/View/edit/EditController.d.ts | 31 - .../Game/View/edit/EditController.js | 644 ---------------- .../Game/View/edit/Selection.d.ts | 23 - .../student-space/Game/View/edit/Selection.js | 158 ---- .../Game/View/edit/editableViews.d.ts | 49 -- .../Game/View/edit/editableViews.js | 280 ------- test/engine/IslandEditor.selection.test.ts | 301 -------- test/engine/IslandEditor.spawn.test.ts | 193 ----- test/engine/IslandEditor.transform.test.ts | 296 -------- 14 files changed, 2807 deletions(-) delete mode 100644 src/components/student-space/editor/IslandEditorPanel.tsx delete mode 100644 src/engine/student-space/Game/View/edit/CommandStack.d.ts delete mode 100644 src/engine/student-space/Game/View/edit/CommandStack.js delete mode 100644 src/engine/student-space/Game/View/edit/EditController.d.ts delete mode 100644 src/engine/student-space/Game/View/edit/EditController.js delete mode 100644 src/engine/student-space/Game/View/edit/Selection.d.ts delete mode 100644 src/engine/student-space/Game/View/edit/Selection.js delete mode 100644 src/engine/student-space/Game/View/edit/editableViews.d.ts delete mode 100644 src/engine/student-space/Game/View/edit/editableViews.js delete mode 100644 test/engine/IslandEditor.selection.test.ts delete mode 100644 test/engine/IslandEditor.spawn.test.ts delete mode 100644 test/engine/IslandEditor.transform.test.ts diff --git a/src/components/student-space/EngineHost.tsx b/src/components/student-space/EngineHost.tsx index 93d02ca..fe1c458 100644 --- a/src/components/student-space/EngineHost.tsx +++ b/src/components/student-space/EngineHost.tsx @@ -19,7 +19,6 @@ import { cn } from '~/lib/utils' import { AskSheet } from './capture/AskSheet' import { CaptureChooser } from './capture/CaptureChooser' import { MoodSheet } from './capture/MoodSheet' -import { IslandEditorPanel } from './editor/IslandEditorPanel' import { MobileNav } from './navigation/MobileNav' import { SideRail } from './navigation/SideRail' import { CameraTuneHud, type CameraTuneTargets } from './onboarding/CameraTuneHud' @@ -318,7 +317,6 @@ export function EngineHost({ {showOnboardingFlow ? : null} {import.meta.env.DEV && game ? : null} - {import.meta.env.DEV && game ? : null} {game ? : null} {children} diff --git a/src/components/student-space/editor/IslandEditorPanel.tsx b/src/components/student-space/editor/IslandEditorPanel.tsx deleted file mode 100644 index 4a08cfa..0000000 --- a/src/components/student-space/editor/IslandEditorPanel.tsx +++ /dev/null @@ -1,717 +0,0 @@ -/** - * IslandEditorPanel — dev-only island authoring surface (plan 003). - * - * Mounts only under `import.meta.env.DEV` + `location.hash` includes "editor". - * Never shipped to production. - * - * Sections: - * 1. Add palette: kind + species selectors + "Add" button - * 2. Inspector: x/z/yaw/scale number inputs, species select, locked toggle, Delete - * 3. Undo / Redo buttons - * 4. Diverged badge + Revert to default - * 5. Preview toggle (bare / populated) - * - * Engine access: game is cast to an internal shape to reach state.islandLayout, - * view.editController, view.tree/flowers/fruits — same pattern as CameraTuneBridge - * and MatureIslandBridge in EngineHost.tsx. - */ - -import { useEffect, useState } from 'react' -import type { Game } from '~/engine/student-space/Game' -import { useEngineSliceVersion } from '~/lib/student-space/use-engine-slice-version' - -// ── Known species per kind ──────────────────────────────────────────────────── - -const TREE_SPECIES = ['oak', 'cherry'] as const -const FLOWER_SPECIES = ['daisy', 'tulip', 'rose', 'lily', 'pansy', 'hyacinth'] as const -const FRUIT_SPECIES = ['plum', 'fig', 'citrus', 'berry', 'apple', 'pear'] as const - -type Kind = 'tree' | 'flower' | 'fruit' -type Species = string - -const SPECIES_BY_KIND: Record = { - tree: TREE_SPECIES, - flower: FLOWER_SPECIES, - fruit: FRUIT_SPECIES, -} - -// ── Internal engine shape (cast target) ────────────────────────────────────── - -interface PlacedObject { - id: string - kind: string - species?: string - x: number - z: number - yaw?: number - scale?: number - locked?: boolean -} - -interface IslandLayoutSlice { - subscribe(cb: (event: unknown) => void): () => void - list(): PlacedObject[] - listByKind(kind: string): PlacedObject[] - get(id: string): PlacedObject | undefined - addObject(obj: Partial): void - removeObject(id: string): void - updateObject(id: string, patch: Partial): void - isDiverged(): boolean - revertToDefault(): void - serialize(): { v: number; objects: PlacedObject[] } - setLayout(snapshot: unknown): void -} - -interface CommandStack { - push(cmd: { do: () => void; undo: () => void }): void - undo(): void - redo(): void - undoCount: number - redoCount: number -} - -interface EditController { - activate(): void - deactivate(): void - applyTransform( - id: string, - patch: { x?: number; z?: number; yaw?: number; scale?: number }, - ): boolean - selection: { - get(): string | null - onChange(cb: (id: string | null) => void): () => void - select?(id: string, object3d: unknown): void - } - commandStack: CommandStack -} - -interface SpeciesPaletteSlice { - get(kind: string, species: string): Record | null - setColor(kind: string, species: string, colors: Record): void - list(): { - v: number - tree: Record> - flower: Record> - fruit: Record> - } - isDiverged(): boolean - revertToDefault(): void - serialize(): unknown - setFromSnapshot(raw: unknown): void - subscribe(cb: (event: unknown) => void): () => void -} - -interface InternalGame { - state?: { - islandLayout?: IslandLayoutSlice - speciesPalette?: SpeciesPaletteSlice - island?: { isPlaceable(x: number, z: number): boolean } - } - view?: { - editController?: EditController - tree?: { showAll?: () => void; hideAll?: () => void } - flowers?: { showAll?: () => void; hideAll?: () => void } - fruits?: { showAll?: () => void; hideAll?: () => void } - } -} - -// ── Component ───────────────────────────────────────────────────────────────── - -interface IslandEditorPanelProps { - game: Game -} - -export function IslandEditorPanel({ game }: IslandEditorPanelProps) { - const [hashOk, setHashOk] = useState( - () => typeof window !== 'undefined' && window.location.hash.includes('editor'), - ) - - useEffect(() => { - const check = () => setHashOk(window.location.hash.includes('editor')) - window.addEventListener('hashchange', check) - return () => window.removeEventListener('hashchange', check) - }, []) - - if (!hashOk) return null - return -} - -function PanelInner({ game }: IslandEditorPanelProps) { - const internal = game as unknown as InternalGame - const layout = internal.state?.islandLayout - const palette = internal.state?.speciesPalette - const ctrl = internal.view?.editController - - // Subscribe to layout + palette mutations for re-render. - useEngineSliceVersion(layout ?? null) - useEngineSliceVersion(palette ?? null) - - // Track selected object id. - const [selectedId, setSelectedId] = useState(null) - - useEffect(() => { - if (!ctrl) return - ctrl.activate() - return () => ctrl.deactivate() - }, [ctrl]) - - useEffect(() => { - if (!ctrl) return - return ctrl.selection.onChange((id) => setSelectedId(id)) - }, [ctrl]) - - // ── Add palette state ──────────────────────────────────────────────────── - const [addKind, setAddKind] = useState('flower') - const [addSpecies, setAddSpecies] = useState(FLOWER_SPECIES[0]) - - // Keep addSpecies valid when kind changes. - useEffect(() => { - const opts = SPECIES_BY_KIND[addKind] - if (!opts.includes(addSpecies)) setAddSpecies(opts[0] ?? '') - }, [addKind, addSpecies]) - - // ── Preview state ──────────────────────────────────────────────────────── - const [preview, setPreview] = useState(false) - const view = internal.view - - // ── Derived inspector data ─────────────────────────────────────────────── - const selected = selectedId ? layout?.get(selectedId) : null - - // ── Undo/redo counts (re-render on each layout change already fires) ───── - const undoCount = ctrl?.commandStack.undoCount ?? 0 - const redoCount = ctrl?.commandStack.redoCount ?? 0 - - // ── Export / Import ────────────────────────────────────────────────────── - - function handleExport() { - if (!layout) return - const json = JSON.stringify(layout.serialize(), null, 2) - const blob = new Blob([json], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `island-layout-${Date.now().toString(36)}.json` - a.click() - URL.revokeObjectURL(url) - } - - function handleImport() { - if (!layout) return - const input = document.createElement('input') - input.type = 'file' - input.accept = '.json,application/json' - input.onchange = () => { - const file = input.files?.[0] - if (!file) return - const reader = new FileReader() - reader.onload = (e) => { - try { - const parsed = JSON.parse(e.target?.result as string) - layout.setLayout(parsed) - } catch { - alert('Invalid JSON file') - } - } - reader.readAsText(file) - } - input.click() - } - - function handlePaletteExport() { - if (!palette) return - const json = JSON.stringify(palette.serialize(), null, 2) - const blob = new Blob([json], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `species-palette-${Date.now().toString(36)}.json` - a.click() - URL.revokeObjectURL(url) - } - - function handlePaletteImport() { - if (!palette) return - const input = document.createElement('input') - input.type = 'file' - input.accept = '.json,application/json' - input.onchange = () => { - const file = input.files?.[0] - if (!file) return - const reader = new FileReader() - reader.onload = (e) => { - try { - const parsed = JSON.parse(e.target?.result as string) - palette.setFromSnapshot(parsed) - } catch { - alert('Invalid JSON file') - } - } - reader.readAsText(file) - } - input.click() - } - - // ── Handlers ───────────────────────────────────────────────────────────── - - function handleAdd() { - if (!layout || !ctrl) return - const id = `${addKind}-${Date.now().toString(36)}` - const obj: Partial = { - id, - kind: addKind, - species: addSpecies, - x: 0, - z: 0, - yaw: 0, - scale: 1, - } - const before = { id } - layout.addObject(obj) - ctrl.commandStack.push({ - do: () => layout.addObject({ ...obj }), - undo: () => layout.removeObject(before.id), - }) - // Auto-select. - ctrl.selection.select?.(id, null as never) - } - - function handleDelete() { - if (!layout || !ctrl || !selectedId) return - const snap = layout.get(selectedId) - if (!snap) return - layout.removeObject(selectedId) - ctrl.commandStack.push({ - do: () => layout.removeObject(selectedId), - undo: () => layout.addObject({ ...snap }), - }) - setSelectedId(null) - } - - function handleFieldChange(field: 'x' | 'z' | 'yaw' | 'scale', raw: string) { - if (!ctrl || !selectedId) return - const val = Number.parseFloat(raw) - if (!Number.isFinite(val)) return - ctrl.applyTransform(selectedId, { [field]: val }) - } - - function handleSpeciesChange(species: string) { - if (!layout || !selectedId) return - layout.updateObject(selectedId, { species }) - } - - function handleLockedChange(locked: boolean) { - if (!layout || !selectedId) return - layout.updateObject(selectedId, { locked }) - } - - function handleRevert() { - if (!layout) return - if (!window.confirm('Revert to committed default? All local edits will be lost.')) return - layout.revertToDefault() - } - - function handlePreviewToggle(on: boolean) { - setPreview(on) - if (on) { - view?.tree?.showAll?.() - view?.flowers?.showAll?.() - view?.fruits?.showAll?.() - } else { - view?.tree?.hideAll?.() - view?.flowers?.hideAll?.() - view?.fruits?.hideAll?.() - } - } - - const diverged = layout?.isDiverged() ?? false - - return ( -
-
- - ⬡ ISLAND EDITOR - - - - - -
- - {/* ── Add palette ────────────────────────────────────────────── */} -
-
ADD
-
- {(['tree', 'flower', 'fruit'] as Kind[]).map((k) => ( - - ))} -
-
- {SPECIES_BY_KIND[addKind].map((s) => ( - - ))} -
- -
- -
- - {/* ── Inspector ──────────────────────────────────────────────── */} - {selected ? ( -
-
- INSPECTOR —{' '} - - {selected.kind}:{selected.id.slice(-6)} - -
- {(['x', 'z', 'yaw', 'scale'] as const).map((field) => ( - - ))} - - {/* Species */} - - - {/* Locked */} - - - -
- ) : ( -
- Click an object to select -
- )} - -
- - {/* ── Undo / Redo ────────────────────────────────────────────── */} -
- - -
- - {/* ── Diverged badge + revert ─────────────────────────────────── */} - {diverged && ( -
-
- ⚠ Local edits — differs from committed default -
- -
- )} - - {/* ── Preview toggle ──────────────────────────────────────────── */} -
- -
- -
- - {/* ── Species Palette ─────────────────────────────────────────── */} - {palette && ( -
-
- PALETTE - - - - -
- - - - {palette.isDiverged() && ( - - )} -
- )} -
- ) -} - -// ── Palette color editor ───────────────────────────────────────────────────── - -const PALETTE_KINDS: Array<{ - kind: string - species: string[] - slots: string[] -}> = [ - { kind: 'tree', species: ['oak', 'cherry'], slots: ['colorA', 'colorB'] }, - { - kind: 'flower', - species: ['daisy', 'tulip', 'rose', 'lily', 'pansy', 'hyacinth'], - slots: ['petal', 'centre', 'face'], - }, - { - kind: 'fruit', - species: ['apple', 'pear', 'plum', 'fig', 'citrus', 'berry'], - slots: ['color'], - }, -] - -function PaletteEditor({ palette }: { palette: SpeciesPaletteSlice }) { - const paletteList = palette.list() - - return ( -
- {PALETTE_KINDS.map(({ kind, species, slots }) => ( -
-
- {kind.toUpperCase()} -
- {species.map((sp) => { - const colors = ( - paletteList as unknown as Record>> - )[kind]?.[sp] - if (!colors) return null - return ( -
- {sp} - {slots.map((slot) => { - const val = colors[slot] - if (!val) return null - return ( - - ) - })} -
- ) - })} -
- ))} -
- ) -} - -// ── Style helpers ───────────────────────────────────────────────────────────── - -function btnStyle(active: boolean, disabled = false): React.CSSProperties { - return { - padding: '2px 7px', - borderRadius: '4px', - border: '1px solid rgba(255,255,255,0.15)', - background: active ? 'rgba(99,102,241,0.4)' : 'rgba(255,255,255,0.06)', - color: disabled ? '#475569' : '#e2e8f0', - cursor: disabled ? 'not-allowed' : 'pointer', - fontSize: '11px', - opacity: disabled ? 0.5 : 1, - } -} - -const actionBtnStyle: React.CSSProperties = { - padding: '3px 10px', - borderRadius: '4px', - border: '1px solid rgba(255,255,255,0.15)', - background: 'rgba(99,102,241,0.3)', - color: '#e2e8f0', - cursor: 'pointer', - fontSize: '12px', - width: '100%', -} - -const inputStyle: React.CSSProperties = { - flex: 1, - background: 'rgba(255,255,255,0.06)', - border: '1px solid rgba(255,255,255,0.12)', - borderRadius: '3px', - color: '#e2e8f0', - padding: '2px 5px', - fontSize: '11px', - fontFamily: 'monospace', -} diff --git a/src/engine/student-space/Game/View/View.js b/src/engine/student-space/Game/View/View.js index c682348..325057c 100644 --- a/src/engine/student-space/Game/View/View.js +++ b/src/engine/student-space/Game/View/View.js @@ -22,7 +22,6 @@ import Sound from './Sound.js' import Mailbox from './Mailbox.js' import Telescope from './Telescope.js' import OverlayController from './OverlayController.js' -import EditController from './edit/EditController.js' import State from '../State/State.js' // OnboardingFlow lifecycle moved to React (U16–U19) — see // `src/components/student-space/EngineHost.tsx`. The ceremony surfaces @@ -120,19 +119,6 @@ export default class View // is intentionally NOT re-attached to `view.onboardingFlow` — React // disposes it directly on cleanup, so we avoid a double-dispose // through View.dispose()'s SUBSYSTEMS loop. - - // Island editor (plan 002). Constructed after all view kinds so - // the EditableView adapters can reference the fully-built views. - // NOT activated by default — plan 003's panel calls activate(). - // Exposed on window.__islandEditor in dev for pre-UI console testing. - this.editController = new EditController({ view: this, state: this.state }) - if(typeof import.meta !== 'undefined' && import.meta.env?.DEV) - { - if(typeof window !== 'undefined') - { - window.__islandEditor = { editController: this.editController } - } - } } resize() @@ -219,7 +205,6 @@ export default class View this.mailbox, this.telescope, this.sprouts, - this.editController, ] for(const sub of SUBSYSTEMS) { diff --git a/src/engine/student-space/Game/View/edit/CommandStack.d.ts b/src/engine/student-space/Game/View/edit/CommandStack.d.ts deleted file mode 100644 index 7431541..0000000 --- a/src/engine/student-space/Game/View/edit/CommandStack.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface Command { - do: () => void - undo: () => void -} - -export default class CommandStack { - readonly undoCount: number - readonly redoCount: number - - /** Record a command (caller has already executed the forward action). Clears redo stack. */ - push(cmd: Command): void - - /** Undo the most recent command. No-op if history is empty. */ - undo(): void - - /** Redo the most recently undone command. No-op if redo stack is empty. */ - redo(): void - - /** Clear all history. */ - clear(): void -} diff --git a/src/engine/student-space/Game/View/edit/CommandStack.js b/src/engine/student-space/Game/View/edit/CommandStack.js deleted file mode 100644 index 75dc244..0000000 --- a/src/engine/student-space/Game/View/edit/CommandStack.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * CommandStack — unified undo/redo history for the island editor. - * - * Each entry is a plain `{ do, undo }` object. Both are zero-argument - * functions; the caller is responsible for building closures that capture - * the right state at push-time. - * - * The stack is intentionally simple: it does NOT call `cmd.do()` on push — - * the caller has already executed the forward action. It only calls - * `cmd.undo()` / re-calls `cmd.do()` on undo/redo. - * - * Cap: the undo history is capped at MAX_ENTRIES (100). When the cap is - * reached, the oldest entry is silently dropped. - */ - -const MAX_ENTRIES = 100 - -export default class CommandStack -{ - constructor() - { - /** @type {Array<{do: () => void, undo: () => void}>} */ - this._stack = [] - /** @type {Array<{do: () => void, undo: () => void}>} */ - this._redo = [] - } - - /** - * Push a command onto the history. The caller has already executed the - * forward action; this records it for undo. Clears the redo stack. - * - * @param {{ do: () => void, undo: () => void }} cmd - */ - push(cmd) - { - if(!cmd || typeof cmd.do !== 'function' || typeof cmd.undo !== 'function') return - this._redo = [] - this._stack.push(cmd) - if(this._stack.length > MAX_ENTRIES) - this._stack.shift() - } - - /** - * Undo the most recent command. No-op if the history is empty. - */ - undo() - { - const cmd = this._stack.pop() - if(!cmd) return - try { cmd.undo() } catch(err) { console.warn('[CommandStack] undo threw', err) } - this._redo.push(cmd) - } - - /** - * Redo the most recently undone command. No-op if the redo stack is empty. - */ - redo() - { - const cmd = this._redo.pop() - if(!cmd) return - try { cmd.do() } catch(err) { console.warn('[CommandStack] redo threw', err) } - this._stack.push(cmd) - } - - /** Number of commands available to undo. */ - get undoCount() { return this._stack.length } - - /** Number of commands available to redo. */ - get redoCount() { return this._redo.length } - - /** Clear all history (e.g. on deactivate). */ - clear() - { - this._stack = [] - this._redo = [] - } -} diff --git a/src/engine/student-space/Game/View/edit/EditController.d.ts b/src/engine/student-space/Game/View/edit/EditController.d.ts deleted file mode 100644 index 5110353..0000000 --- a/src/engine/student-space/Game/View/edit/EditController.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TransformPatch, EditableViews } from './editableViews' -import type Selection from './Selection' -import type CommandStack from './CommandStack' - -export interface EditControllerParams { - view: object - state: object -} - -export default class EditController { - readonly editableViews: EditableViews - readonly selection: Selection - readonly commandStack: CommandStack - - constructor(params: EditControllerParams) - - /** Add canvas pointer listener. Called by the 003 panel. */ - activate(): void - - /** Remove canvas pointer listener. Cancels in-flight drag. */ - deactivate(): void - - /** Called from View.dispose(). */ - dispose(): void - - /** - * Apply a partial transform to an object. - * Returns false if rejected (not placeable, unknown id). - */ - applyTransform(id: string, patch: TransformPatch): boolean -} diff --git a/src/engine/student-space/Game/View/edit/EditController.js b/src/engine/student-space/Game/View/edit/EditController.js deleted file mode 100644 index fc247cb..0000000 --- a/src/engine/student-space/Game/View/edit/EditController.js +++ /dev/null @@ -1,644 +0,0 @@ -/** - * EditController — engine core of the island editor (plan 002). - * - * Responsibilities: - * 1. activate()/deactivate() — adds/removes canvas pointer listeners. - * 2. Raycast pick — on pointerdown over a recognised object, call - * selection.select(id). Clicking empty space deselects. - * 3. applyTransform(id, patch) — the API the 003 inspector calls. - * Validates bounds, applies via the adapter, pushes a CommandStack - * entry, and commits to state.islandLayout. - * 4. Coarse-move drag — pointer-drag the selected object across the - * plateau (ground-plane projection, same pattern as Sprouts.js). - * Suppresses camera.controls during drag; commits on release inside - * bounds, snaps back on out-of-bounds release. - * 5. Subscribe to objectUpdated — keeps mesh in sync when layout changes - * from the outside (undo, inspector). - * - * NOT active by default. Plan 003 calls activate(). Exposed via - * window.__islandEditor in dev for pre-UI testing (see View.js). - * - * No 3D gizmo — transforms are numeric (003 inspector) + ground-plane - * drag for coarse move. This makes the core unit-testable without WebGL. - */ - -import * as THREE from 'three' -import { buildEditableViews } from './editableViews.js' -import Selection from './Selection.js' -import CommandStack from './CommandStack.js' - -// Drag lift while held — slight visual separation from terrain. -const DRAG_LIFT = 0.15 - -export default class EditController -{ - /** - * @param {{ - * view: import('../View.js').default, - * state: import('../../State/State.js').default, - * }} params - */ - constructor({ view, state }) - { - this._view = view - this._state = state - - this._island = state.island - this._camera = view.camera - this._scene = view.scene - - this.editableViews = buildEditableViews(view, this._island, state.islandLayout) - this.selection = new Selection(this._scene) - this.commandStack = new CommandStack() - - this._active = false - - // Raycast helpers (reused per event) - this._raycaster = new THREE.Raycaster() - this._pointer = new THREE.Vector2() - - // Drag state — non-null while a drag is in-flight. - // @type {{ id: string, kind: string, group: THREE.Object3D, - // originPos: THREE.Vector3, originScale: number, - // originYaw: number, liftHeight: number, - // valid: boolean, pointerId: number } | null} - this._drag = null - this._dragGroundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0) - - // Bound event handlers - this._onPointerDown = (e) => this._handlePointerDown(e) - this._onPointerMove = (e) => this._handlePointerMove(e) - this._onPointerUp = (e) => this._handlePointerUp(e) - - this._canvasEl = view.renderer?.instance?.domElement ?? null - - // Subscribe to layout mutations so meshes stay in sync when layout - // changes come from undo, the inspector, or external callers. - this._unsubLayout = this._state.islandLayout?.subscribe((event) => - { - if(event.type === 'objectUpdated') - { - this._syncMesh(event.object) - return - } - - // Structural events — reconcile the appropriate view kind. - if(event.type === 'objectAdded' || event.type === 'objectRemoved' || event.type === 'layoutReplaced') - { - this._reconcileAfterStructural(event) - } - }) - } - - // ── Lifecycle ───────────────────────────────────────────────────────────── - - /** - * Activate the editor. Called by the 003 panel when #editor is open. - * Adds the canvas pointer-down listener. - */ - activate() - { - if(this._active) return - this._active = true - if(this._canvasEl) - { - this._canvasEl.addEventListener('pointerdown', this._onPointerDown) - } - } - - /** - * Deactivate the editor. Removes listeners, cancels any in-flight drag, - * restores camera.controls. - */ - deactivate() - { - if(!this._active) return - this._active = false - if(this._drag) this._cancelDrag() - if(this._canvasEl) - { - this._canvasEl.removeEventListener('pointerdown', this._onPointerDown) - } - this._restoreControls() - } - - /** - * Dispose everything. Called from View.dispose(). - * Always restores camera.controls.enabled regardless of active state. - */ - dispose() - { - if(this._drag) this._cancelDrag() - if(this._canvasEl) - { - this._canvasEl.removeEventListener('pointerdown', this._onPointerDown) - this._canvasEl.removeEventListener('pointermove', this._onPointerMove) - this._canvasEl.removeEventListener('pointerup', this._onPointerUp) - } - this._active = false - // Always restore controls — dispose may be called with controls stuck - // false from a drag or external manipulation. - this._restoreControls() - if(this._unsubLayout) - { - try { this._unsubLayout() } catch(_) {} - this._unsubLayout = null - } - this.selection.dispose() - this.commandStack.clear() - } - - // ── Public API ──────────────────────────────────────────────────────────── - - /** - * Apply a partial transform `{ x?, z?, yaw?, scale? }` to the object - * with the given layout id. - * - * - Validates XZ against isPlaceable (if x or z are changing). - * - Applies the mesh transform via the adapter. - * - Pushes an undo-able command onto the command stack. - * - Commits to state.islandLayout.updateObject (y always derived). - * - * @param {string} id - * @param {{ x?: number, z?: number, yaw?: number, scale?: number }} patch - * @returns {boolean} false if rejected (not placeable, unknown id, etc.) - */ - applyTransform(id, patch) - { - if(typeof id !== 'string' || !patch || typeof patch !== 'object') return false - - const layout = this._state.islandLayout - const current = layout?.get(id) - if(!current) return false - - const kind = current.kind - const adapter = this.editableViews[kind] - if(!adapter) return false - - // Compute new XZ (patch may be partial). - const newX = typeof patch.x === 'number' ? patch.x : current.x - const newZ = typeof patch.z === 'number' ? patch.z : current.z - - // Placeable check only when position is changing. - const posChanging = typeof patch.x === 'number' || typeof patch.z === 'number' - if(posChanging && !this._island.isPlaceable(newX, newZ)) return false - - // Snapshot before state for undo. - const before = { - x: current.x, - z: current.z, - yaw: current.yaw, - scale: current.scale, - } - const after = { - x: newX, - z: newZ, - yaw: typeof patch.yaw === 'number' ? patch.yaw : current.yaw, - scale: typeof patch.scale === 'number' ? patch.scale : current.scale, - } - - // Apply live to mesh. - adapter.applyTransform(id, after) - - // Update highlight if this is the selected object. - if(this.selection.get() === id) - { - const obj3d = adapter.getObject3D(id) - if(obj3d) this.selection.update(obj3d) - } - - // Commit to layout slice (y omitted — derived by heightAt in the view). - layout.updateObject(id, after) - - // Push undo entry. - this.commandStack.push({ - do: () => { adapter.applyTransform(id, after); layout.updateObject(id, after) }, - undo: () => { adapter.applyTransform(id, before); layout.updateObject(id, before) }, - }) - - return true - } - - // ── Private: pick ───────────────────────────────────────────────────────── - - _handlePointerDown(e) - { - if(!this._active) return - if(e.button !== 0) return // left-button only - - const camera = this._camera?.instance - if(!camera || !this._canvasEl) return - - const rect = this._canvasEl.getBoundingClientRect() - this._pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1 - this._pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1 - this._raycaster.setFromCamera(this._pointer, camera) - - // Collect all hit targets across all kinds. - const targets = [] - for(const adapter of Object.values(this.editableViews)) - { - for(const t of adapter.hitTargets()) targets.push(t) - } - - const intersects = this._raycaster.intersectObjects(targets, true) - const hit = intersects[0] - - if(!hit) - { - this.selection.deselect() - return - } - - // Walk up to find which adapter group was hit and resolve its layout id. - const resolved = this._resolveHit(hit.object) - if(!resolved) - { - this.selection.deselect() - return - } - - const { id, kind } = resolved - const adapter = this.editableViews[kind] - const obj3d = adapter?.getObject3D(id) - - this.selection.select(id, obj3d) - - // Start coarse-move drag. - this._startDrag(e, id, kind, obj3d) - - e.preventDefault?.() - } - - // ── Private: drag ───────────────────────────────────────────────────────── - - _startDrag(e, id, kind, group) - { - if(!group) return - const layout = this._state.islandLayout - const current = layout?.get(id) - if(!current) return - - const originX = group.position.x - const originZ = group.position.z - const originY = group.position.y - const originYaw = group.rotation.y - const originScale = group.scale.x - - const pickupGroundY = this._island.heightAt(originX, originZ) - const liftHeight = pickupGroundY + DRAG_LIFT - - // Set the ground plane constant so the cursor projects correctly. - this._dragGroundPlane.constant = -liftHeight - - this._drag = { - id, - kind, - group, - originPos: new THREE.Vector3(originX, originY, originZ), - originYaw, - originScale, - liftHeight, - valid: true, - pointerId: e.pointerId, - // snapshot for undo - before: { x: current.x, z: current.z, yaw: current.yaw, scale: current.scale }, - } - - // Lift the mesh visually. - group.position.y = liftHeight - - if(this._camera?.controls) this._camera.controls.enabled = false - - if(this._canvasEl) - { - try { this._canvasEl.setPointerCapture?.(e.pointerId) } catch(_) {} - this._canvasEl.addEventListener('pointermove', this._onPointerMove) - this._canvasEl.addEventListener('pointerup', this._onPointerUp) - } - } - - _handlePointerMove(e) - { - const drag = this._drag - if(!drag) return - - const camera = this._camera?.instance - if(!camera || !this._canvasEl) return - - const rect = this._canvasEl.getBoundingClientRect() - this._pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1 - this._pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1 - this._raycaster.setFromCamera(this._pointer, camera) - - const hit = new THREE.Vector3() - const intersected = this._raycaster.ray.intersectPlane(this._dragGroundPlane, hit) - if(!intersected) return - - const x = hit.x - const z = hit.z - - // Route through the kind's adapter live-move (keeps InstancedMesh - // leaf clouds in sync for trees, etc.). - const adapter = this.editableViews[drag.kind] - if(adapter) - { - adapter.applyTransform(drag.id, { x, z, yaw: drag.group.rotation.y }) - drag.group.position.y = drag.liftHeight - } - else - { - drag.group.position.set(x, drag.liftHeight, z) - } - - drag.valid = this._island.isPlaceable(x, z) - - // Update highlight ring to follow. - if(this.selection.get() === drag.id) this.selection.update(drag.group) - } - - _handlePointerUp(e) - { - this._finishDrag(e) - } - - _finishDrag(e) - { - const drag = this._drag - if(!drag) return - this._drag = null - - if(this._canvasEl) - { - try { this._canvasEl.releasePointerCapture?.(e?.pointerId ?? drag.pointerId) } catch(_) {} - this._canvasEl.removeEventListener('pointermove', this._onPointerMove) - this._canvasEl.removeEventListener('pointerup', this._onPointerUp) - } - - this._restoreControls() - - const adapter = this.editableViews[drag.kind] - - if(drag.valid) - { - const finalX = drag.group.position.x - const finalZ = drag.group.position.z - - // Snap to ground (remove the lift). - if(adapter) adapter.applyTransform(drag.id, { x: finalX, z: finalZ }) - else drag.group.position.set(finalX, this._island.heightAt(finalX, finalZ), finalZ) - - // Commit to layout (y not stored — always heightAt). - const after = { x: finalX, z: finalZ, yaw: drag.group.rotation.y, scale: drag.group.scale.x } - const before = drag.before - const id = drag.id - const layout = this._state.islandLayout - - layout?.updateObject(id, { x: finalX, z: finalZ }) - - // Push undo entry for the drag. - this.commandStack.push({ - do: () => { adapter?.applyTransform(id, after); layout?.updateObject(id, after) }, - undo: () => { adapter?.applyTransform(id, before); layout?.updateObject(id, before) }, - }) - } - else - { - // Snap back — restore visual without touching the layout. - const { originPos, originYaw, originScale } = drag - if(adapter) - { - adapter.applyTransform(drag.id, { - x: originPos.x, - z: originPos.z, - yaw: originYaw, - scale: originScale, - }) - } - else - { - drag.group.position.copy(originPos) - drag.group.rotation.y = originYaw - drag.group.scale.setScalar(originScale) - } - } - - // Refresh selection highlight. - if(this.selection.get() === drag.id) this.selection.update(drag.group) - } - - _cancelDrag() - { - const drag = this._drag - if(!drag) return - this._drag = null - - if(this._canvasEl) - { - this._canvasEl.removeEventListener('pointermove', this._onPointerMove) - this._canvasEl.removeEventListener('pointerup', this._onPointerUp) - } - - this._restoreControls() - - const adapter = this.editableViews[drag.kind] - const { originPos, originYaw, originScale } = drag - - if(adapter) - { - adapter.applyTransform(drag.id, { - x: originPos.x, - z: originPos.z, - yaw: originYaw, - scale: originScale, - }) - } - else - { - drag.group.position.copy(originPos) - drag.group.rotation.y = originYaw - drag.group.scale.setScalar(originScale) - } - } - - _restoreControls() - { - try - { - if(this._camera?.controls) this._camera.controls.enabled = true - } - catch(_) {} - } - - // ── Private: hit resolution ─────────────────────────────────────────────── - - /** - * Walk up the scene graph from the intersected object to find which - * adapter group it belongs to. Returns `{ id, kind }` or null. - * - * Strategy: for each kind, check if the hit object (or any of its ancestors - * up to adapter.hitTargets()) matches one of the known groups, then resolve - * the layout id by reverse-looking up the adapter record. - * - * @param {THREE.Object3D} hitObject - * @returns {{ id: string, kind: string } | null} - */ - _resolveHit(hitObject) - { - // Tree entries - const tree = this._view.tree - if(tree?.entries) - { - for(const entry of tree.entries) - { - if(entry.group && this._isDescendant(hitObject, entry.group)) - { - if(entry.layoutId) return { id: entry.layoutId, kind: 'tree' } - } - } - } - - // Flower entries - const flowers = this._view.flowers - if(flowers?.flowers) - { - for(const f of flowers.flowers) - { - if(f.group && this._isDescendant(hitObject, f.group)) - { - if(f.layoutId) return { id: f.layoutId, kind: 'flower' } - } - } - } - - // Fruit entries - const fruits = this._view.fruits - if(fruits?.entries) - { - for(const entry of fruits.entries) - { - if(entry.group && this._isDescendant(hitObject, entry.group)) - { - if(entry.layoutId) return { id: entry.layoutId, kind: 'fruit' } - } - } - } - - // Mailbox — singleton; layout id is 'mailbox-0' - const mailbox = this._view.mailbox - if(mailbox?.group && this._isDescendant(hitObject, mailbox.group)) - { - return { id: 'mailbox-0', kind: 'mailbox' } - } - - // Telescope — singleton; layout id is 'telescope-0' - const telescope = this._view.telescope - if(telescope?.group && this._isDescendant(hitObject, telescope.group)) - { - return { id: 'telescope-0', kind: 'telescope' } - } - - return null - } - - /** - * True if `node` is `ancestor` or a descendant of it. - * - * @param {THREE.Object3D} node - * @param {THREE.Object3D} ancestor - */ - _isDescendant(node, ancestor) - { - let cur = node - while(cur) - { - if(cur === ancestor) return true - cur = cur.parent - } - return false - } - - // ── Private: reactive sync ──────────────────────────────────────────────── - - /** - * Called when objectUpdated fires (e.g. from undo or an external - * inspector write). Syncs the mesh to the new layout state. - * - * @param {{ id: string, kind: string, x: number, z: number, yaw: number, scale: number }} obj - */ - _syncMesh(obj) - { - const adapter = this.editableViews[obj.kind] - if(!adapter) return - try - { - adapter.applyTransform(obj.id, { - x: obj.x, - z: obj.z, - yaw: obj.yaw, - scale: obj.scale, - }) - } - catch(err) { console.warn('[EditController] _syncMesh threw', err) } - } - - /** - * Called when objectAdded / objectRemoved / layoutReplaced fires. - * Reconciles the affected view kind(s) via ensureFromLayout. - * - * @param {{ type: string, object?: { kind: string }, kind?: string }} event - */ - _reconcileAfterStructural(event) - { - const layout = this._state.islandLayout - if(!layout) return - - // Determine which kinds to reconcile. - const kindsToReconcile = new Set() - - if(event.type === 'layoutReplaced') - { - // All editable kinds. - for(const k of ['tree', 'flower', 'fruit', 'mailbox', 'telescope']) kindsToReconcile.add(k) - } - else if(event.object?.kind) - { - kindsToReconcile.add(event.object.kind) - } - - for(const kind of kindsToReconcile) - { - const objs = layout.listByKind(kind) - try - { - // Trees and flowers/fruits have ensureFromLayout. - if(kind === 'tree') - { - this._view.tree?.ensureFromLayout?.(objs) - } - else if(kind === 'flower') - { - this._view.flowers?.ensureFromLayout?.(objs) - } - else if(kind === 'fruit') - { - this._view.fruits?.ensureFromLayout?.(objs) - } - else if(kind === 'mailbox') - { - const obj = objs[0] - if(obj) this._view.mailbox?.move?.(obj.x, obj.z) - } - else if(kind === 'telescope') - { - const obj = objs[0] - if(obj) this._view.telescope?.move?.(obj.x, obj.z) - } - } - catch(err) - { - console.warn(`[EditController] reconcile threw for kind=${kind}`, err) - } - } - } -} diff --git a/src/engine/student-space/Game/View/edit/Selection.d.ts b/src/engine/student-space/Game/View/edit/Selection.d.ts deleted file mode 100644 index e43d499..0000000 --- a/src/engine/student-space/Game/View/edit/Selection.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type * as THREE from 'three' - -export default class Selection { - constructor(scene: THREE.Scene) - - /** Currently selected layout id, or null. */ - get(): string | null - - /** Select an object by layout id with its Three.js group for highlighting. */ - select(id: string, object3d: THREE.Object3D): void - - /** Clear the selection and remove highlights. */ - deselect(): void - - /** Update highlight position to track object movement. */ - update(object3d: THREE.Object3D): void - - /** Subscribe to selection changes. Returns unsubscribe fn. */ - onChange(cb: (id: string | null) => void): () => void - - /** Dispose highlights and remove from scene. */ - dispose(): void -} diff --git a/src/engine/student-space/Game/View/edit/Selection.js b/src/engine/student-space/Game/View/edit/Selection.js deleted file mode 100644 index b31bf6b..0000000 --- a/src/engine/student-space/Game/View/edit/Selection.js +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Selection — tracks the currently selected island-layout object and - * renders a lightweight highlight around it. - * - * Highlight strategy: a THREE.BoxHelper wraps the object's bounding box. - * The helper is cheap (one LineSegments draw call) and doesn't require - * WebGL extensions. A ground-ring mesh provides additional affordance at - * foot level. - * - * Change callbacks: a Set of functions called whenever the selection - * changes (deselect also calls them with null). Used by the 003 inspector. - */ - -import * as THREE from 'three' - -const HIGHLIGHT_COLOR = 0x00d4ff // cyan — distinct from any island palette - -export default class Selection -{ - /** - * @param {THREE.Scene} scene - */ - constructor(scene) - { - this._scene = scene - /** @type {string | null} */ - this._id = null - /** @type {THREE.BoxHelper | null} */ - this._helper = null - /** @type {THREE.Mesh | null} */ - this._ring = null - /** @type {Set<(id: string | null) => void>} */ - this._callbacks = new Set() - } - - // ── Public API ────────────────────────────────────────────────────────── - - /** - * Select an object by layout id. Replaces any existing selection. - * - * @param {string} id - * @param {THREE.Object3D} object3d - */ - select(id, object3d) - { - this._disposeHighlight() - this._id = id - - if(object3d) - { - // BoxHelper around the object. - const helper = new THREE.BoxHelper(object3d, HIGHLIGHT_COLOR) - this._scene.add(helper) - this._helper = helper - - // Ground ring at the object's XZ position. - const pos = new THREE.Vector3() - object3d.getWorldPosition(pos) - const ringGeo = new THREE.RingGeometry(0.35, 0.42, 32) - const ringMat = new THREE.MeshBasicMaterial({ - color: HIGHLIGHT_COLOR, - side: THREE.DoubleSide, - opacity: 0.6, - transparent: true, - }) - const ring = new THREE.Mesh(ringGeo, ringMat) - ring.rotation.x = -Math.PI / 2 - ring.position.set(pos.x, pos.y + 0.02, pos.z) - this._scene.add(ring) - this._ring = ring - } - - this._notify() - } - - /** Clear the selection. */ - deselect() - { - if(this._id === null) return - this._id = null - this._disposeHighlight() - this._notify() - } - - /** - * Returns the currently selected layout id, or null. - * @returns {string | null} - */ - get() - { - return this._id - } - - /** - * Update the highlight to track the object's current position - * (called each frame or after a transform). - * - * @param {THREE.Object3D} object3d - */ - update(object3d) - { - if(!object3d) return - if(this._helper) this._helper.update() - if(this._ring) - { - const pos = new THREE.Vector3() - object3d.getWorldPosition(pos) - this._ring.position.set(pos.x, pos.y + 0.02, pos.z) - } - } - - /** - * Subscribe to selection changes. Callback receives the new layout id - * (string) or null on deselect. Returns unsubscribe function. - * - * @param {(id: string | null) => void} cb - * @returns {() => void} - */ - onChange(cb) - { - this._callbacks.add(cb) - return () => this._callbacks.delete(cb) - } - - /** Dispose highlight objects and remove from scene. */ - dispose() - { - this._id = null - this._disposeHighlight() - } - - // ── Private ───────────────────────────────────────────────────────────── - - _disposeHighlight() - { - if(this._helper) - { - try { this._scene?.remove(this._helper) } catch(_) {} - try { this._helper.geometry?.dispose?.() } catch(_) {} - this._helper = null - } - if(this._ring) - { - try { this._scene?.remove(this._ring) } catch(_) {} - try { this._ring.geometry?.dispose?.() } catch(_) {} - try { this._ring.material?.dispose?.() } catch(_) {} - this._ring = null - } - } - - _notify() - { - for(const cb of this._callbacks) - { - try { cb(this._id) } catch(err) { console.warn('[Selection] callback threw', err) } - } - } -} diff --git a/src/engine/student-space/Game/View/edit/editableViews.d.ts b/src/engine/student-space/Game/View/edit/editableViews.d.ts deleted file mode 100644 index 5a0b170..0000000 --- a/src/engine/student-space/Game/View/edit/editableViews.d.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type * as THREE from 'three' - -export interface TransformPatch { - x?: number - z?: number - yaw?: number - scale?: number -} - -export interface PlacedObject { - id: string - kind: string - species?: string - x: number - z: number - yaw: number - scale: number -} - -export interface EditableView { - /** Resolve the THREE.Group for a layout id (null if not found). */ - getObject3D(layoutId: string): THREE.Object3D | null - - /** All raycasting hit targets for this kind. */ - hitTargets(): THREE.Object3D[] - - /** - * Apply a partial transform to the mesh live. - * Does NOT commit to IslandLayout — the caller (EditController) does. - */ - applyTransform(id: string, t: TransformPatch): void - - /** Stub — implemented in plan 003. */ - spawn(obj: PlacedObject): void - - /** Stub — implemented in plan 003. */ - remove(id: string): void -} - -export interface EditableViews { - tree: EditableView - flower: EditableView - fruit: EditableView - mailbox: EditableView - telescope: EditableView -} - -/** Build the per-kind adapter map for an active View instance. */ -export function buildEditableViews(view: object, island: object): EditableViews diff --git a/src/engine/student-space/Game/View/edit/editableViews.js b/src/engine/student-space/Game/View/edit/editableViews.js deleted file mode 100644 index 6e4a1ee..0000000 --- a/src/engine/student-space/Game/View/edit/editableViews.js +++ /dev/null @@ -1,280 +0,0 @@ -/** - * EditableView adapters — per-kind wrappers that let EditController work - * uniformly across the five bespoke view kinds (tree, flower, fruit, - * mailbox, telescope). - * - * Each adapter exposes: - * getObject3D(layoutId) — resolve the THREE.Group for a layout id - * hitTargets() — array of Object3D meshes for raycasting - * applyTransform(id, t) — apply {x?,z?,yaw?,scale?} live (does not - * commit to IslandLayout — caller does that) - * spawn(obj) — stub; see plan 003 - * remove(id) — stub; see plan 003 - * - * `buildEditableViews(view, island)` returns the full map. - */ - -/** - * @param {import('../View.js').default} view - * @param {import('../../State/Island.js').default} island - * @param {import('../../State/IslandLayout.js').default} [layout] - */ -export function buildEditableViews(view, island, layout) -{ - return { - tree: buildTreeAdapter(view, island, layout), - flower: buildFlowerAdapter(view, island, layout), - fruit: buildFruitAdapter(view, island, layout), - mailbox: buildMailboxAdapter(view, island, layout), - telescope: buildTelescopeAdapter(view, island, layout), - } -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** - * Build spawn/remove helpers for a given kind that delegate to the view's - * ensureFromLayout. The layout slice has already been mutated before these - * are called, so we just trigger the reconcile. - * - * @param {object} view - * @param {import('../../State/IslandLayout.js').default} layout - * @param {string} kind - */ -function buildSpawnRemove(view, layout, kind) -{ - const reconcile = () => - { - const objs = layout?.listByKind?.(kind) ?? [] - // Try both singular and plural names (tree→view.tree, flower→view.flowers). - const target = view[kind] ?? view[`${kind}s`] - target?.ensureFromLayout?.(objs) - } - - return { - spawn: (_obj) => reconcile(), - remove: (_id) => reconcile(), - } -} - -// ── Tree ───────────────────────────────────────────────────────────────────── - -function buildTreeAdapter(view, island, layout) -{ - return { - getObject3D(layoutId) - { - const entry = view.tree?.entries?.find((e) => e.layoutId === layoutId) - return entry?.group ?? null - }, - - hitTargets() - { - if(!view.tree?.entries) return [] - return view.tree.entries - .map((e) => e.group) - .filter(Boolean) - }, - - applyTransform(id, t) - { - const entry = view.tree?.entries?.find((e) => e.layoutId === id) - if(!entry || !entry.group) return - - const x = typeof t.x === 'number' ? t.x : entry.group.position.x - const z = typeof t.z === 'number' ? t.z : entry.group.position.z - - const idx = view.tree.entries.indexOf(entry) - if(typeof view.tree.moveEntry === 'function') - { - view.tree.moveEntry(idx, x, z) - } - else - { - const y = island.heightAt(x, z) - entry.group.position.set(x, y, z) - } - - if(typeof t.yaw === 'number') entry.group.rotation.y = t.yaw - if(typeof t.scale === 'number') entry.group.scale.setScalar(t.scale) - }, - - ...buildSpawnRemove(view, layout, 'tree'), - } -} - -// ── Flower ──────────────────────────────────────────────────────────────────── - -function buildFlowerAdapter(view, island, layout) -{ - return { - getObject3D(layoutId) - { - const f = view.flowers?.flowers?.find((fl) => fl.layoutId === layoutId) - return f?.group ?? null - }, - - hitTargets() - { - if(!view.flowers?.flowers) return [] - return view.flowers.flowers - .map((f) => f.group) - .filter(Boolean) - }, - - applyTransform(id, t) - { - const f = view.flowers?.flowers?.find((fl) => fl.layoutId === id) - if(!f || !f.group) return - - const x = typeof t.x === 'number' ? t.x : f.group.position.x - const z = typeof t.z === 'number' ? t.z : f.group.position.z - - const idx = view.flowers.flowers.indexOf(f) - if(typeof view.flowers.moveInstance === 'function') - { - view.flowers.moveInstance(idx, x, z) - } - else - { - const y = island.heightAt(x, z) - f.group.position.set(x, y, z) - } - - if(typeof t.yaw === 'number') f.group.rotation.y = t.yaw - if(typeof t.scale === 'number') f.group.scale.setScalar(t.scale) - }, - - ...buildSpawnRemove(view, layout, 'flower'), - } -} - -// ── Fruit ───────────────────────────────────────────────────────────────────── - -function buildFruitAdapter(view, island, layout) -{ - return { - getObject3D(layoutId) - { - const entry = view.fruits?.entries?.find((e) => e.layoutId === layoutId) - return entry?.group ?? null - }, - - hitTargets() - { - if(!view.fruits?.entries) return [] - return view.fruits.entries - .map((e) => e.group) - .filter(Boolean) - }, - - applyTransform(id, t) - { - const entry = view.fruits?.entries?.find((e) => e.layoutId === id) - if(!entry || !entry.group) return - - const x = typeof t.x === 'number' ? t.x : entry.group.position.x - const z = typeof t.z === 'number' ? t.z : entry.group.position.z - - const idx = view.fruits.entries.indexOf(entry) - if(typeof view.fruits.moveEntry === 'function') - { - view.fruits.moveEntry(idx, x, z) - } - else - { - const y = island.heightAt(x, z) - entry.group.position.set(x, y, z) - } - - if(typeof t.yaw === 'number') entry.group.rotation.y = t.yaw - if(typeof t.scale === 'number') entry.group.scale.setScalar(t.scale) - }, - - ...buildSpawnRemove(view, layout, 'fruit'), - } -} - -// ── Mailbox ─────────────────────────────────────────────────────────────────── - -function buildMailboxAdapter(view, island, layout) -{ - return { - getObject3D(_layoutId) - { - return view.mailbox?.group ?? null - }, - - hitTargets() - { - const g = view.mailbox?.group - return g ? [g] : [] - }, - - applyTransform(id, t) - { - const g = view.mailbox?.group - if(!g) return - - const x = typeof t.x === 'number' ? t.x : g.position.x - const z = typeof t.z === 'number' ? t.z : g.position.z - - if(typeof view.mailbox.move === 'function') - { - view.mailbox.move(x, z) - } - else - { - const y = island.heightAt(x, z) - g.position.set(x, y, z) - } - - if(typeof t.yaw === 'number') g.rotation.y = t.yaw - if(typeof t.scale === 'number') g.scale.setScalar(t.scale) - }, - - ...buildSpawnRemove(view, layout, 'mailbox'), - } -} - -// ── Telescope ───────────────────────────────────────────────────────────────── - -function buildTelescopeAdapter(view, island, layout) -{ - return { - getObject3D(_layoutId) - { - return view.telescope?.group ?? null - }, - - hitTargets() - { - const g = view.telescope?.group - return g ? [g] : [] - }, - - applyTransform(id, t) - { - const g = view.telescope?.group - if(!g) return - - const x = typeof t.x === 'number' ? t.x : g.position.x - const z = typeof t.z === 'number' ? t.z : g.position.z - - if(typeof view.telescope.move === 'function') - { - view.telescope.move(x, z) - } - else - { - const y = island.heightAt(x, z) - g.position.set(x, y, z) - } - - if(typeof t.yaw === 'number') g.rotation.y = t.yaw - if(typeof t.scale === 'number') g.scale.setScalar(t.scale) - }, - - ...buildSpawnRemove(view, layout, 'telescope'), - } -} diff --git a/test/engine/IslandEditor.selection.test.ts b/test/engine/IslandEditor.selection.test.ts deleted file mode 100644 index 5bd1fcb..0000000 --- a/test/engine/IslandEditor.selection.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Plan 002 — Island Editor: Selection tests. - * - * Tests raycast-hit → selection, deselect, and dispose clears highlight. - * - * These tests are fully unit-testable without WebGL — the gizmo is gone. - * We build lightweight stubs for the adapter layer and the selection - * highlight machinery rather than spinning up a full View. - */ - -import * as THREE from 'three' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' -import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' -import CommandStack from '~/engine/student-space/Game/View/edit/CommandStack.js' -import EditController from '~/engine/student-space/Game/View/edit/EditController.js' -import Selection from '~/engine/student-space/Game/View/edit/Selection.js' - -// ── Test infrastructure ──────────────────────────────────────────────────── - -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() -} - -function makeIslandStub() { - return { - heightAt: (_x: number, _z: number) => 1.0, - isPlaceable: (x: number, z: number) => Math.abs(x) < 4 && Math.abs(z) < 4, - } -} - -/** - * Build a minimal view stub with one tree, one flower, one fruit, a - * mailbox, and a telescope — just enough for the adapter to find groups. - */ -function makeViewStub(layout: IslandLayout, island: ReturnType) { - const firstTree = layout.listByKind('tree')[0] - const firstFlower = layout.listByKind('flower')[0] - const firstFruit = layout.listByKind('fruit')[0] - - const treeGroup = new THREE.Group() - treeGroup.position.set(firstTree?.x ?? 0, 1, firstTree?.z ?? 0) - const flowerGroup = new THREE.Group() - flowerGroup.position.set(firstFlower?.x ?? 0, 1, firstFlower?.z ?? 0) - const fruitGroup = new THREE.Group() - fruitGroup.position.set(firstFruit?.x ?? 0, 1, firstFruit?.z ?? 0) - const mailboxGroup = new THREE.Group() - mailboxGroup.position.set(-0.6, 1, 2.5) - const teleGroup = new THREE.Group() - teleGroup.position.set(2.5, 1, -1.5) - - return { - scene: new THREE.Scene(), - camera: { - instance: new THREE.PerspectiveCamera(), - controls: { enabled: true }, - bindControls: vi.fn(), - }, - renderer: { instance: { domElement: document.createElement('canvas') } }, - tree: { - entries: [ - { - layoutId: firstTree?.id ?? 'tree-0', - group: treeGroup, - x: firstTree?.x ?? 0, - z: firstTree?.z ?? 0, - }, - ], - moveEntry: (_idx: number, x: number, z: number) => { - treeGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - flowers: { - flowers: [ - { - layoutId: firstFlower?.id ?? 'flower-0', - group: flowerGroup, - x: firstFlower?.x ?? 0, - z: firstFlower?.z ?? 0, - }, - ], - moveInstance: (_idx: number, x: number, z: number) => { - flowerGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - fruits: { - entries: [ - { - layoutId: firstFruit?.id ?? 'fruit-0', - group: fruitGroup, - kind: 'fruit', - x: firstFruit?.x ?? 0, - z: firstFruit?.z ?? 0, - }, - ], - moveEntry: (_idx: number, x: number, z: number) => { - fruitGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - mailbox: { - group: mailboxGroup, - position: { x: -0.6, y: 1, z: 2.5 }, - move: (x: number, z: number) => { - mailboxGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - telescope: { - group: teleGroup, - move: (x: number, z: number) => { - teleGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - } -} - -function makeStateStub(layout: IslandLayout, island: ReturnType) { - return { - island, - islandLayout: layout, - } -} - -afterEach(() => { - ;(Persistence as unknown as { instance: unknown }).instance = null - ;(IslandLayout as unknown as { instance: unknown }).instance = null - vi.restoreAllMocks() -}) - -// ── CommandStack ─────────────────────────────────────────────────────────── - -describe('CommandStack', () => { - it('push and undo restores state', () => { - const stack = new CommandStack() - let val = 'original' - stack.push({ - do: () => { - val = 'modified' - }, - undo: () => { - val = 'original' - }, - }) - expect(stack.undoCount).toBe(1) - expect(stack.redoCount).toBe(0) - stack.undo() - expect(val).toBe('original') - expect(stack.undoCount).toBe(0) - expect(stack.redoCount).toBe(1) - }) - - it('redo re-applies', () => { - const stack = new CommandStack() - let val = 0 - stack.push({ - do: () => { - val = 1 - }, - undo: () => { - val = 0 - }, - }) - stack.undo() - expect(val).toBe(0) - stack.redo() - expect(val).toBe(1) - }) - - it('push clears redo stack', () => { - const stack = new CommandStack() - stack.push({ do: vi.fn(), undo: vi.fn() }) - stack.undo() - expect(stack.redoCount).toBe(1) - stack.push({ do: vi.fn(), undo: vi.fn() }) - expect(stack.redoCount).toBe(0) - }) - - it('caps at 100 entries', () => { - const stack = new CommandStack() - for (let i = 0; i < 110; i++) { - stack.push({ do: vi.fn(), undo: vi.fn() }) - } - expect(stack.undoCount).toBe(100) - }) - - it('undo on empty stack is a no-op', () => { - const stack = new CommandStack() - expect(() => stack.undo()).not.toThrow() - }) - - it('clear removes everything', () => { - const stack = new CommandStack() - stack.push({ do: vi.fn(), undo: vi.fn() }) - stack.clear() - expect(stack.undoCount).toBe(0) - }) -}) - -// ── Selection ────────────────────────────────────────────────────────────── - -describe('Selection', () => { - it('select stores the id', () => { - const scene = new THREE.Scene() - const sel = new Selection(scene) - const obj = new THREE.Group() - sel.select('tree-0', obj) - expect(sel.get()).toBe('tree-0') - }) - - it('deselect clears the id', () => { - const scene = new THREE.Scene() - const sel = new Selection(scene) - const obj = new THREE.Group() - sel.select('tree-0', obj) - sel.deselect() - expect(sel.get()).toBeNull() - }) - - it('onChange fires on select', () => { - const scene = new THREE.Scene() - const sel = new Selection(scene) - const ids: Array = [] - sel.onChange((id: string | null) => ids.push(id)) - sel.select('tree-0', new THREE.Group()) - expect(ids).toEqual(['tree-0']) - }) - - it('onChange fires on deselect with null', () => { - const scene = new THREE.Scene() - const sel = new Selection(scene) - const ids: Array = [] - sel.onChange((id: string | null) => ids.push(id)) - sel.select('tree-0', new THREE.Group()) - sel.deselect() - expect(ids).toEqual(['tree-0', null]) - }) - - it('dispose clears id and removes helper from scene', () => { - const scene = new THREE.Scene() - const sel = new Selection(scene) - sel.select('tree-0', new THREE.Group()) - const childCountAfterSelect = scene.children.length - expect(childCountAfterSelect).toBeGreaterThan(0) - sel.dispose() - expect(sel.get()).toBeNull() - // Helpers removed — scene children should decrease. - expect(scene.children.length).toBeLessThan(childCountAfterSelect) - }) - - it('unsubscribe removes callback', () => { - const scene = new THREE.Scene() - const sel = new Selection(scene) - let count = 0 - const unsub = sel.onChange(() => count++) - sel.select('tree-0', new THREE.Group()) - unsub() - sel.deselect() - expect(count).toBe(1) // only the select fired - }) -}) - -// ── EditController — selection ───────────────────────────────────────────── - -describe('EditController — selection', () => { - let layout: IslandLayout - let island: ReturnType - let viewStub: ReturnType - let stateStub: ReturnType - let controller: EditController - - beforeEach(() => { - layout = freshLayout() - island = makeIslandStub() - viewStub = makeViewStub(layout, island) - stateStub = makeStateStub(layout, island) - controller = new EditController({ view: viewStub as never, state: stateStub as never }) - }) - - afterEach(() => { - controller.dispose() - }) - - it('starts inactive with no selection', () => { - expect(controller.selection.get()).toBeNull() - }) - - it('activate/deactivate do not throw', () => { - expect(() => { - controller.activate() - controller.deactivate() - }).not.toThrow() - }) - - it('dispose restores camera.controls.enabled', () => { - viewStub.camera.controls.enabled = false - controller.dispose() - expect(viewStub.camera.controls.enabled).toBe(true) - }) -}) diff --git a/test/engine/IslandEditor.spawn.test.ts b/test/engine/IslandEditor.spawn.test.ts deleted file mode 100644 index d4367ce..0000000 --- a/test/engine/IslandEditor.spawn.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Plan 003 — Island Editor: spawn / remove reconcile tests. - * - * Verifies that addObject / removeObject triggers ensureFromLayout on the - * correct view stub, and that _reconcileAfterStructural in EditController - * delegates to the right per-kind handler. - */ - -import * as THREE from 'three' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' -import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' -import EditController from '~/engine/student-space/Game/View/edit/EditController.js' - -// ── Test infrastructure ──────────────────────────────────────────────────── - -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() -} - -function makeIsland() { - return { - heightAt: (_x: number, _z: number) => 0, - isPlaceable: (x: number, z: number) => Math.abs(x) < 5 && Math.abs(z) < 5, - } -} - -function makeViewStub(layout: IslandLayout, island: ReturnType) { - const firstTree = layout.listByKind('tree')[0] - const firstFlower = layout.listByKind('flower')[0] - const firstFruit = layout.listByKind('fruit')[0] - - const treeGroup = new THREE.Group() - const flowerGroup = new THREE.Group() - const fruitGroup = new THREE.Group() - const mailboxGroup = new THREE.Group() - const teleGroup = new THREE.Group() - - return { - scene: new THREE.Scene(), - camera: { - instance: new THREE.PerspectiveCamera(), - controls: { enabled: true }, - bindControls: vi.fn(), - }, - renderer: { instance: { domElement: document.createElement('canvas') } }, - tree: { - ready: true, - entries: [{ layoutId: firstTree?.id ?? 'tree-0', group: treeGroup }], - ensureFromLayout: vi.fn(), - }, - flowers: { - flowers: [{ layoutId: firstFlower?.id ?? 'flower-0', group: flowerGroup }], - ensureFromLayout: vi.fn(), - }, - fruits: { - entries: [{ layoutId: firstFruit?.id ?? 'fruit-0', group: fruitGroup }], - ensureFromLayout: vi.fn(), - }, - mailbox: { - group: mailboxGroup, - move: vi.fn((x: number, z: number) => { - mailboxGroup.position.set(x, island.heightAt(x, z), z) - }), - }, - telescope: { - group: teleGroup, - move: vi.fn((x: number, z: number) => { - teleGroup.position.set(x, island.heightAt(x, z), z) - }), - }, - } -} - -function makeState(layout: IslandLayout, island: ReturnType) { - return { island, islandLayout: layout } -} - -afterEach(() => { - ;(Persistence as unknown as { instance: unknown }).instance = null - ;(IslandLayout as unknown as { instance: unknown }).instance = null - vi.restoreAllMocks() -}) - -// ── Spawn / remove reconcile via EditController ─────────────────────────── - -describe('EditController spawn/remove reconcile', () => { - it('addObject(flower) calls flowers.ensureFromLayout with updated list', () => { - const layout = freshLayout() - const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) - - const ctrl = new EditController({ view: view as never, state: state as never }) - ctrl.activate() - - const beforeCount = layout.listByKind('flower').length - - layout.addObject({ id: 'flower-new', kind: 'flower', species: 'daisy', x: 0.5, z: 0.5 }) - - expect(layout.listByKind('flower').length).toBe(beforeCount + 1) - expect(view.flowers.ensureFromLayout).toHaveBeenCalled() - const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at( - -1, - )![0] as { id: string }[] - expect(lastCall.some((o) => o.id === 'flower-new')).toBe(true) - - ctrl.dispose() - }) - - it('addObject(tree) calls tree.ensureFromLayout', () => { - const layout = freshLayout() - const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) - - const ctrl = new EditController({ view: view as never, state: state as never }) - ctrl.activate() - - layout.addObject({ id: 'tree-new', kind: 'tree', species: 'oak', x: 1.0, z: 1.0 }) - - expect(view.tree.ensureFromLayout).toHaveBeenCalled() - - ctrl.dispose() - }) - - it('addObject(fruit) calls fruits.ensureFromLayout', () => { - const layout = freshLayout() - const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) - - const ctrl = new EditController({ view: view as never, state: state as never }) - ctrl.activate() - - layout.addObject({ id: 'fruit-new', kind: 'fruit', species: 'plum', x: 2.0, z: 0.5 }) - - expect(view.fruits.ensureFromLayout).toHaveBeenCalled() - - ctrl.dispose() - }) - - it('removeObject(flower) calls flowers.ensureFromLayout with reduced list', () => { - const layout = freshLayout() - const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) - - const firstFlowerId = layout.listByKind('flower')[0]!.id - - const ctrl = new EditController({ view: view as never, state: state as never }) - ctrl.activate() - ;(view.flowers.ensureFromLayout as ReturnType).mockClear() - - layout.removeObject(firstFlowerId) - - expect(view.flowers.ensureFromLayout).toHaveBeenCalled() - const lastCall = (view.flowers.ensureFromLayout as ReturnType).mock.calls.at( - -1, - )![0] as { id: string }[] - expect(lastCall.some((o) => o.id === firstFlowerId)).toBe(false) - - ctrl.dispose() - }) - - it('revertToDefault triggers all-kind reconcile (layoutReplaced)', () => { - const layout = freshLayout() - const island = makeIsland() - const view = makeViewStub(layout, island) - const state = makeState(layout, island) - - const ctrl = new EditController({ view: view as never, state: state as never }) - ctrl.activate() - ;(view.tree.ensureFromLayout as ReturnType).mockClear() - ;(view.flowers.ensureFromLayout as ReturnType).mockClear() - ;(view.fruits.ensureFromLayout as ReturnType).mockClear() - - // Diverge then revert. - layout.addObject({ id: 'tmp-flower', kind: 'flower', species: 'daisy', x: 0, z: 0 }) - ;(view.flowers.ensureFromLayout as ReturnType).mockClear() - - layout.revertToDefault() - - expect(view.tree.ensureFromLayout).toHaveBeenCalled() - expect(view.flowers.ensureFromLayout).toHaveBeenCalled() - expect(view.fruits.ensureFromLayout).toHaveBeenCalled() - - ctrl.dispose() - }) -}) diff --git a/test/engine/IslandEditor.transform.test.ts b/test/engine/IslandEditor.transform.test.ts deleted file mode 100644 index 75ff8ae..0000000 --- a/test/engine/IslandEditor.transform.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Plan 002 — Island Editor: applyTransform, undo/redo, drag controls. - * - * Tests: - * - applyTransform writes {x,z,yaw,scale} to layout.updateObject - * - y is not stored (always derived from heightAt) - * - off-plateau translate rejected - * - undo restores before, redo re-applies after - * - drag toggles camera.controls.enabled and restores on finish - * - dispose restores controls.enabled - */ - -import * as THREE from 'three' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import IslandLayout from '~/engine/student-space/Game/State/IslandLayout.js' -import Persistence, { memoryAdapter } from '~/engine/student-space/Game/State/Persistence.js' -import EditController from '~/engine/student-space/Game/View/edit/EditController.js' - -// ── Shared test helpers ──────────────────────────────────────────────────── - -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() -} - -function makeIslandStub() { - return { - heightAt: (_x: number, _z: number) => 1.0, - isPlaceable: (x: number, z: number) => Math.abs(x) < 4 && Math.abs(z) < 4, - } -} - -function makeViewStub(layout: IslandLayout, island: ReturnType) { - const firstTree = layout.listByKind('tree')[0]! - const treeGroup = new THREE.Group() - treeGroup.position.set(firstTree.x, 1, firstTree.z) - - const firstFlower = layout.listByKind('flower')[0]! - const flowerGroup = new THREE.Group() - flowerGroup.position.set(firstFlower.x, 1, firstFlower.z) - - const firstFruit = layout.listByKind('fruit')[0]! - const fruitGroup = new THREE.Group() - fruitGroup.position.set(firstFruit.x, 1, firstFruit.z) - - const mailboxGroup = new THREE.Group() - mailboxGroup.position.set(-0.6, 1, 2.5) - - const teleGroup = new THREE.Group() - teleGroup.position.set(2.5, 1, -1.5) - - return { - scene: new THREE.Scene(), - camera: { - instance: new THREE.PerspectiveCamera(), - controls: { enabled: true }, - }, - renderer: { instance: { domElement: document.createElement('canvas') } }, - tree: { - entries: [ - { - layoutId: firstTree.id, - group: treeGroup, - x: firstTree.x, - z: firstTree.z, - }, - ], - moveEntry: (_idx: number, x: number, z: number) => { - treeGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - flowers: { - flowers: [ - { - layoutId: firstFlower.id, - group: flowerGroup, - x: firstFlower.x, - z: firstFlower.z, - }, - ], - moveInstance: (_idx: number, x: number, z: number) => { - flowerGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - fruits: { - entries: [ - { - layoutId: firstFruit.id, - group: fruitGroup, - kind: 'fruit', - x: firstFruit.x, - z: firstFruit.z, - }, - ], - moveEntry: (_idx: number, x: number, z: number) => { - fruitGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - mailbox: { - group: mailboxGroup, - position: { x: -0.6, y: 1, z: 2.5 }, - move: (x: number, z: number) => { - mailboxGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - telescope: { - group: teleGroup, - move: (x: number, z: number) => { - teleGroup.position.set(x, island.heightAt(x, z), z) - }, - }, - } -} - -function makeStateStub(layout: IslandLayout, island: ReturnType) { - return { island, islandLayout: layout } -} - -afterEach(() => { - ;(Persistence as unknown as { instance: unknown }).instance = null - ;(IslandLayout as unknown as { instance: unknown }).instance = null - vi.restoreAllMocks() -}) - -// ── applyTransform ───────────────────────────────────────────────────────── - -describe('EditController.applyTransform', () => { - let layout: IslandLayout - let island: ReturnType - let controller: EditController - let treeId: string - - beforeEach(() => { - layout = freshLayout() - island = makeIslandStub() - const view = makeViewStub(layout, island) - const state = makeStateStub(layout, island) - controller = new EditController({ view: view as never, state: state as never }) - treeId = layout.listByKind('tree')[0]!.id - }) - - afterEach(() => controller.dispose()) - - it('writes x and z to layout', () => { - const ok = controller.applyTransform(treeId, { x: 1.5, z: -0.5 }) - expect(ok).toBe(true) - const updated = layout.get(treeId) - expect(updated?.x).toBeCloseTo(1.5) - expect(updated?.z).toBeCloseTo(-0.5) - }) - - it('does not store y (y is always derived)', () => { - controller.applyTransform(treeId, { x: 1, z: 1 }) - const updated = layout.get(treeId) - expect((updated as Record).y).toBeUndefined() - }) - - it('writes yaw to layout', () => { - controller.applyTransform(treeId, { yaw: 1.23 }) - expect(layout.get(treeId)?.yaw).toBeCloseTo(1.23) - }) - - it('writes scale to layout', () => { - controller.applyTransform(treeId, { scale: 2.0 }) - expect(layout.get(treeId)?.scale).toBeCloseTo(2.0) - }) - - it('off-plateau translate is rejected and layout unchanged', () => { - const before = { x: layout.get(treeId)!.x, z: layout.get(treeId)!.z } - const ok = controller.applyTransform(treeId, { x: 10, z: 10 }) - expect(ok).toBe(false) - const after = layout.get(treeId) - expect(after?.x).toBeCloseTo(before.x) - expect(after?.z).toBeCloseTo(before.z) - }) - - it('unknown id is rejected', () => { - const ok = controller.applyTransform('nonexistent-id', { x: 1, z: 1 }) - expect(ok).toBe(false) - }) - - it('invalid patch types are rejected', () => { - const ok = controller.applyTransform(null as never, { x: 1 }) - expect(ok).toBe(false) - }) -}) - -// ── undo / redo ──────────────────────────────────────────────────────────── - -describe('EditController undo / redo', () => { - let layout: IslandLayout - let controller: EditController - let treeId: string - - beforeEach(() => { - layout = freshLayout() - const island = makeIslandStub() - const view = makeViewStub(layout, island) - const state = makeStateStub(layout, island) - controller = new EditController({ view: view as never, state: state as never }) - treeId = layout.listByKind('tree')[0]!.id - }) - - afterEach(() => controller.dispose()) - - it('undo restores the before state', () => { - const original = { ...layout.get(treeId)! } - controller.applyTransform(treeId, { x: 1.5, z: -0.5 }) - expect(layout.get(treeId)?.x).toBeCloseTo(1.5) - - controller.commandStack.undo() - expect(layout.get(treeId)?.x).toBeCloseTo(original.x) - expect(layout.get(treeId)?.z).toBeCloseTo(original.z) - }) - - it('redo re-applies the after state', () => { - controller.applyTransform(treeId, { x: 1.5, z: -0.5 }) - controller.commandStack.undo() - controller.commandStack.redo() - expect(layout.get(treeId)?.x).toBeCloseTo(1.5) - expect(layout.get(treeId)?.z).toBeCloseTo(-0.5) - }) - - it('multiple transforms track in correct order', () => { - const orig = layout.get(treeId)!.x - controller.applyTransform(treeId, { x: 1.0, z: 0 }) - controller.applyTransform(treeId, { x: 2.0, z: 0 }) - controller.commandStack.undo() - expect(layout.get(treeId)?.x).toBeCloseTo(1.0) - controller.commandStack.undo() - expect(layout.get(treeId)?.x).toBeCloseTo(orig) - }) -}) - -// ── drag controls ────────────────────────────────────────────────────────── - -describe('EditController drag — camera.controls restored', () => { - let layout: IslandLayout - let controller: EditController - let viewStub: ReturnType - - beforeEach(() => { - layout = freshLayout() - const island = makeIslandStub() - viewStub = makeViewStub(layout, island) - const state = makeStateStub(layout, island) - controller = new EditController({ view: viewStub as never, state: state as never }) - controller.activate() - }) - - afterEach(() => controller.dispose()) - - it('dispose restores camera.controls.enabled to true', () => { - // Manually mark controls disabled as if a drag is in progress. - viewStub.camera.controls.enabled = false - controller.dispose() - expect(viewStub.camera.controls.enabled).toBe(true) - }) - - it('deactivate restores camera.controls.enabled', () => { - viewStub.camera.controls.enabled = false - controller.deactivate() - expect(viewStub.camera.controls.enabled).toBe(true) - }) - - it('deactivate while active adds and removes canvas listener without error', () => { - expect(() => controller.deactivate()).not.toThrow() - }) -}) - -// ── reactive sync via objectUpdated ─────────────────────────────────────── - -describe('EditController reactive sync', () => { - it('layout.updateObject fans objectUpdated and controller syncs mesh', () => { - const layout = freshLayout() - const island = makeIslandStub() - const viewS = makeViewStub(layout, island) - const stateS = makeStateStub(layout, island) - const ctrl = new EditController({ view: viewS as never, state: stateS as never }) - - const treeId = layout.listByKind('tree')[0]!.id - const treeGroup = viewS.tree.entries[0]!.group - - // Direct layout mutation (simulating undo from outside). - layout.updateObject(treeId, { x: 3.0, z: 0.5 }) - - // The controller's subscriber should have called the adapter and moved - // the group via moveEntry. - expect(treeGroup.position.x).toBeCloseTo(3.0) - expect(treeGroup.position.z).toBeCloseTo(0.5) - - ctrl.dispose() - }) -})