From 90a97367dcb245b00ed88578700e01f50e364892 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 21:21:22 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Prefab=20authoring=20(PLAN=20=C2=A7E)=20+?= =?UTF-8?q?=20THE=20EDITOR=20WAS=20RENDERING=20NO=20MODELS=20AT=20ALL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug that matters most here `joinPath('.', 'assets/models')` returned `'./assets/models'`. The project file lives in the CWD, so rootDir came out as '.', so the asset catalog was keyed `'./assets/models/prop_tree.glb'` while every world file references `'assets/models/prop_tree.glb'`. Every model lookup missed. EVERY entity with a model rendered as a grey placeholder cube -- all 88 trees, the building, every prop. The models loaded fine. Nothing errored. The editor just quietly showed you a level made of boxes, which is indistinguishable from "this level is made of boxes", and is exactly why it survived this long. The editor was unusable for authoring and it was not obvious that it was. '.' is not a directory component -- it is "here", and prefixing it produces a path that is EQUIVALENT to the original but not EQUAL to it. The catalog is a Map; only equal will do. Self-test pins the identity between catalog key and world modelRef. ## Prefab authoring (PLAN §E) prefab-tool.ts had existed for weeks with ZERO call sites, because the UI it seemed to need -- a parallel render path for children, a parallel selection model, parallel gizmo handling -- was too big a job to start. It needed none of that. A PrefabChild is an EntityData minus `name` and `userData`, so while you edit a prefab its children simply ARE state.world.entities: the real world is parked in a stash and the children are handed to the editor as the world. Rendering, picking, the move/rotate/scale gizmos, delete, duplicate, snapping, undo/redo -- all of it was already written against entities, and none of it needs to know it is looking at a prefab. - Prefabs tab: name field + "+ New Prefab"; "Edit " for the selected prefab. No double-click: the UI context has no notion of one, and a hidden gesture is worse than a visible button. - Ctrl+S saves the PREFAB, not the world -- saving the world from inside prefab mode would write the neutral authoring stage over the real level. - ESC restores the world AND its undo history exactly. Prefab mode gets its own history; undoing past its start and landing in the world's edits would be indefensible. - Cycle rejection is TRANSITIVE (A->B->C->A), enforced at the one place a cycle can be created, and it now SAYS so -- via a new transient status line. The editor had no way to tell you it had refused something, and a click that silently does nothing reads as a broken editor rather than a rule being enforced. - Entering prefab mode frames the camera on the parts. Without it the camera stays pointed at a 200 m arena and a three-prop prefab is three pixels of dust. 87 self-tests pass (was 81). Verified in the running editor: the arena now renders its actual trees, building and river instead of grey boxes; prefab mode shows its parts in the outliner, saves *.prefab.json, and restores the world on exit. --- PLAN.md | 28 +++- src/io/paths.ts | 15 +- src/main.ts | 16 ++- src/state/editor-state.ts | 34 +++++ src/tests/self-tests.ts | 119 ++++++++++++++++ src/tools/place-tool.ts | 12 +- src/tools/prefab-tool.ts | 259 ++++++++++++++++++++++++++-------- src/ui/layouts/asset-panel.ts | 72 +++++++++- src/ui/layouts/status-bar.ts | 5 + 9 files changed, 497 insertions(+), 63 deletions(-) diff --git a/PLAN.md b/PLAN.md index 70267c3..81794c9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -204,7 +204,33 @@ The dividing line, for future schema questions: **lights are engine-universal - **D2. Kernel:** implement the `'paint'` branch in `applyBrush` (`brush-tool.ts:121-150`): add weight to the active layer's per-cell `weights[]` with the same radial falloff as sculpt kernels, renormalizing across layers per cell. Undo via a weights-snapshot command (mirror `TerrainStrokeCommand`). - **D3. Splat rendering.** ⚠ Investigate `bloom_compile_material` (`../engine/native/.../ffi_core/models.rs`, TS wrapper in the engine) — determine whether compiled materials support texture bindings. Target design: pack up to 4 layer weights into an RGBA weights texture regenerated on paint, sample each layer's texture by terrain UV × `tileScale`, blend by weights. Implement in the engine (shared by editor and `instantiateWorld`). If `compile_material` cannot bind textures, the required engine extension (a material-with-texture-slots FFI) is **in scope** — enumerate and build it rather than shipping a vertex-color approximation. Acceptance: paint two layers in the editor, save, load in a game via `instantiateWorld`, blended texturing visible in both. -### E. Prefab authoring — wire the existing logic +### E. Prefab authoring — ✅ DONE (2026-07-12) + +> **Shipped.** `prefab-tool.ts` had existed for weeks with **zero call sites**, +> because the UI it appeared to need — a parallel render path for children, a +> parallel selection model, parallel gizmo handling — was too big a job to start. +> +> **It didn't need any of that.** A `PrefabChild` is an `EntityData` minus `name` +> and `userData`, so while you are editing a prefab its children simply **ARE** +> `state.world.entities`: the real world is parked in a stash and the children are +> handed to the editor as if they were the world. Rendering, picking, the gizmos, +> delete, duplicate, snapping, undo/redo — every one of those was already written +> against entities, and not one of them needs to know it is looking at a prefab. +> +> - **E1/E2 (UI):** name field + `+ New Prefab` in the Prefabs tab; `Edit ""` +> for the selected prefab. No double-click — the UI context has no notion of one, +> and inventing a hidden gesture is worse than a visible button. +> - **E3:** placement uses the ordinary `CreateEntityCommand`. Ctrl+S saves the +> *prefab* (not the world — that would write the neutral authoring stage over the +> real level). ESC exits and restores the world **and its undo history** exactly. +> - **E4:** the catalog refreshes on save, so a new prefab is immediately placeable. +> - **Cycle rejection** is transitive (A→B→C→A), refused at the one place a cycle can +> be created — the place tool — and it now *says so*, via a new transient status +> line. A click that silently does nothing reads as a broken editor, not as a rule. +> - 6 new self-tests (mode round-trip incl. history restore; direct, one-hop and +> multi-hop cycles). + +### E. Prefab authoring — original plan - **E1.** "New Prefab" button in the asset panel's prefab tab → prompt for a name using the `textInput` widget (`src/ui/text-input.ts`) → `enterNewPrefabMode`. - **E2.** Double-click a prefab entry → `enterPrefabEditMode`. diff --git a/src/io/paths.ts b/src/io/paths.ts index 4821eed..a73ac69 100644 --- a/src/io/paths.ts +++ b/src/io/paths.ts @@ -29,7 +29,20 @@ export function categoryFromName(relPath: string): string { } export function joinPath(a: string, b: string): string { - if (a.length === 0) return b; + // '.' is not a directory component — it is "here", and prefixing it produces a + // path that is EQUIVALENT to `b` but not EQUAL to it. That distinction cost the + // editor its entire model rendering: + // + // the project file lives in the CWD, so rootDir came out as '.' + // -> the asset catalog was keyed './assets/models/prop_tree.glb' + // -> world entities reference 'assets/models/prop_tree.glb' + // -> every lookup missed, and EVERY entity with a model rendered as a grey + // placeholder cube. The whole arena — 88 trees, the building, every prop. + // + // The models loaded fine. Nothing errored. The editor just quietly showed you a + // level made of boxes, which is indistinguishable from "this level is made of + // boxes" and is why it survived so long. + if (a.length === 0 || a === '.' || a === './') return b; if (b.length === 0) return a; if (a.charAt(a.length - 1) === '/') return a + b; return a + '/' + b; diff --git a/src/main.ts b/src/main.ts index 9579b24..6c1a7b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,7 +46,9 @@ import { updateBrushTool } from './tools/brush-tool'; import { updateWaterTool, drawWaterVolumes } from './tools/water-tool'; import { updateRiverTool, drawRiverSplines } from './tools/river-tool'; import { updateLightTool, drawLightMarkers, RemoveLightCommand } from './tools/light-tool'; -import { updatePrefabTool, drawPrefabBreadcrumb } from './tools/prefab-tool'; +import { + updatePrefabTool, drawPrefabBreadcrumb, savePrefabToDisk, +} from './tools/prefab-tool'; import { drawEnvironmentPanel } from './ui/layouts/environment-panel'; import { drawBrushPanel } from './ui/layouts/brush-panel'; import { updatePlaytest, drawPlaytestOverlay } from './playtest/playtest'; @@ -132,7 +134,12 @@ while (!windowShouldClose()) { if (isKeyDown(Key.LEFT_CONTROL) || isKeyDown(Key.LEFT_SUPER)) { if (isKeyPressed(Key.Z)) undo(state); if (isKeyPressed(Key.Y)) redo(state); - if (isKeyPressed(Key.S)) saveCurrentWorld(state); + // In prefab mode Ctrl+S means "save the prefab" — saving the world from inside + // a prefab would write the neutral authoring stage over the real level. + if (isKeyPressed(Key.S)) { + if (state.editingPrefab) savePrefabToDisk(state); + else saveCurrentWorld(state); + } } // Delete the selection — entity, water volume, or river. Delete only @@ -207,6 +214,11 @@ while (!windowShouldClose()) { updatePrefabTool(state); + if (state.statusMessageT > 0) { + state.statusMessageT = state.statusMessageT - dt; + if (state.statusMessageT < 0) state.statusMessageT = 0; + } + // ---- camera update ------------------------------------------------------- updateOrbitCamera(state); diff --git a/src/state/editor-state.ts b/src/state/editor-state.ts index b6e1224..e67dccc 100644 --- a/src/state/editor-state.ts +++ b/src/state/editor-state.ts @@ -111,6 +111,19 @@ export class HandleMap { // ---- main state object ----------------------------------------------------- +/// The world (and its history) parked while a prefab is being edited. +export interface PrefabStash { + world: WorldData; + worldPath: string | null; + undoStack: Command[]; + redoStack: Command[]; + selectionIds: string[]; + selectionPrimary: string | null; + activeTool: ToolId; + placeAssetRef: string | null; + modified: boolean; +} + export interface EditorState { // Project project: Project | null; @@ -122,8 +135,19 @@ export interface EditorState { worldPath: string | null; world: WorldData; editingPrefab: PrefabData | null; // Non-null while in prefab edit mode. + // What the world looked like before we entered prefab mode. See prefab-tool.ts: + // while editing, the prefab's children ARE `state.world.entities`, so every tool, + // gizmo and undo command already written for entities works on them unchanged. + // This is what gets swapped back on the way out. + prefabStash: PrefabStash | null; modified: boolean; + // A transient line in the status bar. The editor had no way to tell the user it + // had REFUSED something — a click that silently does nothing reads as a bug in + // the editor, not as a rule being enforced. + statusMessage: string; + statusMessageT: number; // seconds remaining + // Selection selection: Selection; @@ -188,6 +212,9 @@ export function createEditorState(): EditorState { worldPath: null, world: createEmptyWorld('untitled', 'Untitled World'), editingPrefab: null, + prefabStash: null, + statusMessage: '', + statusMessageT: 0, modified: false, selection: { ids: new Set(), primary: null, kind: 'entity' }, activeTool: 'select', @@ -316,3 +343,10 @@ export function nextEntityId(state: EditorState): string { state.world.metadata[key] = (n + 1).toString(); return 'ent_' + n.toString().padStart(4, '0'); } + + +/// Post a transient message to the status bar. +export function setStatus(state: EditorState, msg: string): void { + state.statusMessage = msg; + state.statusMessageT = 4.0; +} diff --git a/src/tests/self-tests.ts b/src/tests/self-tests.ts index dea7416..3cf781d 100644 --- a/src/tests/self-tests.ts +++ b/src/tests/self-tests.ts @@ -17,6 +17,10 @@ import { CreateEntityCommand } from '../state/commands/create-entity'; import { TransformEntityCommand } from '../state/commands/transform-entity'; import { CreateTerrainCommand } from '../state/commands/create-terrain'; import { SetUserDataCommand } from '../state/commands/set-userdata'; +import { + enterNewPrefabMode, enterPrefabEditMode, exitPrefabMode, wouldCycle, +} from '../tools/prefab-tool'; +import { joinPath } from '../io/paths'; let passed = 0; let failed = 0; @@ -43,6 +47,9 @@ export function runSelfTests(): number { testMapSize(); testCreateTerrainUndo(); testCounterIds(); + testPrefabAuthoringMode(); + testPrefabAuthoringCycles(); + testPathJoinIdentity(); testUserDataCommand(); testWaterCommands(); testLightMigration(); @@ -322,3 +329,115 @@ function testCommandUndoRedo(): void { undo(state); // undo create assert(state.world.entities.length === 0, 'command: undo create'); } + + +// --- Prefab authoring mode (PLAN §E) ---------------------------------------- +// +// The mode works by swapping the prefab's children in AS the world's entities, so +// every existing tool operates on them unchanged. These tests pin the two things +// that swap could plausibly get wrong: losing the world on the way out, and letting +// a prefab contain itself. +function testPrefabAuthoringMode(): void { + const state = createEditorState(); + state.project = { + root: '.', modelsDir: 'models', prefabsDir: 'prefabs', + worldsDir: 'worlds', texturesDir: 'textures', + } as any; + + // A world with one entity, and a dirty flag we expect to survive the round trip. + runCommand(state, new CreateEntityCommand(createEntity('world_ent', 'a.glb', [1, 2, 3]))); + const worldRef = state.world; + const undoDepth = state.undoStack.length; + assert(state.world.entities.length === 1, 'prefab mode: world starts with 1 entity'); + + // Entering swaps the world out for a neutral authoring stage. + enterNewPrefabMode(state, 'camp', 'Camp'); + assert(state.editingPrefab !== null, 'prefab mode: entered'); + assert(state.world !== worldRef, 'prefab mode: world was swapped out'); + assert(state.world.entities.length === 0, 'prefab mode: new prefab starts empty'); + assert(state.world.terrain === null, 'prefab mode: authoring stage has no terrain'); + assert(state.undoStack.length === 0, 'prefab mode: history is separate'); + + // Placing a part uses the ordinary entity command — that is the whole point. + runCommand(state, new CreateEntityCommand(createEntity('part_0', 'tent.glb', [0, 0, 0]))); + runCommand(state, new CreateEntityCommand(createEntity('part_1', 'fire.glb', [2, 0, 0]))); + assert(state.world.entities.length === 2, 'prefab mode: parts placed as entities'); + + // ...and so does undo. + undo(state); + assert(state.world.entities.length === 1, 'prefab mode: undo works on parts'); + redo(state); + assert(state.world.entities.length === 2, 'prefab mode: redo works on parts'); + + // Leaving must restore the world EXACTLY, history included. + exitPrefabMode(state); + assert(state.editingPrefab === null, 'prefab mode: exited'); + assert(state.world === worldRef, 'prefab mode: original world restored'); + assert(state.world.entities.length === 1, 'prefab mode: world entity survived'); + assert(state.undoStack.length === undoDepth, 'prefab mode: world history restored'); + assert(state.prefabStash === null, 'prefab mode: stash cleared'); +} + +// --- Cycle rejection (PLAN §E acceptance) ------------------------------------ +// +// A prefab that contains itself expands forever at load and takes the game with it. +// Direct self-reference is the obvious case; the one that actually gets built by +// accident is transitive — A holds B, B holds A. +function testPrefabAuthoringCycles(): void { + const state = createEditorState(); + + // Catalog: b contains c, c contains a. + state.catalog.prefabs.set('b', { + id: 'b', name: 'B', children: [ + { id: 'x', modelRef: null, prefabRef: 'c', + transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, + tint: null, tags: [] }, + ], + } as any); + state.catalog.prefabs.set('c', { + id: 'c', name: 'C', children: [ + { id: 'y', modelRef: null, prefabRef: 'a', + transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, + tint: null, tags: [] }, + ], + } as any); + state.catalog.prefabs.set('d', { id: 'd', name: 'D', children: [] } as any); + + // Not editing anything: nothing to cycle into. + assert(!wouldCycle(state, 'b'), 'cycle: no-op outside prefab mode'); + + enterNewPrefabMode(state, 'a', 'A'); + assert(wouldCycle(state, 'a'), 'cycle: direct self-reference rejected'); + assert(wouldCycle(state, 'b'), 'cycle: TRANSITIVE reference rejected (a -> b -> c -> a)'); + assert(wouldCycle(state, 'c'), 'cycle: one-hop transitive rejected (a -> c -> a)'); + assert(!wouldCycle(state, 'd'), 'cycle: an unrelated prefab is allowed'); + exitPrefabMode(state); +} + + +// --- Asset-key identity ------------------------------------------------------ +// +// The catalog is keyed by the SAME string the world file stores in `modelRef`, and +// the two are built by different code paths. A path that is merely EQUIVALENT is +// not good enough — it has to be EQUAL, because the lookup is a Map.get. +// +// This is not hypothetical: rootDir came out as '.' whenever the project file sat +// in the CWD, so the catalog was keyed './assets/models/x.glb' while worlds said +// 'assets/models/x.glb'. Every lookup missed and the editor rendered the entire +// arena — trees, building, every prop — as grey placeholder cubes, silently. +function testPathJoinIdentity(): void { + assert(joinPath('.', 'assets/models') === 'assets/models', + 'paths: "." root does not poison the key'); + assert(joinPath('./', 'assets/models') === 'assets/models', + 'paths: "./" root does not poison the key'); + assert(joinPath('', 'assets/models') === 'assets/models', + 'paths: empty root is a no-op'); + assert(joinPath('proj', 'assets/models') === 'proj/assets/models', + 'paths: a real root still joins'); + assert(joinPath('proj/', 'assets/models') === 'proj/assets/models', + 'paths: trailing slash is not doubled'); + // The thing that actually broke: catalog key must equal the world file's ref. + const catalogKey = joinPath(joinPath('.', 'assets/models'), 'prop_tree.glb'); + assert(catalogKey === 'assets/models/prop_tree.glb', + 'paths: catalog key MATCHES the world modelRef'); +} diff --git a/src/tools/place-tool.ts b/src/tools/place-tool.ts index 3620c3d..bae528c 100644 --- a/src/tools/place-tool.ts +++ b/src/tools/place-tool.ts @@ -4,10 +4,11 @@ import { getMouseX, getMouseY, getScreenWidth, getScreenHeight } from 'bloom'; import { createEntity, Vec3Lit } from 'bloom/world'; import { sampleHeight } from 'bloom/world'; -import { EditorState, nextEntityId } from '../state/editor-state'; +import { EditorState, nextEntityId, setStatus } from '../state/editor-state'; import { CreateEntityCommand } from '../state/commands/create-entity'; import { runCommand } from '../state/commands'; import { mouseToWorldRay, rayPlaneIntersect } from '../viewport/ray'; +import { wouldCycle } from './prefab-tool'; export function handlePlaceClick(state: EditorState): void { if (state.activeTool !== 'place' || state.placeAssetRef === null) return; @@ -38,6 +39,15 @@ export function handlePlaceClick(state: EditorState): void { const modelRef = isPrefab ? null : ref; const prefabRef = isPrefab ? ref.substring(7) : null; + // A prefab that contains itself — directly or through a chain — expands forever at + // load time and takes the game down with it. The one place that can be created is + // right here, so it is the one place that has to refuse. + if (isPrefab && prefabRef !== null && state.editingPrefab + && wouldCycle(state, prefabRef)) { + setStatus(state, 'Cannot place "' + prefabRef + '" inside itself — prefab cycle'); + return; + } + const entity = createEntity(id, modelRef || '', hitPoint as Vec3Lit); if (isPrefab) { entity.modelRef = null; diff --git a/src/tools/prefab-tool.ts b/src/tools/prefab-tool.ts index 1550fd2..d2c0792 100644 --- a/src/tools/prefab-tool.ts +++ b/src/tools/prefab-tool.ts @@ -1,98 +1,247 @@ // Prefab authoring mode. // -// When the user enters prefab edit mode (via New Prefab or double-clicking -// a prefab in the asset panel), the viewport swaps to a neutral scene. -// All placement tools mutate editingPrefab.children instead of world.entities. -// Save writes *.prefab.json to the project's prefabsDir. +// THE IDEA THAT MAKES THIS SMALL. A `PrefabChild` is an `EntityData` minus `name` +// and `userData`. So while you are editing a prefab, its children simply ARE +// `state.world.entities`: the real world is parked in a stash and the prefab's +// children are handed to the editor as if they were the world. +// +// Everything then works for free — rendering, picking, the move/rotate/scale +// gizmos, delete, duplicate, snapping, undo/redo — because every one of those was +// already written against entities and none of them needs to know it is looking at +// a prefab. The alternative (a parallel render path, a parallel selection model, +// parallel gizmo handling for children) is how a feature stays unwritten forever. +// Which is exactly what happened here: the logic in this file has existed for weeks +// with ZERO call sites, because the UI it seemed to need was too big a job. +// +// Save converts the entities back into children and writes `*.prefab.json`. import { isKeyPressed, Key, drawText } from 'bloom'; -import { createEmptyPrefab, PrefabData, PrefabChild, TransformData, Vec3Lit } from 'bloom/world'; -import { savePrefab } from 'bloom/world'; -import { EditorState } from '../state/editor-state'; +import { + createEmptyPrefab, createEmptyWorld, savePrefab, + PrefabData, PrefabChild, EntityData, WorldData, +} from 'bloom/world'; +import { EditorState, PrefabStash } from '../state/editor-state'; import { rebuildAllSceneNodes } from '../world-sync/sync'; +import { frameCameraOnBounds } from '../viewport/frame'; import { Theme } from '../ui/theme'; -// Enter prefab edit mode with a new empty prefab. -export function enterNewPrefabMode(state: EditorState, id: string, name: string): void { - state.editingPrefab = createEmptyPrefab(id, name); +// --- conversions ------------------------------------------------------------- + +function childToEntity(c: PrefabChild): EntityData { + const nm = c.modelRef !== null ? c.modelRef : (c.prefabRef !== null ? c.prefabRef : c.id); + return { + id: c.id, + name: nm, + modelRef: c.modelRef, + prefabRef: c.prefabRef, + transform: { + position: [c.transform.position[0], c.transform.position[1], c.transform.position[2]], + rotation: [c.transform.rotation[0], c.transform.rotation[1], c.transform.rotation[2]], + scale: [c.transform.scale[0], c.transform.scale[1], c.transform.scale[2]], + }, + tint: c.tint, + tags: c.tags, + userData: {}, + }; +} + +function entityToChild(e: EntityData): PrefabChild { + return { + id: e.id, + modelRef: e.modelRef, + prefabRef: e.prefabRef, + transform: { + position: [e.transform.position[0], e.transform.position[1], e.transform.position[2]], + rotation: [e.transform.rotation[0], e.transform.rotation[1], e.transform.rotation[2]], + scale: [e.transform.scale[0], e.transform.scale[1], e.transform.scale[2]], + }, + tint: e.tint, + tags: e.tags, + }; +} + +/// A neutral stage to author on: no terrain, no water, no rivers — just the parts +/// and the grid. A prefab that was authored against one world's hills would look +/// wrong everywhere else. +function prefabWorkspace(prefab: PrefabData): WorldData { + const w = createEmptyWorld('__prefab__', 'Prefab: ' + prefab.name); + const n = prefab.children.length; + const ents = new Array(n); + for (let i = 0; i < n; i++) ents[i] = childToEntity(prefab.children[i]); + w.entities = ents; + return w; +} + +// --- cycle rejection --------------------------------------------------------- + +/// Would placing prefab `candidateId` inside the one we are editing create a cycle? +/// +/// A prefab that contains itself — directly, or through a chain — expands forever at +/// load time and takes the game with it. The check has to be TRANSITIVE: A holding B +/// holding A is just as fatal as A holding A, and much easier to build by accident. +export function wouldCycle(state: EditorState, candidateId: string): boolean { + const editing = state.editingPrefab; + if (!editing) return false; + if (candidateId === editing.id) return true; + + // Walk everything the candidate transitively contains, looking for ourselves. + const seen = new Set(); + const stack: string[] = [candidateId]; + while (stack.length > 0) { + const id = stack.pop() as string; + if (seen.has(id)) continue; + seen.add(id); + const p = state.catalog.prefabs.get(id); + if (!p) continue; + for (let i = 0; i < p.children.length; i++) { + const ref = p.children[i].prefabRef; + if (ref === null) continue; + if (ref === editing.id) return true; + stack.push(ref); + } + } + return false; +} + +// --- mode transitions -------------------------------------------------------- + +function beginEditing(state: EditorState, prefab: PrefabData): void { + const ids: string[] = []; + state.selection.ids.forEach((v) => { ids.push(v); }); + const stash: PrefabStash = { + world: state.world, + worldPath: state.worldPath, + undoStack: state.undoStack, + redoStack: state.redoStack, + selectionIds: ids, + selectionPrimary: state.selection.primary, + activeTool: state.activeTool, + placeAssetRef: state.placeAssetRef, + modified: state.modified, + }; + state.prefabStash = stash; + + state.editingPrefab = prefab; + state.world = prefabWorkspace(prefab); + state.worldPath = null; + // A separate history. Undoing past the start of prefab mode and landing back + // inside the world's edits would be indefensible. + state.undoStack = []; + state.redoStack = []; state.selection.ids.clear(); state.selection.primary = null; - // Clear the viewport scene — we'll render a neutral background. + state.activeTool = 'select'; + state.modified = false; rebuildAllSceneNodes(state); + + // Frame the camera on the PARTS, not on whatever level we just came from. Without + // this the camera stays pointed at a 200 m arena and a three-prop prefab is three + // pixels of dust at the origin — which is exactly how it first came up. + framePrefab(state); +} + +/// Point the camera at the prefab's contents. An empty prefab gets a small default +/// box, so the first part you place lands in view instead of behind you. +function framePrefab(state: EditorState): void { + const ents = state.world.entities; + if (ents.length === 0) { + frameCameraOnBounds(state, [-2, 0, -2], [2, 2, 2]); + return; + } + let mnx = 1e9; let mny = 1e9; let mnz = 1e9; + let mxx = -1e9; let mxy = -1e9; let mxz = -1e9; + for (let i = 0; i < ents.length; i++) { + const p = ents[i].transform.position; + if (p[0] < mnx) mnx = p[0]; + if (p[1] < mny) mny = p[1]; + if (p[2] < mnz) mnz = p[2]; + if (p[0] > mxx) mxx = p[0]; + if (p[1] > mxy) mxy = p[1]; + if (p[2] > mxz) mxz = p[2]; + } + // Pad, so a single part (a degenerate box) still gets a sane orbit distance. + const pad = 1.5; + frameCameraOnBounds(state, + [mnx - pad, mny - pad, mnz - pad], + [mxx + pad, mxy + pad + 1.0, mxz + pad]); +} + +/// Enter prefab edit mode with a new, empty prefab. +export function enterNewPrefabMode(state: EditorState, id: string, name: string): void { + if (state.editingPrefab) return; + beginEditing(state, createEmptyPrefab(id, name)); } -// Enter prefab edit mode for an existing prefab (loaded from catalog). +/// Enter prefab edit mode for an existing prefab from the catalog. export function enterPrefabEditMode(state: EditorState, prefabId: string): void { + if (state.editingPrefab) return; const prefab = state.catalog.prefabs.get(prefabId); if (!prefab) return; - // Deep-clone the prefab so edits don't mutate the catalog copy. - state.editingPrefab = JSON.parse(JSON.stringify(prefab)) as PrefabData; - state.selection.ids.clear(); - state.selection.primary = null; - rebuildAllSceneNodes(state); + // Deep clone: edits must not touch the catalog copy until Save says so. + beginEditing(state, JSON.parse(JSON.stringify(prefab)) as PrefabData); } -// Exit prefab edit mode, returning to the world. +/// Leave prefab mode and put the world back exactly as it was. export function exitPrefabMode(state: EditorState): void { - state.editingPrefab = null; + const stash = state.prefabStash; + if (!stash) { state.editingPrefab = null; return; } + state.world = stash.world; + state.worldPath = stash.worldPath; + state.undoStack = stash.undoStack; + state.redoStack = stash.redoStack; state.selection.ids.clear(); - state.selection.primary = null; + for (let i = 0; i < stash.selectionIds.length; i++) { + state.selection.ids.add(stash.selectionIds[i]); + } + state.selection.primary = stash.selectionPrimary; + state.activeTool = stash.activeTool; + state.placeAssetRef = stash.placeAssetRef; + state.modified = stash.modified; + state.editingPrefab = null; + state.prefabStash = null; rebuildAllSceneNodes(state); } -// Save the current prefab to disk and update the catalog. +/// Write the prefab to disk and refresh the catalog, so it is immediately placeable +/// without a restart. export function savePrefabToDisk(state: EditorState): boolean { - if (!state.editingPrefab || !state.project) return false; const prefab = state.editingPrefab; + if (!prefab || !state.project) return false; + + // The children ARE the workspace entities. That is the whole design. + const n = state.world.entities.length; + const kids = new Array(n); + for (let i = 0; i < n; i++) kids[i] = entityToChild(state.world.entities[i]); + prefab.children = kids; + const path = state.project.prefabsDir + '/' + prefab.id + '.prefab.json'; const result = savePrefab(path, prefab); if (result.ok) { - // Update the catalog entry. state.catalog.prefabs.set(prefab.id, JSON.parse(JSON.stringify(prefab)) as PrefabData); if (!state.catalog.prefabOrder.includes(prefab.id)) { state.catalog.prefabOrder.push(prefab.id); } + state.modified = false; } return result.ok; } -// Add a child to the currently editing prefab. -export function addPrefabChild( - state: EditorState, - childId: string, - modelRef: string | null, - prefabRef: string | null, - position: Vec3Lit, -): void { - if (!state.editingPrefab) return; - const child: PrefabChild = { - id: childId, - modelRef: modelRef, - prefabRef: prefabRef, - transform: { - position: position, - rotation: [0, 0, 0], - scale: [1, 1, 1], - }, - tint: null, - tags: [], - }; - state.editingPrefab.children.push(child); -} +// --- per-frame --------------------------------------------------------------- -// Draw the prefab mode breadcrumb bar (called from main loop when editing). export function drawPrefabBreadcrumb(state: EditorState, screenW: number): void { if (!state.editingPrefab) return; - const y = Theme.toolbarHeight; - const text = 'Editing prefab: ' + state.editingPrefab.name + ' [ESC to exit, Ctrl+S to save]'; - drawText(text, 12, y + 4, Theme.fontSizeSmall, Theme.textAccent); + // Over the VIEWPORT, not at x=12 — which is the outliner, and the breadcrumb was + // landing straight on top of its heading. + const x = state.viewportLeft + 12; + const y = Theme.toolbarHeight + 6; + const n = state.world.entities.length; + const dirty = state.modified ? ' *' : ''; + const text = 'PREFAB: ' + state.editingPrefab.name + dirty + + ' (' + n + ' parts) [Ctrl+S save · ESC exit]'; + drawText(text, x, y, Theme.fontSizeSmall, Theme.textAccent); } -// Per-frame update. Handles ESC to exit and Ctrl+S to save. export function updatePrefabTool(state: EditorState): void { if (!state.editingPrefab) return; - - if (isKeyPressed(Key.ESCAPE)) { - exitPrefabMode(state); - } + if (isKeyPressed(Key.ESCAPE)) exitPrefabMode(state); } diff --git a/src/ui/layouts/asset-panel.ts b/src/ui/layouts/asset-panel.ts index 2bc60e6..e5b0e8b 100644 --- a/src/ui/layouts/asset-panel.ts +++ b/src/ui/layouts/asset-panel.ts @@ -3,9 +3,17 @@ import { getScreenWidth, getScreenHeight } from 'bloom'; import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, label, labelSmall, listRow, separator, toolButton } from '../widgets'; +import { beginPanel, endPanel, label, labelSmall, listRow, separator, toolButton, button } from '../widgets'; +import { textInput, Ref } from '../text-input'; import { Theme } from '../theme'; import { EditorState } from '../../state/editor-state'; +import { + enterNewPrefabMode, enterPrefabEditMode, exitPrefabMode, savePrefabToDisk, +} from '../../tools/prefab-tool'; + +// The name field for "New Prefab". Module-scope because it must survive between +// frames — an immediate-mode text field with a per-frame Ref forgets what you typed. +const newPrefabName: Ref = { value: '' }; export function drawAssetPanel(ui: UiContext, state: EditorState): void { const screenW = getScreenWidth(); @@ -95,22 +103,80 @@ function drawPrefabList( ui: UiContext, state: EditorState, panelX: number, panelW: number, ): void { + // While a prefab is open, this tab is where you get OUT of it — offering to open a + // second one from inside the first is how you lose work. + if (state.editingPrefab) { + labelSmall(ui, 'Editing: ' + state.editingPrefab.name); + labelSmall(ui, 'Place parts from the Models tab.'); + ui.cursorY += Theme.spacing; + if (button(ui, 'prefab_save', 'Save Prefab (Ctrl+S)')) savePrefabToDisk(state); + if (button(ui, 'prefab_exit', 'Exit (ESC)')) exitPrefabMode(state); + return; + } + + // --- New prefab: name field + button. + const fieldW = panelW - Theme.padding * 2; + textInput(ui, 'new_prefab_name', newPrefabName, ui.cursorX, ui.cursorY, fieldW); + ui.cursorY += Theme.rowHeight + Theme.spacing; + + const nm = newPrefabName.value.trim(); + if (button(ui, 'new_prefab', '+ New Prefab')) { + // An unnamed prefab is a file you will never find again. Fall back to a + // sequential name rather than refusing the click and saying nothing. + const name = nm.length > 0 ? nm : ('prefab_' + (state.catalog.prefabOrder.length + 1)); + enterNewPrefabMode(state, slugify(name), name); + newPrefabName.value = ''; + } + + separator(ui); + const order = state.catalog.prefabOrder; if (order.length === 0) { - labelSmall(ui, 'No prefabs found'); + labelSmall(ui, 'No prefabs yet.'); + labelSmall(ui, 'Name one above and hit + New Prefab.'); return; } + + let selectedId: string | null = null; for (let i = 0; i < order.length; i++) { const prefabId = order[i]; const prefab = state.catalog.prefabs.get(prefabId); if (!prefab) continue; const selected = state.placeAssetRef === 'prefab:' + prefabId; - if (listRow(ui, 'prefab_' + i, prefab.name, selected, 0)) { + if (selected) selectedId = prefabId; + const n = prefab.children.length; + const rowText = prefab.name + ' (' + n + ')'; + if (listRow(ui, 'prefab_' + i, rowText, selected, 0)) { state.placeAssetRef = 'prefab:' + prefabId; state.activeTool = 'place'; + selectedId = prefabId; } } + + // Edit the selected one. (No double-click: the UI context has no notion of one, + // and inventing a hidden gesture is worse than a visible button.) + if (selectedId !== null) { + ui.cursorY += Theme.spacing; + const p = state.catalog.prefabs.get(selectedId); + const label2 = 'Edit "' + (p ? p.name : selectedId) + '"'; + if (button(ui, 'edit_prefab', label2)) enterPrefabEditMode(state, selectedId); + } +} + +/// Lowercase, non-alphanumerics to underscores — this becomes a filename and a +/// stable id that world files reference by string. +function slugify(name: string): string { + let out = ''; + const lower = name.toLowerCase(); + for (let i = 0; i < lower.length; i++) { + const c = lower.charAt(i); + const code = lower.charCodeAt(i); + const isNum = code >= 48 && code <= 57; + const isAlpha = code >= 97 && code <= 122; + out = out + (isNum || isAlpha ? c : '_'); + } + return out.length > 0 ? out : 'prefab'; } function collectCategories(state: EditorState): string[] { diff --git a/src/ui/layouts/status-bar.ts b/src/ui/layouts/status-bar.ts index 9fb91cd..6c79eb8 100644 --- a/src/ui/layouts/status-bar.ts +++ b/src/ui/layouts/status-bar.ts @@ -27,6 +27,11 @@ export function drawStatusBar(state: EditorState): void { drawText('selected: ' + state.selection.primary, 220, ty, Theme.fontSizeSmall, Theme.textAccent); } + // Transient message — a refused action has to SAY it was refused. + if (state.statusMessageT > 0 && state.statusMessage.length > 0) { + drawText(state.statusMessage, 440, ty, Theme.fontSizeSmall, Theme.textError); + } + // Modified indicator. if (state.modified) { drawText('* modified', screenW - 100, ty, Theme.fontSizeSmall, Theme.textError); From b5e61892b6d7c59f39e321a7fe89a8f92efc657d Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 12 Jul 2026 23:12:14 +0200 Subject: [PATCH 2/3] Play-in-editor: run the real game on the level you are looking at A Play button in the toolbar (and Ctrl+R). Saves the level currently ON SCREEN -- not the one on disk; making you save first would defeat the point -- to a scratch world, and launches the game on it via `--world `. The fly-cam shows you geometry. It cannot show you whether the spawners spawn, whether the cover works, whether the arena has a shape -- the things a level actually has to do. And if testing a level means "add it to a manifest, restart the game, navigate the menu", it will not get tested often enough to matter. Needed two things that did not exist: engine EN-048 (launchProcess -- Perry's child_process.spawn COMPILES and then does nothing), and a `--world` override in the shooter. `playCommand` in editor.project.json opts a project in; without it there is no button, because a dead button is worse than no button. Verified end to end: editor -> scratch world (204,275 bytes) -> game launched on it, rendering the level the editor had just written. Claude-Session: https://claude.ai/code/session_01V3MihNxaRwMR8GjMPvMkRb --- PLAN.md | 12 +++++++++ src/io/project.ts | 2 ++ src/main.ts | 3 +++ src/playtest/launch.ts | 54 +++++++++++++++++++++++++++++++++++++++ src/state/editor-state.ts | 2 ++ src/ui/layouts/toolbar.ts | 13 ++++++++++ 6 files changed, 86 insertions(+) create mode 100644 src/playtest/launch.ts diff --git a/PLAN.md b/PLAN.md index 81794c9..1212078 100644 --- a/PLAN.md +++ b/PLAN.md @@ -204,6 +204,18 @@ The dividing line, for future schema questions: **lights are engine-universal - **D2. Kernel:** implement the `'paint'` branch in `applyBrush` (`brush-tool.ts:121-150`): add weight to the active layer's per-cell `weights[]` with the same radial falloff as sculpt kernels, renormalizing across layers per cell. Undo via a weights-snapshot command (mirror `TerrainStrokeCommand`). - **D3. Splat rendering.** ⚠ Investigate `bloom_compile_material` (`../engine/native/.../ffi_core/models.rs`, TS wrapper in the engine) — determine whether compiled materials support texture bindings. Target design: pack up to 4 layer weights into an RGBA weights texture regenerated on paint, sample each layer's texture by terrain UV × `tileScale`, blend by weights. Implement in the engine (shared by editor and `instantiateWorld`). If `compile_material` cannot bind textures, the required engine extension (a material-with-texture-slots FFI) is **in scope** — enumerate and build it rather than shipping a vertex-color approximation. Acceptance: paint two layers in the editor, save, load in a game via `instantiateWorld`, blended texturing visible in both. +### Play-in-editor — ✅ DONE (2026-07-12) + +> A **Play** button in the toolbar (and Ctrl+R): saves the level currently on screen +> — not the one on disk — to a scratch world, and launches the real game on it +> (`--world `, new in the shooter). The fly-cam shows you geometry; only the +> game shows you whether the spawners spawn and whether the arena has a shape. +> +> Needed two engine additions: **EN-048 `launchProcess`** (Perry's +> `child_process.spawn` compiles and then does nothing — undefined pid, no process), +> and the shooter's `--world` override. `playCommand` in `editor.project.json` opts a +> project in; no key, no button. + ### E. Prefab authoring — ✅ DONE (2026-07-12) > **Shipped.** `prefab-tool.ts` had existed for weeks with **zero call sites**, diff --git a/src/io/project.ts b/src/io/project.ts index 7076df8..dd21f8b 100644 --- a/src/io/project.ts +++ b/src/io/project.ts @@ -35,6 +35,7 @@ export function loadProject(state: EditorState): boolean { prefabsDir?: string; worldsDir?: string; defaultWorld?: string; + playCommand?: string; }; // Determine the root directory from the project file path. @@ -49,6 +50,7 @@ export function loadProject(state: EditorState): boolean { prefabsDir: joinPath(rootDir, raw.prefabsDir || 'assets/prefabs'), worldsDir: joinPath(rootDir, raw.worldsDir || 'assets/worlds'), defaultWorld: raw.defaultWorld || '', + playCommand: raw.playCommand || '', }; return true; diff --git a/src/main.ts b/src/main.ts index 6c1a7b0..8fecbeb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,6 +52,7 @@ import { import { drawEnvironmentPanel } from './ui/layouts/environment-panel'; import { drawBrushPanel } from './ui/layouts/brush-panel'; import { updatePlaytest, drawPlaytestOverlay } from './playtest/playtest'; +import { launchGame } from './playtest/launch'; import { frameCameraOnSelection, frameCameraOnWorld } from './viewport/frame'; import { addRecentProject } from './io/recent'; import { drawToolbar } from './ui/layouts/toolbar'; @@ -140,6 +141,8 @@ while (!windowShouldClose()) { if (state.editingPrefab) savePrefabToDisk(state); else saveCurrentWorld(state); } + // Ctrl+R — run the game on this level. (Ctrl+P is already the fly-cam.) + if (isKeyPressed(Key.R) && !state.editingPrefab) launchGame(state); } // Delete the selection — entity, water volume, or river. Delete only diff --git a/src/playtest/launch.ts b/src/playtest/launch.ts new file mode 100644 index 0000000..8742981 --- /dev/null +++ b/src/playtest/launch.ts @@ -0,0 +1,54 @@ +// Play-in-editor: run the real game on the level you are looking at. +// +// The fly-cam (playtest.ts) shows you the geometry. It cannot show you whether the +// spawners spawn, whether the cover works, whether the arena has a shape — the +// things a level actually has to do. For that you need the game, and if testing a +// level means "add it to a manifest, restart the game, navigate the menu", it will +// not get tested often enough to matter. +// +// So: save the current world to a scratch file, launch the game pointed straight at +// it (`--world `), and get out of the way. + +import { launchProcess } from 'bloom/core'; +import { saveWorld } from 'bloom/world'; +import { EditorState, setStatus } from '../state/editor-state'; +import { joinPath } from '../io/paths'; + +/// The scratch level. Deliberately inside the project's worlds dir rather than a +/// system temp dir: the game resolves asset paths relative to its own working +/// directory, so a world parked in %TEMP% would load and then fail to find a single +/// model. Gitignored. +const SCRATCH = '__playtest.world.json'; + +export function launchGame(state: EditorState): void { + const project = state.project; + if (!project) { + setStatus(state, 'No project — cannot launch'); + return; + } + if (project.playCommand.length === 0) { + setStatus(state, 'No "playCommand" in editor.project.json'); + return; + } + + // Save what is on screen, NOT what is on disk. The whole point is to test the + // edit you just made, and making you save first would defeat it. + const worldPath = joinPath(project.worldsDir, SCRATCH); + const res = saveWorld(worldPath, state.world); + if (!res.ok) { + setStatus(state, 'Play: could not write ' + worldPath); + return; + } + // Fire and forget. The editor must not block on the game, and must not die with + // it either: close the game and you are back in the editor, undo history intact. + // + // launchProcess, not child_process.spawn — Perry's spawn compiles and then does + // nothing at all (undefined pid, no process). See engine EN-048. + const cwd = project.rootDir.length > 0 ? project.rootDir : '.'; + const pid = launchProcess(project.playCommand, ['--world', worldPath], cwd); + if (pid === 0) { + setStatus(state, 'Play: could not launch ' + project.playCommand); + return; + } + setStatus(state, 'Playing this level in ' + project.playCommand + ' (pid ' + pid + ')'); +} diff --git a/src/state/editor-state.ts b/src/state/editor-state.ts index e67dccc..6cdecee 100644 --- a/src/state/editor-state.ts +++ b/src/state/editor-state.ts @@ -22,6 +22,8 @@ export interface Project { prefabsDir: string; worldsDir: string; defaultWorld: string; + /// Optional: the game binary to launch for play-in-editor. Empty = no Play button. + playCommand: string; } export interface ModelEntry { diff --git a/src/ui/layouts/toolbar.ts b/src/ui/layouts/toolbar.ts index b03afd4..340eb56 100644 --- a/src/ui/layouts/toolbar.ts +++ b/src/ui/layouts/toolbar.ts @@ -1,6 +1,7 @@ // Top toolbar: File actions (New, Open, Save) + tool selection buttons. import { drawRect, getScreenWidth } from 'bloom'; +import { launchGame } from '../../playtest/launch'; import { UiContext } from '../ui-context'; import { toolButton } from '../widgets'; import { Theme } from '../theme'; @@ -42,6 +43,18 @@ export function drawToolbar(ui: UiContext, state: EditorState): void { drawRect(x, y + 2, 1, Theme.buttonHeight - 4, Theme.border); x += 8; + // Play-in-editor. Runs the REAL game on the level currently on screen — not on + // what is saved, on what you are looking at. Only shown when the project actually + // names a game to run; a dead button is worse than no button. + if (state.project && state.project.playCommand.length > 0) { + if (toolButton(ui, 'tb_play', 'Play', x, y, bw + 8, false)) { + launchGame(state); + } + x += bw + 8 + gap; + drawRect(x, y + 2, 1, Theme.buttonHeight - 4, Theme.border); + x += 8; + } + // Tool buttons. Water/river were reachable only by hotkey (T/Y) before. const tools: [ToolId, string][] = [ ['select', 'Sel'], From 8336186ab455b6ec6829cb5a1b6302d402771050 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 13 Jul 2026 07:47:45 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Terrain=20painting=20(PLAN=20=C2=A7D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paint the ground. The layer list is in the brush panel; the layers are the world's own `terrain.layers`, and the swatch beside each is the MASK colour the viewport tints it with — the viewport shows coverage, the game shows the material. - `paint` is a fifth brush kind. LMB paints, Shift+LMB erases. - `+ Add layer` picks a texture from the project's textures dir (new `texturesDir` key, default `assets/textures`). The catalog lists textures without loading them — a splat layer only ever stores a path, and decoding forty 2K PNGs so a panel can print their names would be the most expensive no-op in the program. - A splat is a PARTITION: painting grass in pushes everything else out, or the weights sum past 1 and a cell that is 90% grass AND 90% rock renders as a washed-out average of every texture at once. Erase drives the active layer to zero and does NOT push the others up, so coverage falls and the ground fades back to the game's procedural blend rather than to a bald patch. - Undo snapshots EVERY layer, not just the one you were holding. - Layers are rebuilt explicitly rather than with `.push()`/`.splice()`: they came from JSON.parse and get serialized back to disk (perry-quirks #2). +29 self-test assertions (116 total), covering the partition invariant, undo of a removed-but-painted layer, and the vertex-colour preview — including that adding an unpainted layer changes nothing. Claude-Session: https://claude.ai/code/session_01V3MihNxaRwMR8GjMPvMkRb --- PLAN.md | 41 ++++++++- src/io/asset-catalog.ts | 21 +++++ src/io/project.ts | 2 + src/state/commands/terrain-paint.ts | 133 ++++++++++++++++++++++++++++ src/state/editor-state.ts | 3 + src/tests/self-tests.ts | 133 ++++++++++++++++++++++++++++ src/tools/brush-tool.ts | 123 +++++++++++++++++++++++-- src/ui/layouts/brush-panel.ts | 129 ++++++++++++++++++++++++--- src/ui/layouts/inspector.ts | 4 +- src/viewport/ray.ts | 4 +- 10 files changed, 565 insertions(+), 28 deletions(-) create mode 100644 src/state/commands/terrain-paint.ts diff --git a/PLAN.md b/PLAN.md index 1212078..d14e924 100644 --- a/PLAN.md +++ b/PLAN.md @@ -198,11 +198,44 @@ The dividing line, for future schema questions: **lights are engine-universal - Editor: Light tool (click to place), Lights section in the outliner, inspector (position / colour / intensity / range), delete with index-preserving undo, and a wire marker at each light plus a range sphere when selected — a light has no mesh, so without markers you cannot see or click one. - Shooter: `arena_02` migrated to v2 (5 lights lifted out of `entities`); its baker reads `world.lights` and still falls back to the v1 entity form. **The generated runtime data is byte-identical apart from one comment** — the migration is semantically a no-op for the game. -### D. Terrain paint & layers +### D. Terrain paint & layers — ✅ DONE (2026-07-13) -- **D1. UI:** layer list in the brush panel — add/remove `TerrainLayer` (pick `textureRef` via the asset catalog; ⚠ the catalog currently only scans models/prefabs — extend it to a textures dir, adding a `texturesDir` key to `editor.project.json` with a sensible default), set `tileScale`, select active layer (`state.brush.activeLayerIdx` already exists), and expose the `'paint'` kind button (`brush-panel.ts:22`). -- **D2. Kernel:** implement the `'paint'` branch in `applyBrush` (`brush-tool.ts:121-150`): add weight to the active layer's per-cell `weights[]` with the same radial falloff as sculpt kernels, renormalizing across layers per cell. Undo via a weights-snapshot command (mirror `TerrainStrokeCommand`). -- **D3. Splat rendering.** ⚠ Investigate `bloom_compile_material` (`../engine/native/.../ffi_core/models.rs`, TS wrapper in the engine) — determine whether compiled materials support texture bindings. Target design: pack up to 4 layer weights into an RGBA weights texture regenerated on paint, sample each layer's texture by terrain UV × `tileScale`, blend by weights. Implement in the engine (shared by editor and `instantiateWorld`). If `compile_material` cannot bind textures, the required engine extension (a material-with-texture-slots FFI) is **in scope** — enumerate and build it rather than shipping a vertex-color approximation. Acceptance: paint two layers in the editor, save, load in a game via `instantiateWorld`, blended texturing visible in both. +> Paint the ground. The layer list lives in the brush panel; layers are the world's +> own `terrain.layers`, and the swatch beside each is the **mask colour** the +> viewport tints it with — the viewport shows you *coverage*, the game shows you the +> material. +> +> - **D1 UI** — `+ Add layer` picks a texture from the project's textures dir (new +> `texturesDir` key, default `assets/textures`; the catalog lists them without +> loading them — a splat layer only ever stores a path). Select the active layer, +> delete a layer, `paint` is a fifth brush kind. +> - **D2 Kernel** — `paintCell` in `brush-tool.ts`. LMB paints, **Shift+LMB erases**. +> A splat is a *partition*: painting grass in must push everything else out, or the +> weights sum past 1 and a cell that is 90% grass AND 90% rock renders as a washed-out +> average of every texture at once. Erase drives the active layer to zero and does NOT +> push the others up, so coverage falls and the ground fades back to the game's +> procedural blend rather than to a bald patch. Undo snapshots **every** layer, not +> just the one you were holding. +> - **D3 Rendering** — no new material system was needed. The shooter already had a +> 4-layer triplanar splat shader whose weights were computed procedurally (slope, +> moisture noise, distance to the river) and authored *nowhere*. The painted weights +> now arrive as one RGBA8 texel per terrain cell and are mixed **over** that +> procedural blend by coverage. So a cell nobody painted keeps the procedural look +> exactly — which is why turning this on changed neither shipped arena by a pixel. +> +> Transport needed one engine addition: **EN-049 `createTextureArrayFromTexels`**. The +> existing byte-array FFI takes a raw pointer, and Perry cannot pass an array to one +> ("Expected safe integer for native i64 parameter") — so it was uncallable, and every +> caller had ended up on `createTextureArrayFromFiles`, which is right for art and +> useless for data computed at load. +> +> **Verified end-to-end in the game**, not just in the editor: `tools/paint-test-world.ts` +> writes the same data the brush does, and the shooter renders it. Editor preview is the +> heightmap mesh's vertex colour (`buildHeightmapMesh` blends the mask palette by weight). +> +> Cost along the way: **EN-050** — Perry miscompiled the engine's `clamp`, so every splat +> weight quantised to `0` and painted terrain loaded unpainted. See shooter +> `docs/perry-quirks.md` #8; it is pinned by the `testSplatPaintPartition` self-test. ### Play-in-editor — ✅ DONE (2026-07-12) diff --git a/src/io/asset-catalog.ts b/src/io/asset-catalog.ts index bbf0c1a..fd32bde 100644 --- a/src/io/asset-catalog.ts +++ b/src/io/asset-catalog.ts @@ -20,9 +20,30 @@ export function loadAssetCatalog(state: EditorState): void { loadModels(state, project); loadPrefabs(state, project); + loadTextures(state, project); invalidatePrefabRegistry(); } +/// List the texture files, but do NOT load them. +/// +/// Splat layers only ever store a path — the game decodes the image, the editor +/// shows a mask colour. Loading forty 2K PNGs into VRAM at startup so the layer +/// panel can print their names would be the most expensive no-op in the program. +function loadTextures(state: EditorState, project: Project): void { + let files: string[]; + try { + files = readdirSync(project.texturesDir) as string[]; + } catch (e) { + return; // No textures dir — the layer picker just comes up empty. + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (!file.endsWith('.png') && !file.endsWith('.jpg') && !file.endsWith('.jpeg')) continue; + state.catalog.textureOrder.push(joinPath(project.texturesDir, file)); + } +} + function loadModels(state: EditorState, project: Project): void { let files: string[]; try { diff --git a/src/io/project.ts b/src/io/project.ts index dd21f8b..595c43c 100644 --- a/src/io/project.ts +++ b/src/io/project.ts @@ -34,6 +34,7 @@ export function loadProject(state: EditorState): boolean { modelsDir?: string; prefabsDir?: string; worldsDir?: string; + texturesDir?: string; defaultWorld?: string; playCommand?: string; }; @@ -49,6 +50,7 @@ export function loadProject(state: EditorState): boolean { modelsDir: joinPath(rootDir, raw.modelsDir || 'assets/models'), prefabsDir: joinPath(rootDir, raw.prefabsDir || 'assets/prefabs'), worldsDir: joinPath(rootDir, raw.worldsDir || 'assets/worlds'), + texturesDir: joinPath(rootDir, raw.texturesDir || 'assets/textures'), defaultWorld: raw.defaultWorld || '', playCommand: raw.playCommand || '', }; diff --git a/src/state/commands/terrain-paint.ts b/src/state/commands/terrain-paint.ts new file mode 100644 index 0000000..d8a6e7f --- /dev/null +++ b/src/state/commands/terrain-paint.ts @@ -0,0 +1,133 @@ +// Splat-layer commands: add a layer, remove a layer, and paint a stroke. +// +// A paint stroke snapshots EVERY layer's weights, not just the active one. +// Painting grass onto a cell necessarily takes weight away from the dirt that +// was there — a splat is a partition of unity, so one brush dab writes to all +// of them. Undoing a stroke that only restored the layer you were holding would +// leave the others carrying the erosion, and the terrain would drift a little +// further from correct with every Ctrl+Z. + +import { TerrainLayer, createTerrainLayer } from 'bloom/world'; +import { EditorState, Command } from '../editor-state'; + +/// Copy the weights of every layer. Used for both halves of a stroke. +export function snapshotWeights(layers: TerrainLayer[]): number[][] { + const out = new Array(layers.length); + for (let i = 0; i < layers.length; i++) out[i] = layers[i].weights.slice(); + return out; +} + +/// Rebuild `t.layers` with `add` appended, or with index `drop` removed. +/// +/// Explicit construction rather than `.push()` / `.splice()`: `t.layers` came +/// out of `JSON.parse`, and a JSON-parsed array grown with `.push()` is the +/// exact shape Perry's array bridge gets wrong (`.length` reports the literal +/// init size — perry-quirks #2). The undo stack can afford `.push()` because it +/// never crosses the FFI; a layer list that gets serialized back to disk cannot. +function rebuildLayers(layers: TerrainLayer[], add: TerrainLayer | null, drop: number, at: number): TerrainLayer[] { + const n = layers.length + (add !== null ? 1 : 0) - (drop >= 0 ? 1 : 0); + const out = new Array(n); + let w = 0; + for (let i = 0; i < layers.length; i++) { + if (i === drop) continue; + if (add !== null && w === at) { out[w] = add; w++; } + out[w] = layers[i]; + w++; + } + if (add !== null && w === at) { out[w] = add; w++; } + return out; +} + +function restoreWeights(state: EditorState, snap: number[][]): void { + const t = state.world.terrain; + if (!t) return; + // A stroke's snapshot is only valid against the layer set it was taken from. + // If layers have since been added or removed the stroke is unreplayable, and + // silently writing a stale snapshot over a different layer set would scramble + // the paint. Undo/redo interleaved with add/remove is the case; refuse it. + if (snap.length !== t.layers.length) return; + for (let i = 0; i < snap.length; i++) t.layers[i].weights = snap[i].slice(); + state.pendingTerrainRebuild = true; +} + +export class TerrainPaintCommand implements Command { + readonly label: string; + private before: number[][]; + private after: number[][]; + + constructor(before: number[][], after: number[][]) { + this.label = 'Paint terrain'; + this.before = before; + this.after = after; + } + + do(state: EditorState): void { restoreWeights(state, this.after); } + undo(state: EditorState): void { restoreWeights(state, this.before); } +} + +export class AddTerrainLayerCommand implements Command { + readonly label: string; + private id: string; + private textureRef: string; + private tileScale: number; + + constructor(id: string, textureRef: string, tileScale: number) { + this.label = 'Add terrain layer'; + this.id = id; + this.textureRef = textureRef; + this.tileScale = tileScale; + } + + do(state: EditorState): void { + const t = state.world.terrain; + if (!t) return; + const layer = createTerrainLayer(t, this.id, this.textureRef, this.tileScale); + t.layers = rebuildLayers(t.layers, layer, -1, t.layers.length); + // Select what you just made — otherwise the first thing every user does + // after adding a layer is paint with the previous one. + state.brush.activeLayerIdx = t.layers.length - 1; + state.pendingTerrainRebuild = true; + } + + undo(state: EditorState): void { + const t = state.world.terrain; + if (!t || t.layers.length === 0) return; + t.layers = rebuildLayers(t.layers, null, t.layers.length - 1, -1); + if (state.brush.activeLayerIdx >= t.layers.length) { + state.brush.activeLayerIdx = t.layers.length - 1; + } + state.pendingTerrainRebuild = true; + } +} + +export class RemoveTerrainLayerCommand implements Command { + readonly label: string; + private idx: number; + private removed: TerrainLayer | null; + + constructor(idx: number) { + this.label = 'Remove terrain layer'; + this.idx = idx; + this.removed = null; + } + + do(state: EditorState): void { + const t = state.world.terrain; + if (!t || this.idx < 0 || this.idx >= t.layers.length) return; + // Hold the whole layer, weights included: removing a painted layer and + // undoing must give the paint back, not an empty layer with the right name. + this.removed = t.layers[this.idx]; + t.layers = rebuildLayers(t.layers, null, this.idx, -1); + if (state.brush.activeLayerIdx >= t.layers.length) { + state.brush.activeLayerIdx = t.layers.length - 1; + } + state.pendingTerrainRebuild = true; + } + + undo(state: EditorState): void { + const t = state.world.terrain; + if (!t || !this.removed) return; + t.layers = rebuildLayers(t.layers, this.removed, -1, this.idx); + state.pendingTerrainRebuild = true; + } +} diff --git a/src/state/editor-state.ts b/src/state/editor-state.ts index 6cdecee..5e8be85 100644 --- a/src/state/editor-state.ts +++ b/src/state/editor-state.ts @@ -21,6 +21,7 @@ export interface Project { modelsDir: string; // Resolved absolute. prefabsDir: string; worldsDir: string; + texturesDir: string; // Splat-layer source textures. defaultWorld: string; /// Optional: the game binary to launch for play-in-editor. Empty = no Play button. playCommand: string; @@ -50,6 +51,7 @@ export class AssetCatalog { prefabs: Map; // Key = prefab id. modelOrder: string[]; // Stable iteration order for the panel. prefabOrder: string[]; + textureOrder: string[]; // relPaths under texturesDir; splat-layer sources. filter: string; // Substring filter for the asset panel. activeCategory: string; // "all" or a category slug. activeTab: number; // 0 = Models, 1 = Prefabs. @@ -59,6 +61,7 @@ export class AssetCatalog { this.prefabs = new Map(); this.modelOrder = []; this.prefabOrder = []; + this.textureOrder = []; this.filter = ''; this.activeCategory = 'all'; this.activeTab = 0; diff --git a/src/tests/self-tests.ts b/src/tests/self-tests.ts index 3cf781d..c6fd9a1 100644 --- a/src/tests/self-tests.ts +++ b/src/tests/self-tests.ts @@ -6,6 +6,7 @@ import { WorldData, PrefabData, WaterVolume, createEmptyWorld, createEntity } fr import { validateWorld, validatePrefab } from 'bloom/world'; import { migrateWorldData } from 'bloom/world'; import { buildHeightmapMesh, sampleHeight, defaultTerrain } from 'bloom/world'; +import { createTerrainLayer, quantizeWeight, terrainLayerMaskColor } from 'bloom/world'; import { expandPrefab, createPrefabRegistry, registerPrefab, PrefabLeaf } from 'bloom/world'; import { createEditorState, nextCounterId, @@ -17,6 +18,10 @@ import { CreateEntityCommand } from '../state/commands/create-entity'; import { TransformEntityCommand } from '../state/commands/transform-entity'; import { CreateTerrainCommand } from '../state/commands/create-terrain'; import { SetUserDataCommand } from '../state/commands/set-userdata'; +import { + AddTerrainLayerCommand, RemoveTerrainLayerCommand, TerrainPaintCommand, snapshotWeights, +} from '../state/commands/terrain-paint'; +import { paintCell } from '../tools/brush-tool'; import { enterNewPrefabMode, enterPrefabEditMode, exitPrefabMode, wouldCycle, } from '../tools/prefab-tool'; @@ -53,6 +58,9 @@ export function runSelfTests(): number { testUserDataCommand(); testWaterCommands(); testLightMigration(); + testSplatLayerCommands(); + testSplatPaintPartition(); + testSplatMaskPreview(); console.log('self-tests: ' + passed + ' passed, ' + failed + ' failed'); return failed; @@ -441,3 +449,128 @@ function testPathJoinIdentity(): void { assert(catalogKey === 'assets/models/prop_tree.glb', 'paths: catalog key MATCHES the world modelRef'); } + +// ---- Splat layers (PLAN §D) ------------------------------------------------- + +// Add / remove / paint, and what undo owes you afterwards. +// +// The one that matters: removing a PAINTED layer and undoing must give the paint +// back. A remove that only remembered the layer's name and texture would undo to +// a layer that is correctly named, correctly textured, and blank — and the paint +// would be gone with no error anywhere. +function testSplatLayerCommands(): void { + const state = createEditorState(); + runCommand(state, new CreateTerrainCommand()); + const t = state.world.terrain as any; + const cells = t.width * t.depth; + + runCommand(state, new AddTerrainLayerCommand('grass', 'assets/textures/g.png', 1.0)); + assert(t.layers.length === 1, 'splat: layer added'); + assert(t.layers[0].weights.length === cells, 'splat: weights sized to the grid'); + assert(t.layers[0].weights[0] === 0, 'splat: a new layer starts unpainted'); + assert(state.brush.activeLayerIdx === 0, 'splat: the new layer is selected'); + + runCommand(state, new AddTerrainLayerCommand('rock', 'assets/textures/r.png', 1.0)); + assert(t.layers.length === 2 && state.brush.activeLayerIdx === 1, 'splat: second layer selected'); + + // Paint layer 1 (rock) into cell 5, through a real command. + const before = snapshotWeights(t.layers); + paintCell(t.layers, 1, 5, 1.0, 1.0); + runCommand(state, new TerrainPaintCommand(before, snapshotWeights(t.layers))); + assert(t.layers[1].weights[5] === 1, 'splat: paint wrote the active layer'); + + undo(state); + assert(t.layers[1].weights[5] === 0, 'splat: undo reverts the stroke'); + redo(state); + assert(t.layers[1].weights[5] === 1, 'splat: redo replays the stroke'); + + // Remove the painted layer, then undo. The paint must come back. + runCommand(state, new RemoveTerrainLayerCommand(1)); + assert(t.layers.length === 1, 'splat: layer removed'); + assert(state.brush.activeLayerIdx === 0, 'splat: selection clamped after removal'); + undo(state); + assert(t.layers.length === 2, 'splat: undo restores the layer'); + assert(t.layers[1].weights[5] === 1, 'splat: undo restores the layer WITH its paint'); + + // Undoing an add must not leave the brush pointing past the end of the list. + runCommand(state, new AddTerrainLayerCommand('dirt', 'assets/textures/d.png', 1.0)); + assert(state.brush.activeLayerIdx === 2, 'splat: third layer selected'); + undo(state); + assert(t.layers.length === 2, 'splat: undo removes the added layer'); + assert(state.brush.activeLayerIdx < t.layers.length, 'splat: activeLayerIdx stays in range'); +} + +// A splat is a partition of unity. If the weights at a cell can sum past 1, the +// shader blends 90% grass with 90% rock and the terrain goes uniformly grey — +// which looks like a lighting bug and is not one. +function testSplatPaintPartition(): void { + const t = defaultTerrain(); + const layers = [ + createTerrainLayer(t, 'a', 'a.png', 1), + createTerrainLayer(t, 'b', 'b.png', 1), + createTerrainLayer(t, 'c', 'c.png', 1), + ]; + const idx = 42; + + // Fill the cell with b, then paint a over it at full strength. + paintCell(layers, 1, idx, 1.0, 1.0); + assert(layers[1].weights[idx] === 1, 'partition: b painted to 1'); + paintCell(layers, 0, idx, 1.0, 1.0); + assert(layers[0].weights[idx] === 1, 'partition: a painted to 1'); + assert(layers[1].weights[idx] === 0, 'partition: a fully displaced b'); + + // A partial dab must leave the total at exactly 1, not above it. + const l2 = [ + createTerrainLayer(t, 'a', 'a.png', 1), + createTerrainLayer(t, 'b', 'b.png', 1), + ]; + paintCell(l2, 1, idx, 1.0, 1.0); // b = 1 + paintCell(l2, 0, idx, 1.0, 0.25); // a = 0.25 -> b must fall to 0.75 + const sum = l2[0].weights[idx] + l2[1].weights[idx]; + assert(Math.abs(sum - 1.0) < 0.002, 'partition: weights sum to 1 after a partial dab (got ' + sum + ')'); + assert(Math.abs(l2[1].weights[idx] - 0.75) < 0.002, 'partition: b scaled proportionally'); + + // Erase drives the active layer to 0 and does NOT push the others back up — + // falling coverage is what lets the game blend back to its procedural base. + paintCell(l2, 0, idx, 0.0, 1.0); + assert(l2[0].weights[idx] === 0, 'partition: erase zeroes the active layer'); + assert(Math.abs(l2[1].weights[idx] - 0.75) < 0.002, 'partition: erase leaves the others alone'); + const cov = l2[0].weights[idx] + l2[1].weights[idx]; + assert(cov < 1.0, 'partition: erasing lowers total coverage'); + + // Quantization is what keeps the world file from becoming a megabyte of noise. + assert(quantizeWeight(0.5019607843137255) === 0.502, 'partition: weights quantize to 3dp'); + assert(quantizeWeight(-1) === 0 && quantizeWeight(2) === 1, 'partition: weights clamp to 0..1'); +} + +// The editor's paint preview is the heightmap mesh's vertex colour. If that +// stays grey no matter what you paint, the paint tool is invisible and therefore +// useless — so pin it. +function testSplatMaskPreview(): void { + const t = defaultTerrain(); + const bare = buildHeightmapMesh(t); + const STRIDE = 12; + const r0 = bare.vertices[6]; // vertex 0, colour R + + t.layers = [createTerrainLayer(t, 'rock', 'rock.png', 1)]; + const unpainted = buildHeightmapMesh(t); + assert(unpainted.vertices[6] === r0, + 'mask: adding an unpainted layer does not change the terrain'); + + // Paint layer 0 fully at cell 0. Vertex 0's colour must become the mask colour. + t.layers[0].weights[0] = 1.0; + const painted = buildHeightmapMesh(t); + const want = terrainLayerMaskColor(0); + assert(Math.abs(painted.vertices[6] - want[0]) < 0.001 && + Math.abs(painted.vertices[7] - want[1]) < 0.001 && + Math.abs(painted.vertices[8] - want[2]) < 0.001, + 'mask: a fully-painted cell takes the layer mask colour'); + // ...and its neighbour, which nobody painted, must not have moved. + assert(painted.vertices[STRIDE + 6] === r0, + 'mask: an unpainted cell keeps the bare colour'); + + // A short weights array (hand-edited file, resized grid) must not produce NaN. + t.layers[0].weights = [1.0]; + const ragged = buildHeightmapMesh(t); + assert(ragged.vertices[STRIDE + 6] === r0, 'mask: a short weights array degrades to bare, not NaN'); +} diff --git a/src/tools/brush-tool.ts b/src/tools/brush-tool.ts index 4503a4a..99c9a5d 100644 --- a/src/tools/brush-tool.ts +++ b/src/tools/brush-tool.ts @@ -8,10 +8,11 @@ // snapshotted. On stroke end, a TerrainStrokeCommand is emitted that holds // both the before and after snapshots. Undo restores the before snapshot. -import { isMouseButtonDown, isMouseButtonPressed, isMouseButtonReleased, MouseButton, getMouseX, getMouseY, getScreenWidth, getScreenHeight, getDeltaTime } from 'bloom'; -import { raycastTerrain, TerrainData } from 'bloom/world'; +import { isMouseButtonDown, isMouseButtonPressed, isMouseButtonReleased, MouseButton, getMouseX, getMouseY, getScreenWidth, getScreenHeight, getDeltaTime, isKeyDown, Key } from 'bloom'; +import { raycastTerrain, TerrainData, TerrainLayer, quantizeWeight } from 'bloom/world'; import { EditorState } from '../state/editor-state'; import { runCommand } from '../state/commands'; +import { TerrainPaintCommand, snapshotWeights } from '../state/commands/terrain-paint'; import { mouseToWorldRay } from '../viewport/ray'; import { Command } from '../state/editor-state'; @@ -48,11 +49,13 @@ class TerrainStrokeCommand implements Command { interface BrushToolState { stroking: boolean; heightsSnapshot: number[] | null; + weightsSnapshot: number[][] | null; // Paint strokes: every layer, see terrain-paint.ts. } const brushState: BrushToolState = { stroking: false, heightsSnapshot: null, + weightsSnapshot: null, }; // ---- Update ---------------------------------------------------------------- @@ -86,15 +89,33 @@ export function updateBrushTool(state: EditorState): void { const hit = raycastTerrain(terrain, ray.origin, ray.direction, 200, 0.5); if (!hit.hit) return; + const painting = state.brush.kind === 'paint'; + + // Painting with no layer to paint into is a no-op, not a crash. The panel + // says so; do not let a click through to an out-of-range index. + if (painting && (terrain.layers.length === 0 || + state.brush.activeLayerIdx < 0 || + state.brush.activeLayerIdx >= terrain.layers.length)) { + return; + } + // Start stroke. if (isMouseButtonPressed(MouseButton.LEFT)) { brushState.stroking = true; - brushState.heightsSnapshot = terrain.heights.slice(); + if (painting) { + brushState.weightsSnapshot = snapshotWeights(terrain.layers); + } else { + brushState.heightsSnapshot = terrain.heights.slice(); + } } // Apply brush while mouse is held. if (brushState.stroking && isMouseButtonDown(MouseButton.LEFT)) { - applyBrush(state, terrain, hit.cellX, hit.cellZ); + if (painting) { + applyPaint(state, terrain, hit.cellX, hit.cellZ); + } else { + applyBrush(state, terrain, hit.cellX, hit.cellZ); + } state.pendingTerrainRebuild = true; } @@ -105,14 +126,23 @@ export function updateBrushTool(state: EditorState): void { } function endStroke(state: EditorState): void { - if (brushState.heightsSnapshot && state.world.terrain) { - runCommand(state, new TerrainStrokeCommand( - brushState.heightsSnapshot, - state.world.terrain.heights.slice(), - )); + const t = state.world.terrain; + if (t) { + if (brushState.weightsSnapshot) { + runCommand(state, new TerrainPaintCommand( + brushState.weightsSnapshot, + snapshotWeights(t.layers), + )); + } else if (brushState.heightsSnapshot) { + runCommand(state, new TerrainStrokeCommand( + brushState.heightsSnapshot, + t.heights.slice(), + )); + } } brushState.stroking = false; brushState.heightsSnapshot = null; + brushState.weightsSnapshot = null; } // ---- Brush kernels --------------------------------------------------------- @@ -148,6 +178,81 @@ function applyBrush(state: EditorState, t: TerrainData, cx: number, cz: number): } } +// ---- Paint ----------------------------------------------------------------- + +/// Paint the active splat layer under the brush. Hold Shift to erase. +/// +/// A splat is a partition: the four (or eight) weights at a cell say what +/// fraction of the ground is grass, dirt, rock. So painting grass IN must push +/// everything else OUT, or the weights sum past 1 and the shader renders a cell +/// that is 90% grass AND 90% rock — which reads as a washed-out average of every +/// texture at once, the classic "my terrain went grey" bug. +/// +/// Erasing does NOT push the others back up. It drives the active layer toward +/// zero and leaves the rest where they are, so the cell's total coverage falls — +/// and coverage is exactly what the shooter's shader uses to blend back to its +/// procedural slope/moisture blend. Erase everything and you get the untouched +/// terrain, not a bald patch. +function applyPaint(state: EditorState, t: TerrainData, cx: number, cz: number): void { + const brush = state.brush; + const active = brush.activeLayerIdx; + const layers = t.layers; + const n = layers.length; + const r = Math.ceil(brush.radius); + const dt = getDeltaTime(); + const erase = isKeyDown(Key.LeftShift) || isKeyDown(Key.RightShift); + const target = erase ? 0.0 : 1.0; + + for (let dz = -r; dz <= r; dz++) { + for (let dx = -r; dx <= r; dx++) { + const x = cx + dx; + const z = cz + dz; + if (x < 0 || x >= t.width || z < 0 || z >= t.depth) continue; + + const dist = Math.sqrt(dx * dx + dz * dz); + if (dist > brush.radius) continue; + + const falloff = 1.0 - dist / brush.radius; + const idx = z * t.width + x; + + // Rate is deliberately brisk (×4): a splat weight only has to travel 0..1, + // where a sculpt brush moves metres, so the same strength value would feel + // dead here. + const amount = Math.min(brush.strength * falloff * dt * 4.0, 1.0); + paintCell(layers, active, idx, target, amount); + } + } +} + +/// Move one cell's splat weights toward `target` for layer `active`, then keep +/// the cell a valid partition. Pure, and exported so the self-tests can exercise +/// the part that is actually easy to get wrong without a mouse and a frame clock. +export function paintCell( + layers: TerrainLayer[], active: number, idx: number, + target: number, amount: number, +): void { + const n = layers.length; + const prev = layers[active].weights[idx]; + const next = quantizeWeight(prev + (target - prev) * amount); + layers[active].weights[idx] = next; + + // Re-normalize the others so the cell's total never exceeds 1. Scaling them + // proportionally (rather than subtracting equally) is what keeps a 70/30 + // dirt/rock mix reading as 70/30 after grass is painted over the top of it. + let others = 0; + for (let l = 0; l < n; l++) { + if (l !== active) others = others + layers[l].weights[idx]; + } + const room = 1.0 - next; + if (others > room && others > 0.0) { + const k = room / others; + for (let l = 0; l < n; l++) { + if (l === active) continue; + layers[l].weights[idx] = quantizeWeight(layers[l].weights[idx] * k); + } + } +} + function avgNeighbors(t: TerrainData, x: number, z: number): number { let sum = 0; let count = 0; diff --git a/src/ui/layouts/brush-panel.ts b/src/ui/layouts/brush-panel.ts index ceb700f..85dab09 100644 --- a/src/ui/layouts/brush-panel.ts +++ b/src/ui/layouts/brush-panel.ts @@ -1,20 +1,45 @@ // Brush settings panel — visible when the brush tool is active. -// Controls: brush kind radio, radius slider, strength slider, flatten target. +// +// Two brushes share it: the sculpt brush (raise/lower/smooth/flatten), which +// moves heights, and the paint brush, which writes splat weights. Paint needs a +// layer to paint INTO, so when it is selected the panel grows a layer list: the +// layers are the world's (`terrain.layers`), the swatch beside each is the mask +// colour the viewport tints it with, and `+ Add layer` picks a texture out of +// the project's textures dir. +// +// The swatch is a MASK colour, not a thumbnail of the texture — see +// `terrainLayerMaskColor` in the engine. The viewport shows you *coverage*; the +// game is what shows you the material. +import { drawRect, drawText } from 'bloom'; +import { terrainLayerMaskColor } from 'bloom/world'; import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, labelSmall, separator, dragFloat, toggleButton, button, Ref } from '../widgets'; -import { Theme } from '../theme'; +import { beginPanel, endPanel, label, labelSmall, separator, dragFloat, toggleButton, button, listRow, Ref } from '../widgets'; +import { Theme, UiColor } from '../theme'; import { EditorState, BrushSettings } from '../../state/editor-state'; import { runCommand } from '../../state/commands'; import { CreateTerrainCommand } from '../../state/commands/create-terrain'; +import { AddTerrainLayerCommand, RemoveTerrainLayerCommand } from '../../state/commands/terrain-paint'; +import { basenameNoExt } from '../../io/paths'; + +// Is the "+ Add layer" texture picker open? Panel-local: it is not world data, +// it must not be undoable, and it must not survive a tool switch. +let pickingTexture = false; + +function maskColor(i: number): UiColor { + const c = terrainLayerMaskColor(i); + return { r: c[0] * 255, g: c[1] * 255, b: c[2] * 255, a: 255 }; +} export function drawBrushPanel(ui: UiContext, state: EditorState): void { - if (state.activeTool !== 'brush') return; + if (state.activeTool !== 'brush') { + pickingTexture = false; + return; + } const px = Theme.outlinerWidth + 10; const py = Theme.toolbarHeight + 10; - const pw = 220; - const ph = 240; + const pw = 240; // Terrain-less world: offer explicit creation instead of sculpting into a // silently materialized heightmap. @@ -29,33 +54,40 @@ export function drawBrushPanel(ui: UiContext, state: EditorState): void { return; } - beginPanel(ui, 'brush_panel', px, py, pw, ph, 'Brush Settings'); - + const terrain = state.world.terrain; const brush = state.brush; + const painting = brush.kind === 'paint'; - // Kind toggles. - const kinds: BrushSettings['kind'][] = ['raise', 'lower', 'smooth', 'flatten']; + // The panel grows with the layer list, and again with the picker. A fixed 240 + // was fine when it held four toggles and two sliders. + let ph = 250; + if (painting) { + ph = ph + 60 + terrain.layers.length * Theme.rowHeight; + if (pickingTexture) ph = ph + 40 + state.catalog.textureOrder.length * Theme.rowHeight; + } + + beginPanel(ui, 'brush_panel', px, py, pw, ph, 'Brush Settings'); + + const kinds: BrushSettings['kind'][] = ['raise', 'lower', 'smooth', 'flatten', 'paint']; for (let i = 0; i < kinds.length; i++) { if (toggleButton(ui, 'brush_kind_' + kinds[i], kinds[i], brush.kind === kinds[i])) { brush.kind = kinds[i]; + if (brush.kind !== 'paint') pickingTexture = false; } } separator(ui); - // Radius. const radiusRef: Ref = { value: brush.radius }; if (dragFloat(ui, 'brush_radius', 'Radius', radiusRef, 0.1, 1, 30)) { brush.radius = radiusRef.value; } - // Strength. const strengthRef: Ref = { value: brush.strength }; if (dragFloat(ui, 'brush_strength', 'Strength', strengthRef, 0.01, 0.01, 2.0)) { brush.strength = strengthRef.value; } - // Flatten target height (only when kind === 'flatten'). if (brush.kind === 'flatten') { const targetRef: Ref = { value: brush.targetHeight }; if (dragFloat(ui, 'brush_target', 'Target H', targetRef, 0.1, -50, 50)) { @@ -63,5 +95,76 @@ export function drawBrushPanel(ui: UiContext, state: EditorState): void { } } + if (painting) drawLayerList(ui, state); + endPanel(ui); } + +function drawLayerList(ui: UiContext, state: EditorState): void { + const terrain = state.world.terrain; + if (!terrain) return; + const brush = state.brush; + + separator(ui); + label(ui, 'Splat layers'); + + if (terrain.layers.length === 0) { + labelSmall(ui, 'None yet — add one to paint.', Theme.textDim); + } + + for (let i = 0; i < terrain.layers.length; i++) { + const rowY = ui.cursorY; + + // Indent 1 leaves room for the swatch, which is overdrawn rather than laid + // out beside the row: listRow owns the row's hit-test and cursor advance. + if (listRow(ui, 'brush_layer_' + i, terrain.layers[i].id, i === brush.activeLayerIdx, 1)) { + brush.activeLayerIdx = i; + } + + const sw = 9; + drawRect(ui.panelX + Theme.padding, rowY + (Theme.rowHeight - sw) / 2, sw, sw, maskColor(i)); + + // Delete, right-aligned. Drawn after listRow so its hit-test wins the click. + if (miniButton(ui, 'brush_layer_del_' + i, 'x', ui.panelX + ui.panelW - 22, rowY + 4)) { + runCommand(state, new RemoveTerrainLayerCommand(i)); + return; // The list being iterated just changed length. + } + } + + if (!pickingTexture) { + if (button(ui, 'brush_add_layer', '+ Add layer')) pickingTexture = true; + if (terrain.layers.length > 0) { + labelSmall(ui, 'LMB paint - Shift+LMB erase', Theme.textDim); + } + return; + } + + // --- texture picker --- + separator(ui); + const textures = state.catalog.textureOrder; + if (textures.length === 0) { + labelSmall(ui, 'No textures in project.', Theme.textDim); + } + for (let i = 0; i < textures.length; i++) { + const relPath = textures[i]; + if (listRow(ui, 'brush_tex_' + i, basenameNoExt(relPath), false, 1)) { + // The layer id is the texture's basename: short, stable, and already what + // the list shows — so a level author never has to name anything. + runCommand(state, new AddTerrainLayerCommand(basenameNoExt(relPath), relPath, 1.0)); + pickingTexture = false; + return; + } + } + if (button(ui, 'brush_pick_cancel', 'Cancel')) pickingTexture = false; +} + +/// A one-glyph square button at an explicit position, for the per-row delete. +/// `button` is full-width and cursor-driven, so it cannot sit inside a row. +function miniButton(ui: UiContext, id: string, text: string, x: number, y: number): boolean { + const s = 16; + const hovered = ui.mouseX >= x && ui.mouseX < x + s && ui.mouseY >= y && ui.mouseY < y + s; + if (hovered) { ui.hotId = id; ui.mouseCaptured = true; } + drawRect(x, y, s, s, hovered ? Theme.textError : Theme.button); + drawText(text, x + 5, y + 2, Theme.fontSizeSmall, Theme.text); + return hovered && ui.mousePressedLeft; +} diff --git a/src/ui/layouts/inspector.ts b/src/ui/layouts/inspector.ts index 3b0c396..b15f119 100644 --- a/src/ui/layouts/inspector.ts +++ b/src/ui/layouts/inspector.ts @@ -403,5 +403,7 @@ function drawColorFields(ui: UiContext, idPrefix: string, color: number[]): bool } function clamp01(v: number): number { - return v < 0 ? 0 : (v > 1 ? 1 : v); + if (v < 0) return 0; + if (v > 1) return 1; + return v; } diff --git a/src/viewport/ray.ts b/src/viewport/ray.ts index 913a017..476625a 100644 --- a/src/viewport/ray.ts +++ b/src/viewport/ray.ts @@ -143,5 +143,7 @@ function unprojectPoint( } function clamp01(v: number): number { - return v < 0 ? 0 : (v > 1 ? 1 : v); + if (v < 0) return 0; + if (v > 1) return 1; + return v; }