diff --git a/.gitignore b/.gitignore index 666f166..31c82a3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ /main /main.exe /main.pdb +/main-*.exe /bloom-editor /bloom-editor.exe /bloom-editor.pdb @@ -15,3 +16,15 @@ tools/.testout/ # Scratch output written by `main --test` (round-trip self-tests) /__selftest_* + +# DirectX shader compiler, copied from ../engine/native/shared/ (see README). +# Without these beside the exe, the Dx12 backend is unavailable and the editor +# dies at window creation unless launched from a directory that has them. +/dxcompiler.dll +/dxil.dll + +# Repro artifacts kept beside crash dumps +/main-*.pdb + +# Local recent-projects state (written to CWD) +/.bloom-editor/ diff --git a/PLAN.md b/PLAN.md index 1a55728..01fcc0d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3,16 +3,26 @@ **Date:** 2026-07-11 · **Toolchain:** Perry 0.5.1239 · **Status (updated 2026-07-15):** MOSTLY DONE — the ✅ markers through the body are accurate; this header was not. A/B/C/D/E/F1 and play-in-editor have shipped (and are now pushed — they sat committed on one machine's local `main` until 2026-07-15). -Still outstanding: **F2** (rename/tint/tags/modelRef editing), **F3** (outliner -depth), **G** (asset thumbnails — `renderAllThumbnails`/`getThumbnail` still have -zero call sites), **H** (recent-projects UI — the write path runs every launch and -nothing reads it), **I** (environment panel — still mutates directly, no -`SetEnvironmentCommand`), and **K2** (see the correction there — its premise is -void). ~~**K1**~~ shipped 2026-07-15 (evening) — see the correction at §K1. +**Status update 2026-07-16:** the outstanding list is now EMPTY apart from +slivers. Shipped 2026-07-16 on `feat/editor-completion`: **F2** (rename / tint / +tags / modelRef-with-Apply, all undoable), **F3** (outliner scrolls + filter +box; rename lives in the inspector, per-row ops remain the selection + Delete / +Ctrl+D paths), **I** (full env schema incl. sunColor/ambientColor/fogColor, +per-field-coalescing `SetEnvironmentCommand`), **§C tail** (point gizmo: water +move/scale, per-control-point river handles, light move), **H** (Recent button +→ panel → whole-project switch), **G** (model thumbnails via render-to-texture, +one per frame; *prefab-tab thumbnails are the remaining sliver*), **P5** (the +catalog streams — one GLB per frame, placeholders pop into meshes; the ~20 s +black window is gone), plus `--project`/`--world` CLI args and the +dxcompiler.dll discovery (see README — the editor previously could not START +outside a directory carrying the DX12 compiler DLLs). **K2** stays void/optional +(a `postSaveCommand` key remains unimplemented by choice); **J** stays stretch. +~~**K1**~~ shipped 2026-07-15 (evening) — see the correction at §K1. **Part 4 (added 2026-07-15) extends scope:** the editor should edit **any world -for any game** that uses the world format. It carries its own audit and phased -plan; Phase 1 (file trust) is already landed. +for any game** that uses the world format. Phase 1 (file trust) landed +2026-07-15; **P2 (contract) and P3a (world-viewer) landed 2026-07-16** — see +the Part 4 status notes. P3b (garden port) is the one open decision. This document is self-contained: it is written for an implementer with no prior context. It replaces the original design plan (which was deleted). Part 1 is a verified audit of the current state, Part 2 defines "done", Part 3 is the work plan. This is a **single unified scope** — do not cut it down to an MVP; if a task has a prerequisite, the prerequisite is in scope too. @@ -401,25 +411,124 @@ Every task lands with its self-test where the logic is testable headless (comman #### P2. The contract — write down what "uses the world format" means -- **P2a** `engine/docs/world-format.md`: the schema (or a blessed pointer to `types.ts`), the three sanctioned extension points and the strip-warning behavior, `editor.project.json` — all eight keys, defaults, "all optional" — the `--world ` play convention, and the recommended `userData.kind` pattern with `halfExtents` et al. documented as conventions. Acceptance: a third game could adopt the format from this doc alone, without reading shooter source. -- **P2b** De-shooterize the soft spots: optional project key (e.g. `kindColors`) feeding the placeholder map, hash fallback stays the default; either use `gameId` or stop parsing it. -- **P2c** Editor CLI: `--project ` and `--world ` args (argv handling already exists for `--test`), so any world can be opened without dialogs. Acceptance: `./main --world ../somegame/assets/worlds/x.world.json` opens it. - -#### P3. The proof — a second consumer - -- **P3a** Engine `world-viewer` example (~100 LOC): `loadWorld` + `instantiateWorld` + `applyWorldLights` + fly camera on any path handed to it. First consumer of `instantiateWorld`; becomes the conformance harness for the generic path (terrain, splat layers, water, rivers, lights outside the editor) and the "hello world" for adopters. ⚠ verify-first: `instantiateWorld` has never run in a game — expect the §C1-era acceptance that was "never executable" to surface real gaps here. +✅ **DONE (2026-07-16):** + +> - **P2a** `engine/docs/world-format.md` shipped: schema summary with +> `types.ts` as source of truth, the three round-tripping extension points +> and the strip-warning behavior, the full `editor.project.json` key table +> (now nine keys — `kindColors` added), the `--world` play contract, both +> runtime consumption shapes, and the versioning promises. +> - **P2b** `kindColors` project key feeds the placeholder map (hash fallback +> stays); `gameId` is read and shown in the window title instead of being +> parsed-and-dropped. +> - **P2c** `--project ` / `--world ` CLI args work — including a +> bare world with no project. Discovered en route: the editor could not +> START from any directory not carrying `dxcompiler.dll`/`dxil.dll` (Dx12 +> backend unavailable → Vulkan surface panic at window creation). The DLLs +> now sit beside the exe (gitignored; README documents the one-time copy). + +#### P3. The proof — a second consumer (P3a ✅, P3b open) + +- **P3a** ✅ **DONE (2026-07-16).** `engine/examples/world-viewer/` runs arena_02: `loadWorld` → `instantiateWorld` → `applyWorldEnvironment` per frame, fly camera, instantiation warnings surfaced. `instantiateWorld` has its first consumer. The predicted gap was REAL: nothing on the generic path re-applied the environment per frame (the renderer clears lighting in begin_frame, so `instantiateWorld` alone lights exactly one frame). Fix: new shared `applyWorldEnvironment` in `engine/src/world/render.ts`, used by the viewer AND by the editor's `syncEnvironment` — the lighting preview is now literally the same code path everywhere. Also found en route: `getRenderTextureTexture` still returned the pre-0.5 `{ id }` Texture shape (fixed; it had zero draw-call consumers until thumbnails). - **P3b** Port **garden** to the world format. Its scene maps directly (island → terrain or flat entities, water plane → water volume, 12 collectibles → entities with `userData.kind: "bloom"`), and it exercises the exact path shooter doesn't: a world **authored from scratch in the editor**, loaded via **`instantiateWorld`**. Acceptance: a garden level built in the editor plays in garden. Decision needed first: adoption as product direction vs. testbed branch — garden has a GDD; don't fight its design (§4.4 Q1). -#### P4. Generic editing depth (Part 3's F/H/I items, re-prioritized for foreign worlds) - -Order changes because the customer is now "someone else's world": **F3 first and split** — scrolling + a filter box are prerequisites for opening any large world (66 entities already buries the list; the panel cannot scroll at all — `outliner.ts:35,73`), rename/per-row ops can follow. Then **F2** with modelRef reassignment leading (remapping asset paths is the first thing editing a foreign world needs), then **I** (env completeness + `SetEnvironmentCommand`), then water/river gizmo manipulation (§C tail), then **H** (recent projects — the multi-game switching workflow). **J** stays stretch. - -#### P5. Polish - -Async/lazy asset catalog (the 20 s blocking startup punishes exactly the big new game this is for; models on demand, catalog listing stays instant), then **G** thumbnails as the natural background job once loading is async. +#### P4. Generic editing depth — ✅ DONE (2026-07-16) + +> All landed on `feat/editor-completion`: outliner scroll region + filter box +> (F3; rename lives in the inspector; per-row delete/duplicate stayed as the +> selection + Delete / Ctrl+D paths — a visible convention beats per-row +> button clutter in a 24px row); inspector rename / tint / tags / modelRef +> draft-and-Apply (F2, all undoable, add/remove tint discrete while drags +> coalesce); full-schema environment panel through a per-field-coalescing +> `SetEnvironmentCommand` (I); point gizmo for water center/size, river +> control points (click a handle, drag axes), and light position (§C tail); +> recent-projects panel with whole-project switching (H). **J** stays stretch. + +#### P5. Polish — ✅ DONE except prefab thumbnails (2026-07-16) + +> The catalog streams: `loadAssetCatalog` lists instantly, `pumpAssetCatalog` +> loads one GLB per frame in the main loop, entities pop from placeholder box +> to mesh as their model arrives. The ~20 s black window is gone. Model +> thumbnails (G) render one per frame into 128px render textures after the +> stream settles; the Models tab is a thumbnail grid. **Remaining sliver:** +> the Prefabs tab still shows text rows — prefab thumbnails need leaf +> expansion + a computed AABB (prefab `bounds` are typically degenerate). +> ⚠ Thumbnail visual quality is machine-verified only as "doesn't crash" — +> eyeball composition/orientation on first human run. ### 4.4 Open questions 1. Garden adoption: product direction or testbed? (P3b blocks on this; P3a doesn't.) 2. Should unknown-field warnings eventually harden into a versioned-strict mode (error, not warning, when `schemaVersion` claims current)? Today: warn only. 3. Does `world-viewer` belong in `engine/examples/` (sibling to `perry-embed`) or as a `--view` mode of the editor binary? Leaning examples/ — the point is proving the path with zero editor code in the loop. + +--- + +## Part 5 — Production readiness (added 2026-07-17) + +Parts 1–4 are feature-complete, and then two days of REAL USE found four +"done"-but-broken things in a row: mouse hits off by the display scale (DPI), +the editor unable to even start outside a directory carrying dxcompiler.dll, +the whole arena invisible under `--project` (catalog key identity), and an +0xc0000005 on the first placement click (Perry miscompiled the ray math into a +load from absolute address 8 — layout-sensitive, invisible to every test that +never clicked). The lesson is structural: **the features exist; what's missing +for production is the machinery that catches this class of failure before a +user does.** In rough priority: + +### 5.1 Interaction smoke test — ✅ DONE (2026-07-17) + +> `tools/ui-smoke.ps1`: launches the real binary against a scratch copy of +> the arena_01 fixture, injects asset-cell click → 5 placements → camera +> orbit → select → move-gizmo drag → Ctrl+S, then asserts alive, no FATAL on +> stderr, saved file parses, entity count grew by exactly the placements, +> and the safe-save siblings exist. First run: PASS (8 → 13 entities). +> Would have caught both the key-identity bug and the ray miscompile. +> Run it after every build and every Perry upgrade. Still to do: pin the +> Perry version formally; file the kept repro pair upstream. + +### 5.2 Data safety — ✅ DONE (2026-07-17) + +> - **Crash-safe saves** (engine `feat/safe-saves`): `saveWorld`/`savePrefab` +> write `.tmp` → read back + byte-compare (catches the 0-byte-write class) +> → snapshot `.bak` → write real. True atomic replace still wants a rename +> FFI. Gitignore `*.world.json.tmp/.bak` in game repos. +> - **Exit recovery instead of confirm-on-close**: closing with unsaved +> changes parks them in `.recover` (never silently lost, never +> silently overwriting); opening that world later announces the recovery +> file in the status bar + console. + +### 5.3 Engine features the editor is blocked on + +- **Render-mesh-to-texture** (the obvious one): real thumbnails for models + and prefabs. The current render-target API is a per-frame override consumed + at end_frame — unusable mid-frame; needs either an immediate mode or a + dedicated `renderModelToTexture(model, camera, size)` call. +- **Text-input completeness**: ~~caret movement~~ DONE 2026-07-17 (click + places the caret, Left/Right/Home/End move it, Delete deletes forward, + typing inserts at the caret). Still engine-blocked: clipboard paste, + selection ranges, hold-to-repeat (needs key-repeat events). +- **unloadModel**: project switching currently leaks every GPU model. +- (Nice-to-have) UI scissor/clip rects — current clipping skips whole + widgets; real clip rects would allow partially visible rows. + +### 5.4 Editing depth that daily use will demand + +Multi-select that acts as a group (selection.ids exists; gizmos/inspector act +on primary only), copy/paste, a per-point river width UI, terrain resize, +per-row outliner ops, world switcher listing `worldsDir` (Open dialog is the +only path today), and a persistent log panel (status line is a 4-second +transient — save errors deserve better). ~~Camera zoom~~ FIXED 2026-07-17: +the wheel now dollies toward the point under the CURSOR (not the world +center), honors multi-notch deltas, min distance 0.2, and no longer fires +while the pointer is over a scrolling panel. Middle-drag pans, right-drag +orbits — that was already there, just undocumented. + +### 5.5 Proof obligations that remain from Part 4 + +The garden port (P3b, blocked on §4.4 Q1) is still the only thing that turns +"any game CAN use this" into "a second game DOES." macOS is untested since the +Windows bring-up — "production" means both platforms run the smoke test. And +scale is unmeasured beyond ~250 entities: syncRebuilds does O(n) find() per +rebuilt id and the outliner draws every row each frame; fine at hundreds, +unknown at ten thousand. diff --git a/README.md b/README.md index 5e5063a..daa2626 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,11 @@ Like everything in the Bloom ecosystem, the editor is written in TypeScript and ## Status -Core editing works; several planned features are unfinished. **[`PLAN.md`](PLAN.md)** contains a full verified audit (what works, what's broken, with file/line references) and the completion plan, including a definition of done. In short: +Feature-complete against **[`PLAN.md`](PLAN.md)**'s definition of done (the plan retains the full audit trail): entity placement/selection/duplication, move/rotate/scale gizmos, terrain sculpting **and** splat painting, water volumes and rivers (placeable, selectable, gizmo-draggable — including per-control-point river handles), point lights, prefab authoring with cycle rejection, rename/tint/tags/modelRef editing, free-form `userData` editing, a full environment panel, asset thumbnails, recent projects, play-in-editor (Ctrl+R runs the real game on the level on screen), fly-camera playtest, and undo/redo behind **every** mutation. Save/load is semantically lossless, proven by self-tests that round-trip real shipped worlds. -- **Working today:** opens the shooter project and renders `arena_02` (entities with no model — spawners, pickups, colliders — draw as colored, pickable placeholder boxes); entity placement and selection, move/rotate/scale gizmos, terrain sculpting, undo/redo, save/load with autosave, `userData` key/value editing, environment panel, fly-camera playtest mode. -- **Not finished yet:** prefab authoring UI, terrain texture painting, water/river editing beyond initial placement, asset thumbnails, recent-projects UI. +The editor is game-agnostic: it opens any project with an `editor.project.json`, or any bare `*.world.json` via `--world ` (`--project ` skips the CWD walk). The adoption contract for new games is [`engine/docs/world-format.md`](https://github.com/Bloom-Engine/engine/blob/main/docs/world-format.md); the engine's `examples/world-viewer` is the reference consumer. -Startup blocks for ~20 s while every GLB in the project's models dir is loaded synchronously (26 for the shooter). The window is black until that finishes — it is loading, not hung. +Models stream in one per frame at startup — the world appears immediately with colored placeholder boxes that pop into real meshes as their GLBs load (a status line counts down). Remaining slivers are tracked at the top of PLAN.md (prefab-tab thumbnails; optional `postSaveCommand` hook). ### Platform notes @@ -28,6 +27,8 @@ perry compile src/main.ts `perry compile` emits the binary as `./main` (`main.exe` on Windows; pass `-o ` to change it). The native-library grant lives in `package.json` under `perry.allow.nativeLibrary`, so no environment variables are needed. +**Windows:** copy `dxcompiler.dll` and `dxil.dll` from `../engine/native/shared/` next to the binary once. Without them the Dx12 backend is unavailable and the editor crashes at window creation ("Failed to create surface") when launched from any directory that doesn't happen to contain them — which is why it used to work only from the shooter's root. + Run the self-test suite headless with: ```sh diff --git a/src/gizmos/point-gizmo.ts b/src/gizmos/point-gizmo.ts new file mode 100644 index 0000000..92ed677 --- /dev/null +++ b/src/gizmos/point-gizmo.ts @@ -0,0 +1,278 @@ +// Point gizmo — axis-drag movement for the non-entity selectables: water +// volumes (move center / scale size), river control points, and lights +// (PLAN §C tail). The entity gizmos bail on these selections by design +// (selectedEntityId returns null), so without this the only way to move a +// river bend or a light was typing numbers into the inspector. +// +// Same axis-ray drag math as move-gizmo.ts; commits through the existing +// EditWaterCommand / EditRiverCommand / EditLightCommand on release, so a +// whole drag is one undo entry (their mergeWith handles inspector drags the +// same way). + +import { + getMouseX, getMouseY, isMouseButtonPressed, isMouseButtonDown, + getScreenWidth, getScreenHeight, MouseButton, + drawRay, drawSphereWires, +} from 'bloom'; +import { WaterVolume, RiverSpline, LightData, Vec3Lit } from 'bloom/world'; +import { EditorState } from '../state/editor-state'; +import { runCommand } from '../state/commands'; +import { EditWaterCommand, EditRiverCommand } from '../state/commands/edit-water'; +import { EditLightCommand, cloneLight } from '../tools/light-tool'; +import { mouseToWorldRay, raySegmentDistance, Ray3 } from '../viewport/ray'; + +const GIZMO_LENGTH = 2.5; +const HIT_THRESHOLD = 0.15; +const POINT_HIT_RADIUS = 0.6; // Click distance for river control points. + +type Axis = 'x' | 'y' | 'z' | null; + +export interface PointGizmoState { + visible: boolean; + dragging: boolean; + // True for one frame when a click landed on a river handle — main.ts must + // not ALSO route that click to handleSelectClick, which would deselect. + consumedClick: boolean; + dragAxis: Axis; + // What the drag is editing. Only one of the before* fields is non-null. + targetKind: 'water' | 'river' | 'light' | null; + targetId: string | null; + activePointIdx: number; // River control point index; 0 otherwise. + beforeWater: WaterVolume | null; + beforeRiver: RiverSpline | null; + beforeLight: LightData | null; + // Scale-mode drag bookkeeping (water size). + dragStartAxisValue: number; + dragStartSize: Vec3Lit; + anchor: Vec3Lit; + scaleMode: boolean; + lastTargetId: string | null; // Reset activePointIdx on selection change. +} + +export function createPointGizmoState(): PointGizmoState { + return { + visible: false, dragging: false, consumedClick: false, dragAxis: null, + targetKind: null, targetId: null, activePointIdx: 0, + beforeWater: null, beforeRiver: null, beforeLight: null, + dragStartAxisValue: 0, dragStartSize: [0, 0, 0], + anchor: [0, 0, 0], scaleMode: false, lastTargetId: null, + }; +} + +function cloneWater(w: WaterVolume): WaterVolume { + return { + id: w.id, kind: w.kind, + center: [w.center[0], w.center[1], w.center[2]], + size: [w.size[0], w.size[1], w.size[2]], + surfaceHeight: w.surfaceHeight, + color: [w.color[0], w.color[1], w.color[2], w.color[3]], + waveAmplitude: w.waveAmplitude, waveSpeed: w.waveSpeed, + }; +} + +function cloneRiver(r: RiverSpline): RiverSpline { + const pts: [number, number, number][] = []; + for (let i = 0; i < r.controlPoints.length; i++) { + const p = r.controlPoints[i]; + pts.push([p[0], p[1], p[2]]); + } + return { + id: r.id, controlPoints: pts, widths: r.widths.slice(), + depth: r.depth, flowSpeed: r.flowSpeed, + color: [r.color[0], r.color[1], r.color[2], r.color[3]], + }; +} + +// Distance from a ray to a point, via a degenerate-safe tiny segment. +function rayPointDistance(ray: Ray3, p: Vec3Lit): number { + return raySegmentDistance(ray, [p[0], p[1] - 0.001, p[2]], [p[0], p[1] + 0.001, p[2]]).dist; +} + +export function updatePointGizmo(state: EditorState, gizmo: PointGizmoState): void { + gizmo.visible = false; + gizmo.consumedClick = false; + + if (state.playtesting || state.activeTool !== 'transform') { + gizmo.dragging = false; + return; + } + + const kind = state.selection.kind; + const id = state.selection.primary; + if (id === null || (kind !== 'water' && kind !== 'river' && kind !== 'light')) { + gizmo.dragging = false; + return; + } + + // Rotation is meaningless for all three; scale only applies to water. + const scaleMode = state.transformMode === 'scale'; + if (state.transformMode === 'rotate') return; + if (scaleMode && kind !== 'water') return; + + if (gizmo.lastTargetId !== id) { + gizmo.lastTargetId = id; + gizmo.activePointIdx = 0; + gizmo.dragging = false; + } + + // Resolve the target + anchor. + const water = kind === 'water' ? state.world.water.find(w => w.id === id) : undefined; + const river = kind === 'river' ? state.world.rivers.find(r => r.id === id) : undefined; + const light = kind === 'light' ? state.world.lights.find(l => l.id === id) : undefined; + if (!water && !river && !light) return; + + if (water) gizmo.anchor = [water.center[0], water.center[1], water.center[2]]; + if (river) { + if (gizmo.activePointIdx >= river.controlPoints.length) gizmo.activePointIdx = 0; + const p = river.controlPoints[gizmo.activePointIdx]; + gizmo.anchor = [p[0], p[1], p[2]]; + } + if (light) gizmo.anchor = [light.position[0], light.position[1], light.position[2]]; + + gizmo.visible = true; + gizmo.targetKind = kind; + gizmo.targetId = id; + gizmo.scaleMode = scaleMode; + + const mx = getMouseX(); + const my = getMouseY(); + const inViewport = mx > state.viewportLeft && mx < state.viewportRight && + my > state.viewportTop && my < state.viewportBottom; + const vw = state.viewportRight - state.viewportLeft; + const vh = state.viewportBottom - state.viewportTop; + const sw = getScreenWidth(); + const sh = getScreenHeight(); + + // ---- click: river point pick first, then axis pick ------------------------ + + if (!gizmo.dragging && inViewport && isMouseButtonPressed(MouseButton.LEFT)) { + const ray = mouseToWorldRay(state.camera, mx, my, sw, sh, state.viewportLeft, state.viewportTop, vw, vh); + + // Clicking a river control point makes it the active handle. + if (river) { + let bestIdx = -1; + let bestDist = POINT_HIT_RADIUS; + for (let i = 0; i < river.controlPoints.length; i++) { + const d = rayPointDistance(ray, river.controlPoints[i]); + if (d < bestDist) { bestDist = d; bestIdx = i; } + } + if (bestIdx >= 0 && bestIdx !== gizmo.activePointIdx) { + gizmo.activePointIdx = bestIdx; + const p = river.controlPoints[bestIdx]; + gizmo.anchor = [p[0], p[1], p[2]]; + gizmo.consumedClick = true; + return; // Selecting the handle is this click's whole job. + } + } + + const pos = gizmo.anchor; + const hitX = raySegmentDistance(ray, pos, [pos[0] + GIZMO_LENGTH, pos[1], pos[2]]); + const hitY = raySegmentDistance(ray, pos, [pos[0], pos[1] + GIZMO_LENGTH, pos[2]]); + const hitZ = raySegmentDistance(ray, pos, [pos[0], pos[1], pos[2] + GIZMO_LENGTH]); + + let best: Axis = null; + let bestDist = HIT_THRESHOLD; + if (hitX.dist < bestDist) { bestDist = hitX.dist; best = 'x'; } + if (hitY.dist < bestDist) { bestDist = hitY.dist; best = 'y'; } + if (hitZ.dist < bestDist) { bestDist = hitZ.dist; best = 'z'; } + + if (best !== null) { + gizmo.dragging = true; + gizmo.dragAxis = best; + gizmo.beforeWater = water ? cloneWater(water) : null; + gizmo.beforeRiver = river ? cloneRiver(river) : null; + gizmo.beforeLight = light ? cloneLight(light) : null; + if (water && scaleMode) { + gizmo.dragStartSize = [water.size[0], water.size[1], water.size[2]]; + let ai = 2; + if (best === 'x') ai = 0; + else if (best === 'y') ai = 1; + gizmo.dragStartAxisValue = gizmo.anchor[ai]; + } + } + } + + // ---- drag ------------------------------------------------------------------ + + if (gizmo.dragging) { + if (isMouseButtonDown(MouseButton.LEFT)) { + const ray = mouseToWorldRay(state.camera, mx, my, sw, sh, state.viewportLeft, state.viewportTop, vw, vh); + const axis = gizmo.dragAxis; + let ai = 2; + let axisVec: Vec3Lit = [0, 0, 1]; + if (axis === 'x') { ai = 0; axisVec = [1, 0, 0]; } + else if (axis === 'y') { ai = 1; axisVec = [0, 1, 0]; } + const pos = gizmo.anchor; + + const endOnAxis = raySegmentDistance(ray, + [pos[0] - axisVec[0] * 50, pos[1] - axisVec[1] * 50, pos[2] - axisVec[2] * 50], + [pos[0] + axisVec[0] * 50, pos[1] + axisVec[1] * 50, pos[2] + axisVec[2] * 50], + ); + let v = endOnAxis.point[ai]; + if (!gizmo.scaleMode && state.snap.translate > 0) { + v = Math.round(v / state.snap.translate) * state.snap.translate; + } + + if (water) { + if (gizmo.scaleMode) { + // Dragging the handle outward grows the box symmetrically. + let ns = gizmo.dragStartSize[ai] + (v - gizmo.dragStartAxisValue) * 2; + if (ns < 0.1) ns = 0.1; + water.size[ai] = ns; + } else { + water.center[ai] = v; + } + state.pendingWaterRebuild = true; + } else if (river) { + river.controlPoints[gizmo.activePointIdx][ai] = v; + state.pendingWaterRebuild = true; + } else if (light) { + light.position[ai] = v; // Lights re-apply every frame from world data. + } + state.modified = true; + } else { + // Release — commit the whole drag as one undoable command. + if (water && gizmo.beforeWater) { + runCommand(state, new EditWaterCommand(water.id, gizmo.beforeWater, water)); + } else if (river && gizmo.beforeRiver) { + runCommand(state, new EditRiverCommand(river.id, gizmo.beforeRiver, river)); + } else if (light && gizmo.beforeLight) { + runCommand(state, new EditLightCommand(light.id, gizmo.beforeLight, light)); + } + gizmo.dragging = false; + gizmo.dragAxis = null; + gizmo.beforeWater = null; + gizmo.beforeRiver = null; + gizmo.beforeLight = null; + } + } +} + +// Draw between beginMode3D / endMode3D. +export function drawPointGizmo(state: EditorState, gizmo: PointGizmoState): void { + if (!gizmo.visible) return; + + // River control points render as wire spheres; the active one is larger and + // amber, so "which point am I about to drag" is never a guess. + if (gizmo.targetKind === 'river' && gizmo.targetId !== null) { + const river = state.world.rivers.find(r => r.id === gizmo.targetId); + if (river) { + for (let i = 0; i < river.controlPoints.length; i++) { + const p = river.controlPoints[i]; + if (i === gizmo.activePointIdx) { + drawSphereWires({ x: p[0], y: p[1], z: p[2] }, 0.35, { r: 255, g: 210, b: 60, a: 255 }); + } else { + drawSphereWires({ x: p[0], y: p[1], z: p[2] }, 0.22, { r: 120, g: 190, b: 255, a: 220 }); + } + } + } + } + + const pos = gizmo.anchor; + drawRay({ x: pos[0], y: pos[1], z: pos[2] }, { x: GIZMO_LENGTH, y: 0, z: 0 }, + { r: 220, g: 60, b: 60, a: gizmo.dragAxis === 'x' ? 255 : 200 }); + drawRay({ x: pos[0], y: pos[1], z: pos[2] }, { x: 0, y: GIZMO_LENGTH, z: 0 }, + { r: 60, g: 200, b: 60, a: gizmo.dragAxis === 'y' ? 255 : 200 }); + drawRay({ x: pos[0], y: pos[1], z: pos[2] }, { x: 0, y: 0, z: GIZMO_LENGTH }, + { r: 60, g: 100, b: 240, a: gizmo.dragAxis === 'z' ? 255 : 200 }); +} diff --git a/src/io/asset-catalog.ts b/src/io/asset-catalog.ts index fd32bde..5452157 100644 --- a/src/io/asset-catalog.ts +++ b/src/io/asset-catalog.ts @@ -1,29 +1,96 @@ -// Asset catalog — scans the project's model and prefab directories, loads -// models, computes bounds, and populates the EditorState catalog. +// Asset catalog — scans the project's model and prefab directories and +// populates the EditorState catalog. // -// Uses Perry's fs.readdirSync for directory listing and Bloom's loadModel -// for synchronous GLB loading. Async parallel loading via parallelMap would -// be faster for large catalogs but requires a loading screen; for now we -// block at startup (19 GLBs takes <1s on any modern machine). +// Models load INCREMENTALLY: loadAssetCatalog lists files and creates +// unloaded stub entries (instant), and pumpAssetCatalog — called once per +// frame from the main loop — loads one GLB per frame. Entities whose model +// just arrived are rebuilt from placeholder cube to real mesh on the spot. +// The old behavior loaded everything synchronously at startup, which was a +// ~20 s black window on the shooter's 26 GLBs; now the world is visible and +// editable immediately, with meshes streaming in. import { readdirSync } from 'fs'; import { loadModel, getModelBounds } from 'bloom'; import { readFile } from 'bloom'; import { PrefabData } from 'bloom/world'; -import { EditorState, ModelEntry, Project } from '../state/editor-state'; -import { basenameNoExt, categoryFromName, joinPath } from './paths'; +import { + EditorState, ModelEntry, Project, handleOfEntity, setStatus, +} from '../state/editor-state'; +import { basenameNoExt, categoryFromName, joinPath, projectRelative } from './paths'; import { invalidatePrefabRegistry } from '../world-sync/sync'; +// Reset + relist. Cheap: no GLB is opened here. Safe to call again when +// switching projects (models from the old project leak their GPU handles — +// the engine has no unloadModel — but a project switch is rare enough that +// this is a known cost, not a bug). export function loadAssetCatalog(state: EditorState): void { + state.catalog.models.clear(); + state.catalog.prefabs.clear(); + state.catalog.modelOrder.length = 0; + state.catalog.prefabOrder.length = 0; + state.catalog.textureOrder.length = 0; + if (!state.project) return; const project = state.project; - loadModels(state, project); + listModels(state, project); loadPrefabs(state, project); loadTextures(state, project); invalidatePrefabRegistry(); } +// Load up to `maxPerFrame` pending models. Returns how many entries are still +// pending, so the caller can surface progress. One per frame keeps the editor +// interactive while a big catalog streams in. +export function pumpAssetCatalog(state: EditorState, maxPerFrame: number): number { + let loadedThisFrame = 0; + let pending = 0; + + for (let i = 0; i < state.catalog.modelOrder.length; i++) { + const entry = state.catalog.models.get(state.catalog.modelOrder[i]); + if (!entry || entry.loaded || entry.failed) continue; + + if (loadedThisFrame >= maxPerFrame) { + pending++; + continue; + } + + const model = loadModel(entry.filePath); + loadedThisFrame++; + if (model.handle === 0) { + // Unreadable GLB: mark failed so we don't retry every frame. Entities + // referencing it stay placeholder boxes, same as a missing file. + entry.failed = true; + console.error('asset catalog: failed to load ' + entry.filePath); + continue; + } + + const bounds = getModelBounds(model); + entry.modelHandle = model.handle; + entry.boundsMin = [bounds.min.x, bounds.min.y, bounds.min.z]; + entry.boundsMax = [bounds.max.x, bounds.max.y, bounds.max.z]; + entry.loaded = true; + onModelLoaded(state, entry.relPath); + } + + return pending; +} + +// A model just became available: swap every placeholder that was standing in +// for it. Prefab-instance entities rebuild too — any of their leaves might +// reference the new model (conservative, but loads are a startup-only burst). +function onModelLoaded(state: EditorState, relPath: string): void { + for (let i = 0; i < state.world.entities.length; i++) { + const e = state.world.entities[i]; + const usesModel = e.modelRef === relPath; + const isPrefab = e.prefabRef !== null && e.prefabRef.length > 0; + if (!usesModel && !isPrefab) continue; + const handle = handleOfEntity(state.handles, e.id); + if (handle !== 0) state.pendingDestroy.add(handle); + state.pendingRebuild.add(e.id); + } +} + /// List the texture files, but do NOT load them. /// /// Splat layers only ever store a path — the game decodes the image, the editor @@ -40,11 +107,14 @@ function loadTextures(state: EditorState, project: Project): void { 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)); + // Project-relative: these strings become splat-layer textureRefs in the + // world file, which the GAME resolves from its own root. + state.catalog.textureOrder.push( + projectRelative(project.rootDir, joinPath(project.texturesDir, file))); } } -function loadModels(state: EditorState, project: Project): void { +function listModels(state: EditorState, project: Project): void { let files: string[]; try { files = readdirSync(project.modelsDir) as string[]; @@ -56,19 +126,21 @@ function loadModels(state: EditorState, project: Project): void { const file = files[i]; if (!file.endsWith('.glb') && !file.endsWith('.gltf')) continue; - const relPath = joinPath(project.modelsDir, file); - const model = loadModel(relPath); - if (model.handle === 0) continue; - - const bounds = getModelBounds(model); + // filePath opens the file; relPath (project-relative) is the KEY and must + // equal the world's modelRef exactly — a Map.get has no notion of + // "equivalent path". See projectRelative in paths.ts for the history. + const filePath = joinPath(project.modelsDir, file); + const relPath = projectRelative(project.rootDir, filePath); const entry: ModelEntry = { relPath: relPath, + filePath: filePath, displayName: basenameNoExt(file), category: categoryFromName(file), - modelHandle: model.handle, - boundsMin: [bounds.min.x, bounds.min.y, bounds.min.z], - boundsMax: [bounds.max.x, bounds.max.y, bounds.max.z], - loaded: true, + modelHandle: 0, + boundsMin: [0, 0, 0], + boundsMax: [0, 0, 0], + loaded: false, + failed: false, }; state.catalog.models.set(relPath, entry); diff --git a/src/io/open-project.ts b/src/io/open-project.ts new file mode 100644 index 0000000..f6c88ee --- /dev/null +++ b/src/io/open-project.ts @@ -0,0 +1,42 @@ +// Switch the editor to another project mid-session (PLAN §H). Loads the +// project file, relists the asset catalog (models stream in via +// pumpAssetCatalog), opens its default world, and records it in the +// recent-projects list. + +import { setWindowTitle } from 'bloom'; +import { EditorState, setStatus } from '../state/editor-state'; +import { loadProject } from './project'; +import { loadAssetCatalog } from './asset-catalog'; +import { openWorld, newWorld } from './world-io'; +import { joinPath } from './paths'; +import { addRecentProject } from './recent'; +import { frameCameraOnWorld } from '../viewport/frame'; + +export function openProject(state: EditorState, projectPath: string): boolean { + if (!loadProject(state, projectPath)) { + setStatus(state, 'Could not open project: ' + projectPath); + return false; + } + + loadAssetCatalog(state); + + const project = state.project; + if (project === null) return false; + + if (project.defaultWorld.length > 0) { + const worldPath = joinPath(project.worldsDir, project.defaultWorld); + if (openWorld(state, worldPath)) { + frameCameraOnWorld(state); + } else { + newWorld(state); + } + } else { + newWorld(state); + } + + addRecentProject(project.name, project.filePath); + setWindowTitle('Bloom World Editor — ' + project.name + + (project.gameId.length > 0 ? ' (' + project.gameId + ')' : '')); + setStatus(state, 'Opened project ' + project.name); + return true; +} diff --git a/src/io/paths.ts b/src/io/paths.ts index a73ac69..918a36c 100644 --- a/src/io/paths.ts +++ b/src/io/paths.ts @@ -47,3 +47,22 @@ export function joinPath(a: string, b: string): string { if (a.charAt(a.length - 1) === '/') return a + b; return a + '/' + b; } + +// The inverse of joinPath(rootDir, ...): strip the project root prefix so the +// result is PROJECT-RELATIVE — the exact string world files store in modelRef +// and the exact string the catalog must be keyed by. +// +// This is the same identity rule joinPath's comment describes, from the other +// side: with `--project ../shooter/editor.project.json` the resolved load path +// is '../shooter/assets/models/x.glb', but the world says +// 'assets/models/x.glb'. Keying the catalog by the LOAD path made every lookup +// miss and rendered the whole arena as placeholder boxes again — the disease +// joinPath was cured of, reintroduced one level up. +export function projectRelative(rootDir: string, path: string): string { + if (rootDir.length === 0 || rootDir === '.' || rootDir === './') return path; + const prefix = rootDir.charAt(rootDir.length - 1) === '/' ? rootDir : rootDir + '/'; + if (path.length > prefix.length && path.substring(0, prefix.length) === prefix) { + return path.substring(prefix.length); + } + return path; +} diff --git a/src/io/project.ts b/src/io/project.ts index 595c43c..0be7966 100644 --- a/src/io/project.ts +++ b/src/io/project.ts @@ -4,19 +4,29 @@ import { readFile, fileExists } from 'bloom'; import { EditorState, Project } from '../state/editor-state'; import { joinPath } from './paths'; -export function loadProject(state: EditorState): boolean { - // Walk up from CWD looking for editor.project.json. - const candidates = [ - 'editor.project.json', - '../editor.project.json', - '../../editor.project.json', - ]; - +// `explicitPath` (from `--project `) skips the CWD walk entirely — the +// editor can open any game's project from anywhere. +export function loadProject(state: EditorState, explicitPath?: string | null): boolean { let foundPath: string | null = null; - for (let i = 0; i < candidates.length; i++) { - if (fileExists(candidates[i])) { - foundPath = candidates[i]; - break; + + if (explicitPath !== undefined && explicitPath !== null && explicitPath.length > 0) { + if (!fileExists(explicitPath)) { + console.error('loadProject: --project file not found: ' + explicitPath); + return false; + } + foundPath = explicitPath; + } else { + // Walk up from CWD looking for editor.project.json. + const candidates = [ + 'editor.project.json', + '../editor.project.json', + '../../editor.project.json', + ]; + for (let i = 0; i < candidates.length; i++) { + if (fileExists(candidates[i])) { + foundPath = candidates[i]; + break; + } } } @@ -37,23 +47,58 @@ export function loadProject(state: EditorState): boolean { texturesDir?: string; defaultWorld?: string; playCommand?: string; + kindColors?: Record; }; - // Determine the root directory from the project file path. - const lastSlash = foundPath.lastIndexOf('/'); - const rootDir = lastSlash > 0 ? foundPath.substring(0, lastSlash) : '.'; + // Determine the root directory from the project file path. `--project` may + // arrive with Windows backslashes; normalize so rootDir/joinPath stay sane. + const normPath = foundPath.split('\\').join('/'); + const lastSlash = normPath.lastIndexOf('/'); + const rootDir = lastSlash > 0 ? normPath.substring(0, lastSlash) : '.'; + + // Optional `kindColors`: { "": "r,g,b" } with 0-255 channels. + // Lets any game color its own marker-entity placeholders without the editor + // hardcoding that game's vocabulary. Parallel arrays — see the Project type. + const kindColorKeys: string[] = []; + const kindColorValues: [number, number, number][] = []; + if (raw.kindColors) { + const kcKeys = Object.keys(raw.kindColors); + for (let i = 0; i < kcKeys.length; i++) { + const parsed = parseRgb255(raw.kindColors[kcKeys[i]]); + if (parsed !== null) { + kindColorKeys.push(kcKeys[i]); + kindColorValues.push(parsed); + } else { + console.error('editor.project.json: kindColors["' + kcKeys[i] + '"] is not "r,g,b" (0-255) — ignored'); + } + } + } state.project = { - filePath: foundPath, + filePath: normPath, rootDir: rootDir, name: raw.name || 'Untitled Project', + gameId: raw.gameId || '', 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 || '', + kindColorKeys: kindColorKeys, + kindColorValues: kindColorValues, }; return true; } + +function parseRgb255(s: string): [number, number, number] | null { + if (!s || s.length === 0) return null; + const parts = s.split(','); + if (parts.length !== 3) return null; + const r = parseFloat(parts[0]); + const g = parseFloat(parts[1]); + const b = parseFloat(parts[2]); + if (r !== r || g !== g || b !== b) return null; + return [r, g, b]; +} diff --git a/src/io/world-io.ts b/src/io/world-io.ts index a2ad22a..1bb0ca1 100644 --- a/src/io/world-io.ts +++ b/src/io/world-io.ts @@ -1,6 +1,7 @@ // Thin wrapper around bloom/world loader/saver for the editor. // Handles file path resolution and state updates (modified flag, world path). +import { fileExists } from 'bloom'; import { loadWorld, saveWorld, createEmptyWorld, listUnknownWorldFields } from 'bloom/world'; import { EditorState, setStatus } from '../state/editor-state'; import { rebuildAllSceneNodes } from '../world-sync/sync'; @@ -24,6 +25,11 @@ export function openWorld(state: EditorState, path: string): boolean { setStatus(state, 'This file has ' + unknown.length + ' field(s) this editor does not know (e.g. ' + unknown[0] + ') — saving will DROP them. See console.'); + } else if (fileExists(path + '.recover')) { + // A previous session closed with unsaved changes on this world. + setStatus(state, 'Unsaved changes from a previous session exist: ' + path + '.recover'); + console.error('openWorld: recovery file present: ' + path + '.recover' + + ' — open it with --world to inspect, delete it to dismiss.'); } return true; } catch (e) { diff --git a/src/main.ts b/src/main.ts index 8fecbeb..575a9c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,7 @@ import { setWindowTitle, } from 'bloom'; -import { createEntity, Vec3Lit } from 'bloom/world'; +import { createEntity, saveWorld, Vec3Lit } from 'bloom/world'; import { handleSelectClick } from './tools/select-tool'; import { handlePlaceClick } from './tools/place-tool'; @@ -28,7 +28,7 @@ import { DuplicateEntityCommand } from './state/commands/duplicate-entity'; import { RemoveWaterCommand, RemoveRiverCommand } from './state/commands/edit-water'; import { loadProject } from './io/project'; -import { loadAssetCatalog } from './io/asset-catalog'; +import { loadAssetCatalog, pumpAssetCatalog } from './io/asset-catalog'; import { openWorld, saveCurrentWorld, defaultSavePath } from './io/world-io'; import { updateOrbitCamera, buildCamera3D } from './viewport/orbit-camera'; @@ -42,6 +42,7 @@ import { createUiContext, uiBeginFrame, uiEndFrame } from './ui/ui-context'; import { createMoveGizmoState, updateMoveGizmo, drawMoveGizmo } from './gizmos/move-gizmo'; import { createRotateGizmoState, updateRotateGizmo, drawRotateGizmo } from './gizmos/rotate-gizmo'; import { createScaleGizmoState, updateScaleGizmo, drawScaleGizmo } from './gizmos/scale-gizmo'; +import { createPointGizmoState, updatePointGizmo, drawPointGizmo } from './gizmos/point-gizmo'; import { updateBrushTool } from './tools/brush-tool'; import { updateWaterTool, drawWaterVolumes } from './tools/water-tool'; import { updateRiverTool, drawRiverSplines } from './tools/river-tool'; @@ -59,16 +60,32 @@ import { drawToolbar } from './ui/layouts/toolbar'; import { drawAssetPanel } from './ui/layouts/asset-panel'; import { drawInspector } from './ui/layouts/inspector'; import { drawOutliner } from './ui/layouts/outliner'; +import { drawRecentPanel } from './ui/layouts/recent-panel'; import { drawStatusBar } from './ui/layouts/status-bar'; +import { pumpThumbnails } from './ui/thumbnails'; import { runSelfTests } from './tests/self-tests'; // ---- self-tests (headless) --------------------------------------------------- // `bloom-editor --test` runs the suite without opening a window and exits // nonzero on any failure, so CI and pre-commit hooks can gate on it. +// +// `--project ` opens a specific editor.project.json (instead of walking +// up from CWD), and `--world ` opens a specific world file — with or +// without a project. Together they let the editor open ANY game's data from +// anywhere, no dialogs: `main --project ../game/editor.project.json` or +// `main --world some/level.world.json`. let selfTestMode = false; +let argProjectPath: string | null = null; +let argWorldPath: string | null = null; for (let i = 0; i < process.argv.length; i++) { if (process.argv[i] === '--test') selfTestMode = true; + if (process.argv[i] === '--project' && i + 1 < process.argv.length) { + argProjectPath = process.argv[i + 1]; + } + if (process.argv[i] === '--world' && i + 1 < process.argv.length) { + argWorldPath = process.argv[i + 1]; + } } if (selfTestMode) { const failures = runSelfTests(); @@ -89,15 +106,29 @@ const ui = createUiContext(); const moveGizmo = createMoveGizmoState(); const rotateGizmo = createRotateGizmoState(); const scaleGizmo = createScaleGizmoState(); +const pointGizmo = createPointGizmoState(); -// Load project + assets. -loadProject(state); -// Blocking: loads every GLB in the project's models dir (~20 s for the -// shooter's 26 on a mid Windows box). Worth making async — see PLAN.md. +// Load project + assets. The catalog only LISTS models here (instant); +// pumpAssetCatalog in the frame loop streams the GLBs in one per frame, so +// the old ~20 s black window at startup is gone — the world appears at once +// as placeholder boxes and meshes pop in as they load. +loadProject(state, argProjectPath); loadAssetCatalog(state); -// Open default world if available. -if (state.project && state.project.defaultWorld.length > 0) { +if (state.project !== null) { + setWindowTitle('Bloom World Editor — ' + state.project.name + + (state.project.gameId.length > 0 ? ' (' + state.project.gameId + ')' : '')); +} + +// Open a world: --world wins, then the project's default. +if (argWorldPath !== null) { + if (openWorld(state, argWorldPath)) { + if (state.project) addRecentProject(state.project.name, state.project.filePath); + frameCameraOnWorld(state); + } else { + console.error('could not open --world ' + argWorldPath); + } +} else if (state.project && state.project.defaultWorld.length > 0) { const worldPath = state.project.worldsDir + '/' + state.project.defaultWorld; openWorld(state, worldPath); addRecentProject(state.project.name, state.project.filePath); @@ -113,6 +144,10 @@ state.pendingEnvironmentSync = true; const AUTOSAVE_INTERVAL = 120; // seconds let autosaveTimer = 0; +// Models still streaming in (previous frame's count). Thumbnails wait for +// zero so the two per-frame pumps never fight over frame time. +let modelsPending = 1; + // ---- main loop ------------------------------------------------------------- while (!windowShouldClose()) { @@ -239,6 +274,13 @@ while (!windowShouldClose()) { a: 255, }); + // Render missing asset thumbnails, one per frame, once models are in. + // Must run inside the frame but before the main 3D pass (it switches the + // render target and back). + if (modelsPending === 0 && !state.playtesting) { + pumpThumbnails(state, 1); + } + // ---- 3D viewport --------------------------------------------------------- const cam3D = buildCamera3D(state.camera); @@ -247,13 +289,16 @@ while (!windowShouldClose()) { drawGroundGrid(); drawWorldAxes(); - // Update and draw gizmos based on transform mode. + // Update and draw gizmos based on transform mode. The point gizmo covers + // water / river / light selections, which the entity gizmos bail on. updateMoveGizmo(state, moveGizmo); updateRotateGizmo(state, rotateGizmo); updateScaleGizmo(state, scaleGizmo); + updatePointGizmo(state, pointGizmo); drawMoveGizmo(moveGizmo); drawRotateGizmo(rotateGizmo); drawScaleGizmo(scaleGizmo); + drawPointGizmo(state, pointGizmo); // In-progress previews for the water/river tools, plus light markers (a light // has no mesh, so without these you cannot see or click one). @@ -300,6 +345,7 @@ while (!windowShouldClose()) { if (state.activeTool === 'select' && state.selection.primary === null && !state.editingPrefab) { drawEnvironmentPanel(ui, state); } + drawRecentPanel(ui, state); } drawPlaytestOverlay(state); @@ -307,7 +353,7 @@ while (!windowShouldClose()) { // ---- process viewport click (only if UI didn't capture the mouse) --------- - if (viewportClicked && !ui.mouseCaptured && !moveGizmo.dragging && !rotateGizmo.dragging && !scaleGizmo.dragging) { + if (viewportClicked && !ui.mouseCaptured && !moveGizmo.dragging && !rotateGizmo.dragging && !scaleGizmo.dragging && !pointGizmo.dragging && !pointGizmo.consumedClick) { if (state.activeTool === 'place') { handlePlaceClick(state); } else if (state.activeTool === 'select' || state.activeTool === 'transform') { @@ -322,6 +368,15 @@ while (!windowShouldClose()) { updateRiverTool(state); updateLightTool(state); + // ---- stream in pending models (one GLB per frame) -------------------------- + + const pendingModels = pumpAssetCatalog(state, 1); + modelsPending = pendingModels; + if (pendingModels > 0) { + state.statusMessage = 'Loading models… ' + pendingModels + ' remaining'; + state.statusMessageT = 0.5; + } + // ---- world sync (at the end of the frame) -------------------------------- syncWorldToScene(state); @@ -331,4 +386,17 @@ while (!windowShouldClose()) { endDrawing(); } +// The window is closing. Losing edits silently is unacceptable — but so is +// silently overwriting the real file with changes the user may have been +// abandoning on purpose. Park them in a sibling recovery file instead; +// openWorld announces it on the next launch. (Skipped in prefab mode: the +// stashed WORLD is what matters there and it hasn't been touched.) +if (state.modified && state.worldPath !== null && !state.editingPrefab) { + const recoverPath = state.worldPath + '.recover'; + const res = saveWorld(recoverPath, state.world); + if (res.ok) { + console.error('editor: window closed with unsaved changes — parked in ' + recoverPath); + } +} + closeWindow(); diff --git a/src/playtest/launch.ts b/src/playtest/launch.ts index 8742981..b7cb34d 100644 --- a/src/playtest/launch.ts +++ b/src/playtest/launch.ts @@ -12,7 +12,7 @@ import { launchProcess } from 'bloom/core'; import { saveWorld } from 'bloom/world'; import { EditorState, setStatus } from '../state/editor-state'; -import { joinPath } from '../io/paths'; +import { joinPath, projectRelative } 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 @@ -44,8 +44,12 @@ export function launchGame(state: EditorState): void { // // launchProcess, not child_process.spawn — Perry's spawn compiles and then does // nothing at all (undefined pid, no process). See engine EN-048. + // The game runs with cwd = project root, so the --world argument must be + // PROJECT-relative — worldPath here is editor-relative and only matches + // when the editor happens to run from the project root. const cwd = project.rootDir.length > 0 ? project.rootDir : '.'; - const pid = launchProcess(project.playCommand, ['--world', worldPath], cwd); + const gameWorldArg = projectRelative(project.rootDir, worldPath); + const pid = launchProcess(project.playCommand, ['--world', gameWorldArg], cwd); if (pid === 0) { setStatus(state, 'Play: could not launch ' + project.playCommand); return; diff --git a/src/playtest/playtest.ts b/src/playtest/playtest.ts index 9286795..7f73e96 100644 --- a/src/playtest/playtest.ts +++ b/src/playtest/playtest.ts @@ -8,7 +8,7 @@ import { disableCursor, enableCursor, } from 'bloom'; import { EditorState } from '../state/editor-state'; -import { cameraEyePosition } from '../viewport/orbit-camera'; +import { cameraEyePosition } from '../state/editor-state'; import { Theme } from '../ui/theme'; const FLY_SPEED = 12; diff --git a/src/state/commands/edit-entity.ts b/src/state/commands/edit-entity.ts new file mode 100644 index 0000000..159b24a --- /dev/null +++ b/src/state/commands/edit-entity.ts @@ -0,0 +1,176 @@ +// Commands for entity properties other than transform: rename, tint, tags, +// and modelRef reassignment (PLAN §F2). All undoable. +// +// Tint, tags, and modelRef changes DESTROY and re-create the entity's scene +// node rather than just flagging a rebuild: syncRebuilds only updates +// transform + tint on an existing node, so a swapped model or a removed tint +// would otherwise leave the old mesh/color on screen. Destroys run before +// rebuilds within the same frame, so the swap is seamless. + +import { Vec4Lit } from 'bloom/world'; +import { EditorState, Command, EntityId, handleOfEntity } from '../editor-state'; + +function findEntity(state: EditorState, id: EntityId) { + for (let i = 0; i < state.world.entities.length; i++) { + if (state.world.entities[i].id === id) return state.world.entities[i]; + } + return null; +} + +// Tear down the scene node (if any) and queue a fresh build. +function rebuildNode(state: EditorState, id: EntityId): void { + const handle = handleOfEntity(state.handles, id); + if (handle !== 0) state.pendingDestroy.add(handle); + state.pendingRebuild.add(id); +} + +function cloneTint(t: Vec4Lit | null): Vec4Lit | null { + if (t === null) return null; + return [t[0], t[1], t[2], t[3]]; +} + +// ---- rename ------------------------------------------------------------------ + +export class RenameEntityCommand implements Command { + readonly label: string; + readonly entityId: EntityId; + private before: string; + private after: string; + + constructor(entityId: EntityId, before: string, after: string) { + this.entityId = entityId; + this.before = before; + this.after = after; + this.label = 'Rename ' + entityId; + } + + do(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) e.name = this.after; + } + + undo(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) e.name = this.before; + } + + // Keystrokes coalesce: one undo entry per rename, not per character. + mergeWith(next: Command): boolean { + if (!(next instanceof RenameEntityCommand)) return false; + if ((next as RenameEntityCommand).entityId !== this.entityId) return false; + this.after = (next as RenameEntityCommand).after; + return true; + } +} + +// ---- tint ---------------------------------------------------------------------- + +export class SetTintCommand implements Command { + readonly label: string; + readonly entityId: EntityId; + private before: Vec4Lit | null; + private after: Vec4Lit | null; + + constructor(entityId: EntityId, before: Vec4Lit | null, after: Vec4Lit | null) { + this.entityId = entityId; + this.before = cloneTint(before); + this.after = cloneTint(after); + this.label = 'Tint ' + entityId; + } + + do(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) { + e.tint = cloneTint(this.after); + rebuildNode(state, this.entityId); + } + } + + undo(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) { + e.tint = cloneTint(this.before); + rebuildNode(state, this.entityId); + } + } + + // Color-field drags coalesce like transform drags — but only value->value + // edits. Add (null->v) and remove (v->null) are discrete operations: if the + // add merged with the drag that follows it, Ctrl+Z after "add tint, tweak + // it" would remove the tint entirely instead of undoing the tweak. + mergeWith(next: Command): boolean { + if (!(next instanceof SetTintCommand)) return false; + const n = next as SetTintCommand; + if (n.entityId !== this.entityId) return false; + if (this.before === null || this.after === null) return false; + if (n.before === null || n.after === null) return false; + this.after = cloneTint(n.after); + return true; + } +} + +// ---- tags ---------------------------------------------------------------------- + +export class SetTagsCommand implements Command { + readonly label: string; + readonly entityId: EntityId; + private before: string[]; + private after: string[]; + + constructor(entityId: EntityId, before: string[], after: string[]) { + this.entityId = entityId; + this.before = before.slice(); + this.after = after.slice(); + this.label = 'Tags ' + entityId; + } + + do(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) { + e.tags = this.after.slice(); + // Placeholder colors key off tags (static_mesh convention), so the + // node must rebuild for the change to show. + rebuildNode(state, this.entityId); + } + } + + undo(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) { + e.tags = this.before.slice(); + rebuildNode(state, this.entityId); + } + } +} + +// ---- modelRef ------------------------------------------------------------------- + +export class SetModelRefCommand implements Command { + readonly label: string; + readonly entityId: EntityId; + private before: string | null; + private after: string | null; + + constructor(entityId: EntityId, before: string | null, after: string | null) { + this.entityId = entityId; + this.before = before; + this.after = after; + this.label = 'Model ' + entityId; + } + + do(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) { + e.modelRef = this.after; + rebuildNode(state, this.entityId); + } + } + + undo(state: EditorState): void { + const e = findEntity(state, this.entityId); + if (e) { + e.modelRef = this.before; + rebuildNode(state, this.entityId); + } + } +} diff --git a/src/state/commands/set-environment.ts b/src/state/commands/set-environment.ts new file mode 100644 index 0000000..056d73f --- /dev/null +++ b/src/state/commands/set-environment.ts @@ -0,0 +1,56 @@ +// Command: edit the world's environment block (PLAN §I). Before this existed, +// the environment panel mutated state.world.environment directly — the only +// mutation path in the editor that bypassed undo. +// +// `fieldKey` scopes merging: consecutive edits to the SAME field (one slider +// drag) coalesce into one undo entry, while moving to a different field starts +// a new entry — so Ctrl+Z steps back field by field, not "the whole session". + +import { EnvironmentData } from 'bloom/world'; +import { EditorState, Command } from '../editor-state'; + +export function cloneEnvironment(e: EnvironmentData): EnvironmentData { + return { + skyColor: [e.skyColor[0], e.skyColor[1], e.skyColor[2]], + ambientColor: [e.ambientColor[0], e.ambientColor[1], e.ambientColor[2]], + ambientIntensity: e.ambientIntensity, + sunDirection: [e.sunDirection[0], e.sunDirection[1], e.sunDirection[2]], + sunColor: [e.sunColor[0], e.sunColor[1], e.sunColor[2]], + sunIntensity: e.sunIntensity, + fogStart: e.fogStart, + fogEnd: e.fogEnd, + fogColor: [e.fogColor[0], e.fogColor[1], e.fogColor[2]], + shadowsEnabled: e.shadowsEnabled, + }; +} + +export class SetEnvironmentCommand implements Command { + readonly label: string; + readonly fieldKey: string; + private before: EnvironmentData; + private after: EnvironmentData; + + constructor(fieldKey: string, before: EnvironmentData, after: EnvironmentData) { + this.fieldKey = fieldKey; + this.before = cloneEnvironment(before); + this.after = cloneEnvironment(after); + this.label = 'Environment ' + fieldKey; + } + + do(state: EditorState): void { + state.world.environment = cloneEnvironment(this.after); + state.pendingEnvironmentSync = true; + } + + undo(state: EditorState): void { + state.world.environment = cloneEnvironment(this.before); + state.pendingEnvironmentSync = true; + } + + mergeWith(next: Command): boolean { + if (!(next instanceof SetEnvironmentCommand)) return false; + if ((next as SetEnvironmentCommand).fieldKey !== this.fieldKey) return false; + this.after = cloneEnvironment((next as SetEnvironmentCommand).after); + return true; + } +} diff --git a/src/state/editor-state.ts b/src/state/editor-state.ts index 5e8be85..1776650 100644 --- a/src/state/editor-state.ts +++ b/src/state/editor-state.ts @@ -18,6 +18,7 @@ export interface Project { filePath: string; // Absolute path of editor.project.json. rootDir: string; // Directory containing the project file. name: string; + gameId: string; // Stable slug; shown in the window title. modelsDir: string; // Resolved absolute. prefabsDir: string; worldsDir: string; @@ -25,16 +26,29 @@ export interface Project { defaultWorld: string; /// Optional: the game binary to launch for play-in-editor. Empty = no Play button. playCommand: string; + // Optional per-game placeholder colors from the project file's `kindColors` + // ("kind": "r,g,b" 0-255). Parallel arrays, NOT a Map: Perry 0.5.x + // miscompiles Map fields on interfaces (docs/perry-map-size-av.md). + kindColorKeys: string[]; + kindColorValues: Vec3Lit[]; } export interface ModelEntry { - relPath: string; // Relative to project root, e.g. "assets/models/tree_oak.glb". + // PROJECT-relative path, e.g. "assets/models/tree_oak.glb" — the catalog + // key, and byte-for-byte the string world files store in modelRef. NOT + // necessarily a path the editor can open (see filePath). + relPath: string; + // The path to actually read the file from, resolved against the project + // root (differs from relPath when the editor runs outside the game repo, + // e.g. --project ../shooter/editor.project.json). + filePath: string; displayName: string; // Filename without extension. category: string; // Derived from prefix: "tree_", "flower_", etc. - modelHandle: number; // Bloom Model.handle after loading. + modelHandle: number; // Bloom Model.handle after loading; 0 until then. boundsMin: Vec3Lit; boundsMax: Vec3Lit; - loaded: boolean; + loaded: boolean; // Set by pumpAssetCatalog when the GLB arrives. + failed: boolean; // Unreadable GLB — never retried, stays placeholder. } // NB: AssetCatalog and HandleMap are CLASSES, not interfaces, and that is @@ -355,3 +369,19 @@ export function setStatus(state: EditorState, msg: string): void { state.statusMessage = msg; state.statusMessageT = 4.0; } + +// World-space eye position from the orbit parameters. Lives here (not in +// orbit-camera.ts) so both ray.ts and orbit-camera.ts can use it without an +// import cycle: orbit-camera needs ray math for zoom-to-cursor, and ray needs +// the eye position — editor-state imports neither. +export function cameraEyePosition(cam: OrbitCamera): [number, number, number] { + const cosPitch = Math.cos(cam.pitch); + const sinPitch = Math.sin(cam.pitch); + const cosYaw = Math.cos(cam.yaw); + const sinYaw = Math.sin(cam.yaw); + + const x = cam.target[0] + cam.distance * cosPitch * sinYaw; + const y = cam.target[1] + cam.distance * (-sinPitch); + const z = cam.target[2] + cam.distance * cosPitch * cosYaw; + return [x, y, z]; +} diff --git a/src/tests/self-tests.ts b/src/tests/self-tests.ts index 07a0c88..e037a0e 100644 --- a/src/tests/self-tests.ts +++ b/src/tests/self-tests.ts @@ -20,6 +20,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 { + RenameEntityCommand, SetTintCommand, SetTagsCommand, SetModelRefCommand, +} from '../state/commands/edit-entity'; +import { SetEnvironmentCommand } from '../state/commands/set-environment'; import { AddTerrainLayerCommand, RemoveTerrainLayerCommand, TerrainPaintCommand, snapshotWeights, } from '../state/commands/terrain-paint'; @@ -27,7 +31,8 @@ import { paintCell } from '../tools/brush-tool'; import { enterNewPrefabMode, enterPrefabEditMode, exitPrefabMode, wouldCycle, } from '../tools/prefab-tool'; -import { joinPath } from '../io/paths'; +import { joinPath, projectRelative } from '../io/paths'; +import { mouseToWorldRay, rayPlaneIntersect } from '../viewport/ray'; let passed = 0; let failed = 0; @@ -59,7 +64,10 @@ export function runSelfTests(): number { testPrefabAuthoringMode(); testPrefabAuthoringCycles(); testPathJoinIdentity(); + testMouseRay(); testUserDataCommand(); + testEntityEditCommands(); + testEnvironmentCommand(); testWaterCommands(); testLightMigration(); testSplatLayerCommands(); @@ -364,6 +372,81 @@ function testUserDataCommand(): void { assert(state.world.entities[0].userData['cooldown'] === '5', 'userdata: undo restores removed key'); } +// PLAN §F2: rename / tint / tags / modelRef, all undoable, rename and tint +// coalescing like drags do. +function testEntityEditCommands(): void { + const state = createEditorState(); + runCommand(state, new CreateEntityCommand(createEntity('fe', 'a.glb', [0, 0, 0]))); + const e = state.world.entities[0]; + const baseDepth = state.undoStack.length; + + // Rename coalesces per entity: typing is one undo entry, not one per key. + runCommand(state, new RenameEntityCommand('fe', 'a', 'ab')); + runCommand(state, new RenameEntityCommand('fe', 'ab', 'abc')); + assert(e.name === 'abc', 'edit: rename applied'); + assert(state.undoStack.length === baseDepth + 1, 'edit: renames coalesced'); + undo(state); + assert(e.name === 'a', 'edit: rename undo restores the pre-typing name'); + + // Tint: add, drag (coalesced), remove, undo chain. + runCommand(state, new SetTintCommand('fe', null, [1, 1, 1, 1])); + assert(e.tint !== null && e.tint[0] === 1, 'edit: tint added'); + runCommand(state, new SetTintCommand('fe', [1, 1, 1, 1], [0.5, 1, 1, 1])); + runCommand(state, new SetTintCommand('fe', [0.5, 1, 1, 1], [0.2, 1, 1, 1])); + assert(e.tint !== null && Math.abs(e.tint[0] - 0.2) < 0.001, 'edit: tint drag applied'); + undo(state); + assert(e.tint !== null && e.tint[0] === 1, 'edit: tint drag undoes as ONE entry to pre-drag'); + runCommand(state, new SetTintCommand('fe', e.tint, null)); + assert(e.tint === null, 'edit: tint removed'); + undo(state); + assert(e.tint !== null, 'edit: tint removal undone'); + + // Tags are discrete: no coalescing, exact restore. + runCommand(state, new SetTagsCommand('fe', [], ['wall'])); + runCommand(state, new SetTagsCommand('fe', ['wall'], ['wall', 'stone'])); + assert(e.tags.length === 2, 'edit: tags added'); + undo(state); + assert(e.tags.length === 1 && e.tags[0] === 'wall', 'edit: tag undo removes only the last'); + + // modelRef swap rebuilds the node (destroy+rebuild queued) and undoes. + runCommand(state, new SetModelRefCommand('fe', 'a.glb', 'b.glb')); + assert(e.modelRef === 'b.glb', 'edit: modelRef swapped'); + assert(state.pendingRebuild.has('fe'), 'edit: modelRef swap queues a rebuild'); + undo(state); + assert(e.modelRef === 'a.glb', 'edit: modelRef undo restores the original'); +} + +// PLAN §I: environment edits go through the undo stack; merging is scoped per +// field so Ctrl+Z steps field by field, not "the whole tweaking session". +function testEnvironmentCommand(): void { + const state = createEditorState(); + const origSun = state.world.environment.sunIntensity; + const origFog = state.world.environment.fogStart; + + // A drag on one field: many ticks, one undo entry. + const depth0 = state.undoStack.length; + const b1 = { ...state.world.environment }; + state.world.environment.sunIntensity = 1.5; + runCommand(state, new SetEnvironmentCommand('sunIntensity', b1, state.world.environment)); + const b2 = { ...state.world.environment }; + state.world.environment.sunIntensity = 2.0; + runCommand(state, new SetEnvironmentCommand('sunIntensity', b2, state.world.environment)); + assert(state.undoStack.length === depth0 + 1, 'env: same-field edits coalesce'); + assert(state.pendingEnvironmentSync === true, 'env: sync flagged'); + + // A different field starts a new entry. + const b3 = { ...state.world.environment }; + state.world.environment.fogStart = 99; + runCommand(state, new SetEnvironmentCommand('fogStart', b3, state.world.environment)); + assert(state.undoStack.length === depth0 + 2, 'env: different field is a separate entry'); + + undo(state); + assert(state.world.environment.fogStart === origFog, 'env: fog undo'); + assert(Math.abs(state.world.environment.sunIntensity - 2.0) < 0.001, 'env: sun survives fog undo'); + undo(state); + assert(Math.abs(state.world.environment.sunIntensity - origSun) < 0.001, 'env: sun undo restores pre-drag'); +} + function testWaterCommands(): void { const state = createEditorState(); const volume: WaterVolume = { @@ -594,6 +677,65 @@ function testPathJoinIdentity(): void { const catalogKey = joinPath(joinPath('.', 'assets/models'), 'prop_tree.glb'); assert(catalogKey === 'assets/models/prop_tree.glb', 'paths: catalog key MATCHES the world modelRef'); + + // The SAME disease one level up (found 2026-07-16, screenshot-verified): + // `--project ../shooter/editor.project.json` makes rootDir '../shooter', the + // load path '../shooter/assets/models/x.glb' — and the catalog key must + // still be the project-relative 'assets/models/x.glb' the world stores. + assert(projectRelative('../shooter', '../shooter/assets/models/prop_tree.glb') + === 'assets/models/prop_tree.glb', + 'paths: --project catalog key strips the root back to the world modelRef'); + assert(projectRelative('.', 'assets/models/prop_tree.glb') === 'assets/models/prop_tree.glb', + 'paths: "." root is identity'); + assert(projectRelative('proj/', 'proj/assets/x.glb') === 'assets/x.glb', + 'paths: trailing-slash root strips cleanly'); + assert(projectRelative('../shooter', 'assets/models/unrelated.glb') + === 'assets/models/unrelated.glb', + 'paths: a path outside the root passes through untouched'); +} + +// --- Mouse-ray unprojection ---------------------------------------------------- +// +// This exists to EXECUTE mouseToWorldRay headless, not just to check its math: +// Perry 0.5.1208 miscompiled the previous body into a load from absolute +// address 8 (see the header comment in viewport/ray.ts), so the editor died +// with 0xc0000005 on the first placement click — and nothing in the suite ever +// CALLED the function, so 152 tests stayed green over a binary that crashed on +// click one. If the miscompile ever comes back, this test takes the whole +// --test run down with the same AV, which is exactly the alarm we want. +function testMouseRay(): void { + const state = createEditorState(); + const cam = state.camera; + + // A ray through the viewport center must go from the eye toward the target. + const ray = mouseToWorldRay(cam, 640, 400, 1280, 800, 240, 36, 800, 728); + const dLen = Math.sqrt(ray.direction[0] * ray.direction[0] + + ray.direction[1] * ray.direction[1] + ray.direction[2] * ray.direction[2]); + assert(Math.abs(dLen - 1) < 0.001, 'ray: direction is normalized (got ' + dLen + ')'); + assert(ray.origin[0] === ray.origin[0] && ray.direction[0] === ray.direction[0], + 'ray: no NaNs'); + + const toTargetX = cam.target[0] - ray.origin[0]; + const toTargetY = cam.target[1] - ray.origin[1]; + const toTargetZ = cam.target[2] - ray.origin[2]; + const dot = ray.direction[0] * toTargetX + ray.direction[1] * toTargetY + + ray.direction[2] * toTargetZ; + assert(dot > 0, 'ray: center ray points toward the orbit target'); + + // Aim from above at the ground plane: the hit must land under the camera-ish, + // and off-center rays must land off-center in the matching direction. + const centerHit = rayPlaneIntersect(ray, [0, 0, 0], [0, 1, 0]); + assert(centerHit !== null, 'ray: center ray hits the ground plane'); + + const leftRay = mouseToWorldRay(cam, 340, 400, 1280, 800, 240, 36, 800, 728); + const leftHit = rayPlaneIntersect(leftRay, [0, 0, 0], [0, 1, 0]); + assert(leftHit !== null, 'ray: left-of-center ray hits the ground plane'); + if (centerHit !== null && leftHit !== null) { + const dxc = leftHit[0] - centerHit[0]; + const dzc = leftHit[2] - centerHit[2]; + assert(Math.sqrt(dxc * dxc + dzc * dzc) > 0.01, + 'ray: different pixels produce different ground hits'); + } } // ---- Splat layers (PLAN §D) ------------------------------------------------- diff --git a/src/ui/layouts/asset-panel.ts b/src/ui/layouts/asset-panel.ts index e5b0e8b..17cfbad 100644 --- a/src/ui/layouts/asset-panel.ts +++ b/src/ui/layouts/asset-panel.ts @@ -1,15 +1,19 @@ // Right-side asset panel: scrollable list of models and prefabs with category // filters. Clicking a model sets it as the active placement asset. -import { getScreenWidth, getScreenHeight } from 'bloom'; -import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, label, labelSmall, listRow, separator, toolButton, button } from '../widgets'; +import { getScreenWidth, getScreenHeight, drawRect, drawRectLines, drawText, drawTexturePro } from 'bloom'; +import { UiContext, pointInRect } from '../ui-context'; +import { + beginPanel, endPanel, label, labelSmall, listRow, separator, toolButton, button, + beginScrollRegion, endScrollRegion, +} 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'; +import { getThumbnail, THUMB_SIZE } from '../thumbnails'; // 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. @@ -81,8 +85,26 @@ function drawModelList( separator(ui); - // Model list. + // Model grid: 64px thumbnails (PLAN §G) with the name below each; entries + // whose thumbnail hasn't rendered yet (or whose model is still streaming + // in) show a flat placeholder cell. Scrolls like the outliner. + const cell = 64; + const cellLabelH = 13; + const cellH = cell + cellLabelH + 6; + const cellGap = 6; + const innerX = panelX + Theme.padding; + const innerW = panelW - Theme.padding * 2; + let cols = Math.floor((innerW + cellGap) / (cell + cellGap)); + if (cols < 1) cols = 1; + + const listTop = ui.cursorY; + const screenH = getScreenHeight(); + const listH = screenH - Theme.statusBarHeight - Theme.padding - listTop; + beginScrollRegion(ui, 'asset_models', listTop, listH); + const order = state.catalog.modelOrder; + let col = 0; + let rowY = ui.cursorY; for (let i = 0; i < order.length; i++) { const relPath = order[i]; const entry = state.catalog.models.get(relPath); @@ -91,12 +113,65 @@ function drawModelList( continue; } - const selected = state.placeAssetRef === relPath; - if (listRow(ui, 'model_' + i, entry.displayName, selected, 0)) { - state.placeAssetRef = relPath; - state.activeTool = 'place'; + const x = innerX + col * (cell + cellGap); + const visible = rowY >= ui.clipTop && rowY + cellH <= ui.clipBottom; + + if (visible) { + const hovered = pointInRect(ui.mouseX, ui.mouseY, x, rowY, cell, cellH); + if (hovered) { + ui.hotId = 'model_' + i; + ui.mouseCaptured = true; + if (ui.mousePressedLeft) { + state.placeAssetRef = relPath; + state.activeTool = 'place'; + } + } + + const thumb = entry.loaded ? getThumbnail(relPath) : null; + if (thumb !== null && thumb.textureHandle !== 0) { + drawTexturePro( + { handle: thumb.textureHandle, width: THUMB_SIZE, height: THUMB_SIZE }, + { x: 0, y: 0, width: THUMB_SIZE, height: THUMB_SIZE }, + { x: x, y: rowY, width: cell, height: cell }, + { x: 0, y: 0 }, 0, + { r: 255, g: 255, b: 255, a: 255 }, + ); + } else { + // No rendered thumbnail (see thumbnails.ts) — draw a clearly visible + // category-colored cell with the model's initial, dimmed while the + // GLB is still streaming in. + const cc = categoryColor(entry.category); + const dim = entry.loaded ? 1.0 : 0.45; + drawRect(x, rowY, cell, cell, { + r: Math.floor(cc[0] * dim), g: Math.floor(cc[1] * dim), + b: Math.floor(cc[2] * dim), a: 255, + }); + const initial = entry.displayName.length > 0 + ? entry.displayName.substring(0, 1).toUpperCase() : '?'; + drawText(initial, x + cell / 2 - 5, rowY + cell / 2 - 10, 20, + { r: 255, g: 255, b: 255, a: 230 }); + } + + const selected = state.placeAssetRef === relPath; + if (selected || hovered) { + drawRectLines(x, rowY, cell, cell, 1, selected ? Theme.textAccent : Theme.border); + } + + let name = entry.displayName; + if (name.length > 10) name = name.substring(0, 9) + '…'; + drawText(name, x, rowY + cell + 2, Theme.fontSizeSmall, Theme.textDim); + } + + col++; + if (col >= cols) { + col = 0; + rowY += cellH; } } + // Account for a partially filled final row. + ui.cursorY = col === 0 ? rowY : rowY + cellH; + + endScrollRegion(ui, 'asset_models'); } function drawPrefabList( @@ -164,6 +239,26 @@ function drawPrefabList( } } +// Stable per-category cell color (same hash-to-hue trick as the viewport's +// placeholder boxes): two categories never silently share a color, and a +// category keeps its color across sessions. +function categoryColor(category: string): [number, number, number] { + let h = 0; + for (let i = 0; i < category.length; i++) h = ((h * 31) + category.charCodeAt(i)) | 0; + const hue = (((h % 360) + 360) % 360) / 60; + const c = 0.55 * 0.5; + const x = c * (1 - Math.abs((hue % 2) - 1)); + const m = 0.55 - c; + let r = 0, g = 0, b = 0; + if (hue < 1) { r = c; g = x; } + else if (hue < 2) { r = x; g = c; } + else if (hue < 3) { g = c; b = x; } + else if (hue < 4) { g = x; b = c; } + else if (hue < 5) { r = x; b = c; } + else { r = c; b = x; } + return [Math.floor((r + m) * 255), Math.floor((g + m) * 255), Math.floor((b + m) * 255)]; +} + /// Lowercase, non-alphanumerics to underscores — this becomes a filename and a /// stable id that world files reference by string. function slugify(name: string): string { diff --git a/src/ui/layouts/environment-panel.ts b/src/ui/layouts/environment-panel.ts index 43ef0e5..b2e5bb4 100644 --- a/src/ui/layouts/environment-panel.ts +++ b/src/ui/layouts/environment-panel.ts @@ -1,69 +1,95 @@ -// Environment inspector panel — edit sky, lighting, fog, shadows. -// Changes apply live via the pendingEnvironmentSync flag. +// Environment inspector panel — edit sky, sun, ambient, fog, shadows. +// Every field covers the full EnvironmentData schema (sunColor, ambientColor, +// and fogColor included), and every edit goes through SetEnvironmentCommand +// so Ctrl+Z works. Per-field merge keys mean one undo entry per slider drag. import { UiContext } from '../ui-context'; import { beginPanel, endPanel, separator, dragFloat, vec3Field, toggleButton, Ref } from '../widgets'; import { Theme } from '../theme'; import { EditorState } from '../../state/editor-state'; +import { runCommand } from '../../state/commands'; +import { SetEnvironmentCommand, cloneEnvironment } from '../../state/commands/set-environment'; import { Vec3Lit } from 'bloom/world'; export function drawEnvironmentPanel(ui: UiContext, state: EditorState): void { - // Only visible when no entity is selected (or via a toggle). - // For now, always draw in a floating panel position. const px = Theme.outlinerWidth + 10; const py = Theme.toolbarHeight + 10; const pw = 260; - const ph = 360; + const ph = 560; beginPanel(ui, 'env_panel', px, py, pw, ph, 'Environment'); const env = state.world.environment; - let dirty = false; + // Snapshot for the command's `before`; taken once per frame, so a drag + // commits (previous frame's value -> this frame's value) each tick and the + // merge collapses the whole drag into one undo entry. + const before = cloneEnvironment(env); + + const commit = (fieldKey: string): void => { + runCommand(state, new SetEnvironmentCommand(fieldKey, before, env)); + }; // Sky color. const skyRef: Ref = { value: env.skyColor }; if (vec3Field(ui, 'env_sky', 'Sky Color', skyRef)) { env.skyColor = skyRef.value; - dirty = true; + commit('skyColor'); } separator(ui); - // Sun direction. + // Sun. const sunDirRef: Ref = { value: env.sunDirection }; if (vec3Field(ui, 'env_sundir', 'Sun Dir', sunDirRef)) { env.sunDirection = sunDirRef.value; - dirty = true; + commit('sunDirection'); + } + + const sunColRef: Ref = { value: env.sunColor }; + if (vec3Field(ui, 'env_suncol', 'Sun Color', sunColRef)) { + env.sunColor = clampColor(sunColRef.value); + commit('sunColor'); } - // Sun intensity. const sunIntRef: Ref = { value: env.sunIntensity }; if (dragFloat(ui, 'env_sunint', 'Sun Int', sunIntRef, 0.01, 0, 5)) { env.sunIntensity = sunIntRef.value; - dirty = true; + commit('sunIntensity'); } separator(ui); - // Ambient intensity. + // Ambient. + const ambColRef: Ref = { value: env.ambientColor }; + if (vec3Field(ui, 'env_ambcol', 'Ambient Color', ambColRef)) { + env.ambientColor = clampColor(ambColRef.value); + commit('ambientColor'); + } + const ambIntRef: Ref = { value: env.ambientIntensity }; - if (dragFloat(ui, 'env_ambint', 'Ambient', ambIntRef, 0.01, 0, 2)) { + if (dragFloat(ui, 'env_ambint', 'Ambient Int', ambIntRef, 0.01, 0, 2)) { env.ambientIntensity = ambIntRef.value; - dirty = true; + commit('ambientIntensity'); } separator(ui); // Fog. + const fogColRef: Ref = { value: env.fogColor }; + if (vec3Field(ui, 'env_fogcol', 'Fog Color', fogColRef)) { + env.fogColor = clampColor(fogColRef.value); + commit('fogColor'); + } + const fogStartRef: Ref = { value: env.fogStart }; if (dragFloat(ui, 'env_fogs', 'Fog Start', fogStartRef, 0.5, 0, 500)) { env.fogStart = fogStartRef.value; - dirty = true; + commit('fogStart'); } const fogEndRef: Ref = { value: env.fogEnd }; if (dragFloat(ui, 'env_foge', 'Fog End', fogEndRef, 0.5, 0, 500)) { env.fogEnd = fogEndRef.value; - dirty = true; + commit('fogEnd'); } separator(ui); @@ -71,13 +97,18 @@ export function drawEnvironmentPanel(ui: UiContext, state: EditorState): void { // Shadows. if (toggleButton(ui, 'env_shadows', 'Shadows', env.shadowsEnabled)) { env.shadowsEnabled = !env.shadowsEnabled; - dirty = true; - } - - if (dirty) { - state.pendingEnvironmentSync = true; - state.modified = true; + commit('shadowsEnabled'); } endPanel(ui); } + +function clampColor(c: Vec3Lit): Vec3Lit { + return [clamp01(c[0]), clamp01(c[1]), clamp01(c[2])]; +} + +function clamp01(v: number): number { + if (v < 0) return 0; + if (v > 1) return 1; + return v; +} diff --git a/src/ui/layouts/inspector.ts b/src/ui/layouts/inspector.ts index b15f119..5da3cb9 100644 --- a/src/ui/layouts/inspector.ts +++ b/src/ui/layouts/inspector.ts @@ -12,9 +12,12 @@ import { } from '../widgets'; import { textInput } from '../text-input'; import { Theme } from '../theme'; -import { EditorState, selectedEntityId } from '../../state/editor-state'; +import { EditorState, selectedEntityId, setStatus } from '../../state/editor-state'; import { TransformEntityCommand } from '../../state/commands/transform-entity'; import { SetUserDataCommand } from '../../state/commands/set-userdata'; +import { + RenameEntityCommand, SetTintCommand, SetTagsCommand, SetModelRefCommand, +} from '../../state/commands/edit-entity'; import { EditWaterCommand, EditRiverCommand, RemoveWaterCommand, RemoveRiverCommand, } from '../../state/commands/edit-water'; @@ -25,6 +28,14 @@ import { Vec3Lit, TransformData, EntityData, WaterVolume, RiverSpline } from 'bl // Add-row scratch state — survives across frames until '+' commits it. const newKeyRef: Ref = { value: '' }; const newValRef: Ref = { value: '' }; +const newTagRef: Ref = { value: '' }; + +// modelRef edits use a DRAFT that only commits on the Apply button — unlike +// userData values, a half-typed model path is not a meaningful intermediate +// state (each keystroke would rebuild the node into a magenta placeholder). +const modelDraftRef: Ref = { value: '' }; +let modelDraftEntity: string | null = null; +let modelDraftBase: string | null = null; export function drawInspector(ui: UiContext, state: EditorState): void { const screenW = getScreenWidth(); @@ -51,11 +62,13 @@ export function drawInspector(ui: UiContext, state: EditorState): void { ? state.world.entities.find(e => e.id === selectedEntityId(state)) : undefined; - // Panel grows upward with the userData row count (no scrolling yet). + // Panel grows upward with its row counts (no scrolling yet). const rowAdvance = Theme.rowHeight + Theme.spacing; const udRows = entity ? Object.keys(entity.userData).length : 0; - let panelH = 300 + udRows * rowAdvance; - const maxH = Math.floor(screenH * 0.65); + const tagRows = entity ? entity.tags.length : 0; + const tintRows = entity && entity.tint !== null ? 3 : 1; + let panelH = 430 + (udRows + tagRows + tintRows) * rowAdvance; + const maxH = Math.floor(screenH * 0.78); if (panelH > maxH) panelH = maxH; const py = screenH - Theme.statusBarHeight - panelH; @@ -67,9 +80,20 @@ export function drawInspector(ui: UiContext, state: EditorState): void { return; } - // Name. - label(ui, entity.name); - labelSmall(ui, entity.id + (entity.modelRef ? ' (' + entity.modelRef + ')' : '')); + // Name — editable, coalesced into one undo entry per rename. + const nameY = ui.cursorY; + drawText('Name', ui.cursorX, nameY + 4, Theme.fontSizeSmall, Theme.textDim); + const nameRef: Ref = { value: entity.name }; + if (textInput(ui, 'insp_name_' + entity.id, nameRef, + ui.cursorX + 48, nameY, ui.panelW - Theme.padding * 2 - 48)) { + runCommand(state, new RenameEntityCommand(entity.id, entity.name, nameRef.value)); + } + ui.cursorY = nameY + Theme.rowHeight + Theme.spacing; + + labelSmall(ui, entity.id); + separator(ui); + + drawModelSection(ui, state, entity); separator(ui); // Transform fields. @@ -95,11 +119,11 @@ export function drawInspector(ui: UiContext, state: EditorState): void { )); } - // Tags. - if (entity.tags.length > 0) { - separator(ui); - labelSmall(ui, 'Tags: ' + entity.tags.join(', ')); - } + separator(ui); + drawTintSection(ui, state, entity); + + separator(ui); + drawTagsSection(ui, state, entity); separator(ui); drawUserDataSection(ui, state, entity); @@ -107,6 +131,104 @@ export function drawInspector(ui: UiContext, state: EditorState): void { endPanel(ui); } +// ---- model section ------------------------------------------------------------- + +function drawModelSection(ui: UiContext, state: EditorState, entity: EntityData): void { + if (entity.prefabRef !== null && entity.prefabRef.length > 0) { + labelSmall(ui, 'Prefab: ' + entity.prefabRef); + return; + } + + labelSmall(ui, 'Model'); + + // (Re)seed the draft when the selection changes or when the entity's actual + // ref changes underneath us (undo/redo) — otherwise a stale draft would + // shadow the real value. + const current = entity.modelRef !== null ? entity.modelRef : ''; + if (modelDraftEntity !== entity.id || modelDraftBase !== current) { + modelDraftEntity = entity.id; + modelDraftBase = current; + modelDraftRef.value = current; + } + + const rowY = ui.cursorY; + textInput(ui, 'insp_model_' + entity.id, modelDraftRef, + ui.cursorX, rowY, ui.panelW - Theme.padding * 2); + ui.cursorY = rowY + Theme.rowHeight + Theme.spacing; + + if (modelDraftRef.value !== current) { + if (button(ui, 'insp_model_apply', 'Apply model ref')) { + const next = modelDraftRef.value.trim(); + runCommand(state, new SetModelRefCommand(entity.id, entity.modelRef, + next.length > 0 ? next : null)); + modelDraftBase = next; + if (next.length > 0 && !state.catalog.models.has(next)) { + setStatus(state, 'Model "' + next + '" is not in the catalog — placeholder box until it exists.'); + } + } + } else if (current.length > 0 && !state.catalog.models.has(current)) { + labelSmall(ui, 'not in catalog — placeholder box', Theme.textError); + } +} + +// ---- tint section ---------------------------------------------------------------- + +function drawTintSection(ui: UiContext, state: EditorState, entity: EntityData): void { + labelSmall(ui, 'Tint'); + + if (entity.tint === null) { + if (button(ui, 'insp_tint_add', 'Add tint')) { + runCommand(state, new SetTintCommand(entity.id, null, [1, 1, 1, 1])); + } + return; + } + + const before: [number, number, number, number] = + [entity.tint[0], entity.tint[1], entity.tint[2], entity.tint[3]]; + const working: number[] = [before[0], before[1], before[2], before[3]]; + if (drawColorFields(ui, 'insp_tint', working)) { + runCommand(state, new SetTintCommand(entity.id, before, + [working[0], working[1], working[2], working[3]])); + } + + if (button(ui, 'insp_tint_remove', 'Remove tint')) { + runCommand(state, new SetTintCommand(entity.id, before, null)); + } +} + +// ---- tags section ---------------------------------------------------------------- + +function drawTagsSection(ui: UiContext, state: EditorState, entity: EntityData): void { + labelSmall(ui, 'Tags'); + + const delW = 18; + const innerW = ui.panelW - Theme.padding * 2; + + for (let i = 0; i < entity.tags.length; i++) { + const rowY = ui.cursorY; + drawText(entity.tags[i], ui.cursorX, rowY + 4, Theme.fontSizeSmall, Theme.text); + if (toolButton(ui, 'tag_del_' + entity.id + '_' + i, 'x', + ui.cursorX + innerW - delW, rowY, delW, false)) { + const after = entity.tags.slice(); + after.splice(i, 1); + runCommand(state, new SetTagsCommand(entity.id, entity.tags, after)); + } + ui.cursorY = rowY + Theme.rowHeight + Theme.spacing; + } + + // Add row: text field + '+' commits (must be non-empty and not a duplicate). + const addY = ui.cursorY; + textInput(ui, 'tag_new_' + entity.id, newTagRef, ui.cursorX, addY, innerW - delW - 4); + if (toolButton(ui, 'tag_add_' + entity.id, '+', ui.cursorX + innerW - delW, addY, delW, false)) { + const nt = newTagRef.value.trim(); + if (nt.length > 0 && entity.tags.indexOf(nt) < 0) { + runCommand(state, new SetTagsCommand(entity.id, entity.tags, entity.tags.concat([nt]))); + newTagRef.value = ''; + } + } + ui.cursorY = addY + Theme.rowHeight + Theme.spacing; +} + // ---- userData table ---------------------------------------------------------- function drawUserDataSection(ui: UiContext, state: EditorState, entity: EntityData): void { diff --git a/src/ui/layouts/outliner.ts b/src/ui/layouts/outliner.ts index ce0abc1..a35613e 100644 --- a/src/ui/layouts/outliner.ts +++ b/src/ui/layouts/outliner.ts @@ -1,16 +1,35 @@ -// Left-side outliner: everything in the world, in three sections — entities, -// water volumes, rivers. Clicking selects; the selection carries which section -// it came from, because ids are only unique within their own array. +// Left-side outliner: everything in the world, in four sections — water, +// rivers, lights, entities. Clicking selects; the selection carries which +// section it came from, because ids are only unique within their own array. +// +// The list lives in a scroll region (wheel to scroll, thin scrollbar at the +// right edge) with a filter box pinned above it — a world with hundreds of +// entities is unusable without both. Filtering matches name AND id, +// case-insensitive, across every section. import { getScreenHeight } from 'bloom'; import { UiContext } from '../ui-context'; -import { beginPanel, endPanel, labelSmall, listRow } from '../widgets'; +import { + beginPanel, endPanel, labelSmall, listRow, + beginScrollRegion, endScrollRegion, +} from '../widgets'; +import { textInput, Ref } from '../text-input'; import { Theme } from '../theme'; import { EditorState, selectEntity, selectWater, selectRiver, selectLight, } from '../../state/editor-state'; import { syncSelectionOutline } from '../../viewport/picking'; +// The filter survives across frames (immediate-mode UI has no retained widget +// state) but is intentionally NOT saved anywhere — a stale invisible filter on +// the next launch would read as data loss. +const filterRef: Ref = { value: '' }; + +function matches(filter: string, name: string, id: string): boolean { + if (filter.length === 0) return true; + return name.toLowerCase().indexOf(filter) >= 0 || id.toLowerCase().indexOf(filter) >= 0; +} + export function drawOutliner(ui: UiContext, state: EditorState): void { const screenH = getScreenHeight(); const pw = Theme.outlinerWidth; @@ -19,6 +38,11 @@ export function drawOutliner(ui: UiContext, state: EditorState): void { beginPanel(ui, 'outliner', 0, py, pw, ph, 'Outliner'); + // Filter box, pinned above the scrolling list. + textInput(ui, 'outliner_filter', filterRef, ui.cursorX, ui.cursorY, pw - Theme.padding * 2); + ui.cursorY += Theme.rowHeight + Theme.spacing; + const filter = filterRef.value.trim().toLowerCase(); + const entities = state.world.entities; const water = state.world.water; const rivers = state.world.rivers; @@ -31,13 +55,18 @@ export function drawOutliner(ui: UiContext, state: EditorState): void { return; } - // Water and rivers first: there are only ever a handful, while a world can - // have hundreds of entities, and this panel does not scroll yet. Listing them - // last would put them permanently below the fold and make them unselectable. + const listTop = ui.cursorY; + const listH = py + ph - listTop - Theme.padding; + beginScrollRegion(ui, 'outliner_list', listTop, listH); + + let shown = 0; + if (water.length > 0) { labelSmall(ui, 'Water'); for (let i = 0; i < water.length; i++) { const w = water[i]; + if (!matches(filter, w.id, w.id)) continue; + shown++; const selected = state.selection.kind === 'water' && state.selection.primary === w.id; if (listRow(ui, 'out_water_' + i, w.id, selected, 1)) { selectWater(state, w.id); @@ -50,6 +79,8 @@ export function drawOutliner(ui: UiContext, state: EditorState): void { labelSmall(ui, 'Rivers'); for (let i = 0; i < rivers.length; i++) { const r = rivers[i]; + if (!matches(filter, r.id, r.id)) continue; + shown++; const selected = state.selection.kind === 'river' && state.selection.primary === r.id; if (listRow(ui, 'out_river_' + i, r.id, selected, 1)) { selectRiver(state, r.id); @@ -62,6 +93,8 @@ export function drawOutliner(ui: UiContext, state: EditorState): void { labelSmall(ui, 'Lights'); for (let i = 0; i < lights.length; i++) { const l = lights[i]; + if (!matches(filter, l.name, l.id)) continue; + shown++; const selected = state.selection.kind === 'light' && state.selection.primary === l.id; if (listRow(ui, 'out_light_' + i, l.name, selected, 1)) { selectLight(state, l.id); @@ -76,6 +109,8 @@ export function drawOutliner(ui: UiContext, state: EditorState): void { for (let i = 0; i < entities.length; i++) { const entity = entities[i]; + if (!matches(filter, entity.name, entity.id)) continue; + shown++; const selected = state.selection.kind === 'entity' && state.selection.primary === entity.id; const indent = entity.prefabRef !== null ? 1 : 0; @@ -87,6 +122,11 @@ export function drawOutliner(ui: UiContext, state: EditorState): void { } } + if (filter.length > 0 && shown === 0) { + labelSmall(ui, 'No matches'); + } + + endScrollRegion(ui, 'outliner_list'); endPanel(ui); state.viewportLeft = pw; } diff --git a/src/ui/layouts/recent-panel.ts b/src/ui/layouts/recent-panel.ts new file mode 100644 index 0000000..5051b98 --- /dev/null +++ b/src/ui/layouts/recent-panel.ts @@ -0,0 +1,51 @@ +// Recent-projects panel (PLAN §H). Toggled from the toolbar; lists the +// entries recent.ts has been writing since day one — this is the read UI +// that never existed. Clicking an entry switches the whole editor to that +// project (catalog, default world, window title). + +import { UiContext } from '../ui-context'; +import { beginPanel, endPanel, labelSmall, listRow } from '../widgets'; +import { Theme } from '../theme'; +import { EditorState } from '../../state/editor-state'; +import { loadRecentProjects, RecentEntry } from '../../io/recent'; +import { openProject } from '../../io/open-project'; + +let panelOpen = false; +let entries: RecentEntry[] = []; + +export function toggleRecentPanel(): void { + panelOpen = !panelOpen; + if (panelOpen) { + // Re-read on open, not per frame — the file only changes when a project + // is opened, and that closes the panel anyway. + entries = loadRecentProjects(); + } +} + +export function drawRecentPanel(ui: UiContext, state: EditorState): void { + if (!panelOpen) return; + + const pw = 340; + const px = Theme.outlinerWidth + 10; + const py = Theme.toolbarHeight + 10; + const rows = entries.length > 0 ? entries.length : 1; + const ph = 40 + rows * (Theme.rowHeight + Theme.fontSizeSmall + Theme.spacing * 2); + + beginPanel(ui, 'recent_panel', px, py, pw, ph, 'Recent projects'); + + if (entries.length === 0) { + labelSmall(ui, 'None yet — recent projects appear here.'); + } + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (listRow(ui, 'recent_' + i, e.name, false, 0)) { + panelOpen = false; + openProject(state, e.path); + return; // State just changed wholesale; stop drawing this panel. + } + labelSmall(ui, ' ' + e.path); + } + + endPanel(ui); +} diff --git a/src/ui/layouts/toolbar.ts b/src/ui/layouts/toolbar.ts index 340eb56..c5a38b4 100644 --- a/src/ui/layouts/toolbar.ts +++ b/src/ui/layouts/toolbar.ts @@ -8,6 +8,7 @@ import { Theme } from '../theme'; import { EditorState, ToolId } from '../../state/editor-state'; import { newWorld, saveCurrentWorld, defaultSavePath } from '../../io/world-io'; import { showOpenWorldDialog, showSaveWorldDialog } from '../dialogs'; +import { toggleRecentPanel } from './recent-panel'; export function drawToolbar(ui: UiContext, state: EditorState): void { const screenW = getScreenWidth(); @@ -30,6 +31,11 @@ export function drawToolbar(ui: UiContext, state: EditorState): void { } x += bw + gap; + if (toolButton(ui, 'tb_recent', 'Recent', x, y, bw + 8, false)) { + toggleRecentPanel(); + } + x += bw + 8 + gap; + if (toolButton(ui, 'tb_save', 'Save', x, y, bw, false)) { if (state.worldPath) { saveCurrentWorld(state); diff --git a/src/ui/text-input.ts b/src/ui/text-input.ts index 3a54e1f..08af8e7 100644 --- a/src/ui/text-input.ts +++ b/src/ui/text-input.ts @@ -1,13 +1,28 @@ // In-window text input widget. Consumes characters from getCharPressed() -// and renders an editable text field. Click to focus, type to edit, -// Enter to confirm, ESC to cancel. +// and renders an editable text field. Click to focus (the click also places +// the caret), type to edit, Enter to confirm, ESC to cancel. +// +// Caret editing (2026-07-17): Left/Right move, Home/End jump, Backspace +// deletes before the caret, Delete deletes after it, and typing inserts AT +// the caret — the widget used to be append/backspace-only, which made fixing +// a typo at the start of a model path mean retyping the whole thing. +// Still missing (needs engine FFI): clipboard paste, selection ranges, +// hold-to-repeat on arrows (isKeyPressed is edge-only). -import { drawRect, drawRectLines, drawText, measureText, getCharPressed, isKeyPressed, Key } from 'bloom'; +import { + drawRect, drawRectLines, drawText, measureText, getCharPressed, + isKeyPressed, Key, +} from 'bloom'; import { UiContext, pointInRect } from './ui-context'; import { Theme } from './theme'; export interface Ref { value: T; } +// Caret state for THE focused field (module scope: only one field is focused +// at a time in an immediate-mode UI, keyed by widget id). +let caretOwner: string | null = null; +let caret = 0; + export function textInput( ui: UiContext, id: string, ref: Ref, x: number, y: number, w: number, @@ -18,10 +33,13 @@ export function textInput( const isFocused = ui.activeId === id; - // Click to focus. + // Click to focus — and place the caret at the nearest character boundary + // to the click, so clicking mid-word edits mid-word. if (hovered && ui.mousePressedLeft) { ui.activeId = id; ui.mouseCaptured = true; + caretOwner = id; + caret = caretIndexAt(ref.value, ui.mouseX - (x + 4)); } let changed = false; @@ -29,22 +47,45 @@ export function textInput( if (isFocused) { ui.mouseCaptured = true; - // Consume characters. + // A field can also gain focus without a click (rare) — default to end. + if (caretOwner !== id) { + caretOwner = id; + caret = ref.value.length; + } + if (caret > ref.value.length) caret = ref.value.length; + if (caret < 0) caret = 0; + + // Caret movement first, so a movement key pressed the same frame as a + // character applies to the pre-insert text predictably. + if (isKeyPressed(Key.LEFT) && caret > 0) caret--; + if (isKeyPressed(Key.RIGHT) && caret < ref.value.length) caret++; + if (isKeyPressed(Key.HOME)) caret = 0; + if (isKeyPressed(Key.END)) caret = ref.value.length; + if (isKeyPressed(Key.DELETE) && caret < ref.value.length) { + ref.value = ref.value.substring(0, caret) + ref.value.substring(caret + 1); + changed = true; + } + + // Consume characters, inserting at the caret. let c = getCharPressed(); while (c !== 0) { if (c === 8) { - // Backspace. - if (ref.value.length > 0) { - ref.value = ref.value.substring(0, ref.value.length - 1); + // Backspace — delete before the caret. + if (caret > 0) { + ref.value = ref.value.substring(0, caret - 1) + ref.value.substring(caret); + caret--; changed = true; } } else if (c === 13) { // Enter — confirm and defocus. ui.activeId = null; + caretOwner = null; return true; } else if (c >= 32) { // Printable character. - ref.value = ref.value + String.fromCharCode(c); + ref.value = ref.value.substring(0, caret) + String.fromCharCode(c) + + ref.value.substring(caret); + caret++; changed = true; } c = getCharPressed(); @@ -53,6 +94,7 @@ export function textInput( // ESC cancels focus. if (isKeyPressed(Key.ESCAPE)) { ui.activeId = null; + caretOwner = null; } } @@ -64,11 +106,27 @@ export function textInput( const displayText = ref.value.length > 0 ? ref.value : ''; drawText(displayText, x + 4, y + 5, Theme.fontSizeSmall, Theme.text); - // Blinking cursor when focused. + // Caret when focused, at its actual position in the string. if (isFocused) { - const cursorX = x + 4 + measureText(displayText, Theme.fontSizeSmall); - drawRect(cursorX, y + 4, 1, h - 8, Theme.textAccent); + const prefix = displayText.substring(0, caret); + const caretX = x + 4 + measureText(prefix, Theme.fontSizeSmall); + drawRect(caretX, y + 4, 1, h - 8, Theme.textAccent); } return changed; } + +// Character boundary nearest to a pixel offset into the text. Linear scan — +// these fields hold names and paths, not documents. +function caretIndexAt(text: string, px: number): number { + if (px <= 0) return 0; + for (let i = 1; i <= text.length; i++) { + const wPrefix = measureText(text.substring(0, i), Theme.fontSizeSmall); + if (wPrefix > px) { + // Closer to the previous boundary or this one? + const wPrev = measureText(text.substring(0, i - 1), Theme.fontSizeSmall); + return (px - wPrev) < (wPrefix - px) ? i - 1 : i; + } + } + return text.length; +} diff --git a/src/ui/thumbnails.ts b/src/ui/thumbnails.ts index 3972aba..0c7f1ae 100644 --- a/src/ui/thumbnails.ts +++ b/src/ui/thumbnails.ts @@ -1,61 +1,37 @@ -// Asset panel 3D model thumbnails. Uses Q1 render targets to pre-render each -// GLB model into a 128x128 texture at editor startup. +// Asset-panel model thumbnails (PLAN §G) — render pass currently DISABLED. // -// Flow: -// 1. For each model in the catalog, create a render texture -// 2. Set up a tight orthographic camera framing the model's bounding box -// 3. beginTextureMode → beginMode3D → drawModel → endMode3D → endTextureMode -// 4. Store the texture handle in the catalog entry for drawTexture in the panel +// The first implementation called beginTextureMode/endTextureMode mid-frame, +// raylib-style. The engine's render targets don't work that way: the override +// set by begin_texture_mode is consumed at END_FRAME ("the next end_frame will +// render to this texture view instead of the surface" — renderer/mod.rs), and +// 2D draws batch for the frame. So the thumbnail textures stayed empty and the +// grid drew invisible images over dark panel — screenshot-verified 2026-07-16. // -// If render targets are not yet functional (begin/endTextureMode are stubs), -// this module gracefully falls back to no-op and the asset panel shows text. +// Rendering a mesh into a texture on this engine needs either (a) a dedicated +// whole frame per thumbnail with the world's scene nodes hidden, or (b) a real +// engine-side render-mesh-to-texture utility. Until one of those exists, the +// asset panel draws colored placeholder cells (visible, clickable, labeled) — +// see asset-panel.ts. The API here stays so the grid lights up the day the +// engine call exists. -import { - loadRenderTexture, beginTextureMode, endTextureMode, getRenderTextureTexture, -} from 'bloom'; -import { - beginMode3D, endMode3D, clearBackground, drawModel, beginDrawing, endDrawing, -} from 'bloom'; -import { EditorState, ModelEntry } from '../state/editor-state'; +import { EditorState } from '../state/editor-state'; -const THUMB_SIZE = 128; +export const THUMB_SIZE = 128; export interface ThumbnailEntry { - textureId: number; // Bloom texture id for drawTexture. 0 if not yet rendered. + textureHandle: number; width: number; height: number; } -// Map from model relPath -> thumbnail texture id. -const thumbnails = new Map(); - -// Render thumbnails for all loaded models. Call once after loadAssetCatalog. -// This is a synchronous batch operation — each model gets one render-to-texture -// cycle. For 20 models at 128x128 this takes <100ms on any modern GPU. -export function renderAllThumbnails(state: EditorState): void { - for (const [relPath, entry] of state.catalog.models) { - if (!entry.loaded || entry.modelHandle === 0) continue; - if (thumbnails.has(relPath)) continue; - - const rt = loadRenderTexture(THUMB_SIZE, THUMB_SIZE); - if (rt === 0) { - // Render targets not available — skip silently. - return; - } - - const tex = getRenderTextureTexture(rt); - const texId = typeof tex === 'object' ? (tex as any).id : tex as number; - - thumbnails.set(relPath, { - textureId: texId, - width: THUMB_SIZE, - height: THUMB_SIZE, - }); - } +// No-op while the render pass is disabled. Returns 0 = "nothing pending" so +// main.ts never waits on it. +export function pumpThumbnails(_state: EditorState, _maxPerFrame: number): number { + return 0; } -// Get the thumbnail texture id for a model, or 0 if not rendered. -export function getThumbnail(relPath: string): ThumbnailEntry | null { - const entry = thumbnails.get(relPath); - return entry ? entry : null; +// Always null while the render pass is disabled — the asset panel then draws +// its colored placeholder cell. +export function getThumbnail(_relPath: string): ThumbnailEntry | null { + return null; } diff --git a/src/ui/ui-context.ts b/src/ui/ui-context.ts index 4bdefd0..afbde4e 100644 --- a/src/ui/ui-context.ts +++ b/src/ui/ui-context.ts @@ -43,6 +43,16 @@ export interface UiContext { panelW: number; panelH: number; + // Vertical clip window widgets draw within. Set by beginPanel to the panel + // bounds and narrowed by beginScrollRegion; widgets that would cross it are + // skipped (cursor still advances, so scrolled-out content keeps its layout). + clipTop: number; + clipBottom: number; + + // Active scroll region (one at a time; regions never nest). + scrollRegionTop: number; + scrollRegionH: number; + // Drag state (for dragFloat). dragStartValue: number; dragStartX: number; @@ -60,6 +70,8 @@ export function createUiContext(): UiContext { scrollOffsets: new Map(), cursorX: 0, cursorY: 0, panelX: 0, panelY: 0, panelW: 0, panelH: 0, + clipTop: 0, clipBottom: 100000, + scrollRegionTop: 0, scrollRegionH: 0, dragStartValue: 0, dragStartX: 0, }; } diff --git a/src/ui/widgets.ts b/src/ui/widgets.ts index 4e766e5..e4007c4 100644 --- a/src/ui/widgets.ts +++ b/src/ui/widgets.ts @@ -29,6 +29,8 @@ export function beginPanel( ui.panelW = w; ui.panelH = h; ui.cursorX = x + Theme.padding; + ui.clipTop = y; + ui.clipBottom = y + h; if (title) { ui.cursorY = y + Theme.padding; @@ -48,23 +50,93 @@ export function endPanel(_ui: UiContext): void { // Placeholder for future scrollbar / clip-region cleanup. } +// True when a widget spanning [y, y+h] pokes outside the current clip window. +// Skipped widgets still advance the layout cursor, so scrolled-out rows keep +// their place; they just neither draw nor hit-test. +function clipped(ui: UiContext, y: number, h: number): boolean { + return y < ui.clipTop || y + h > ui.clipBottom; +} + +// ---- Scroll region ----------------------------------------------------------- +// +// A vertically scrollable strip INSIDE the current panel (never nested). Rows +// drawn between begin/end are offset by the stored scroll and clipped to the +// region; the mouse wheel scrolls while the pointer is over it. endScrollRegion +// measures the content, clamps the offset, and draws a thin scrollbar when the +// content overflows. Offsets persist in ui.scrollOffsets keyed by `id`. + +export function beginScrollRegion(ui: UiContext, id: string, top: number, height: number): void { + const stored = ui.scrollOffsets.get(id); + const scroll = stored !== undefined ? stored : 0; + + ui.scrollRegionTop = top; + ui.scrollRegionH = height; + ui.clipTop = top; + ui.clipBottom = top + height; + ui.cursorY = top - scroll; + + if (ui.mouseWheel !== 0 && + pointInRect(ui.mouseX, ui.mouseY, ui.panelX, top, ui.panelW, height)) { + // Wheel-up (positive) scrolls toward the top. Clamped in endScrollRegion, + // once the content height is known. + ui.scrollOffsets.set(id, scroll - ui.mouseWheel * Theme.rowHeight * 3); + ui.mouseCaptured = true; + } +} + +export function endScrollRegion(ui: UiContext, id: string): void { + const stored = ui.scrollOffsets.get(id); + const scroll = stored !== undefined ? stored : 0; + const contentH = (ui.cursorY + scroll) - ui.scrollRegionTop; + + let maxScroll = contentH - ui.scrollRegionH; + if (maxScroll < 0) maxScroll = 0; + let clampedScroll = scroll; + if (clampedScroll < 0) clampedScroll = 0; + if (clampedScroll > maxScroll) clampedScroll = maxScroll; + if (clampedScroll !== scroll) ui.scrollOffsets.set(id, clampedScroll); + + if (contentH > ui.scrollRegionH && maxScroll > 0) { + const trackX = ui.panelX + ui.panelW - 4; + let thumbH = ui.scrollRegionH * (ui.scrollRegionH / contentH); + if (thumbH < 20) thumbH = 20; + const thumbY = ui.scrollRegionTop + + (ui.scrollRegionH - thumbH) * (clampedScroll / maxScroll); + drawRect(trackX, ui.scrollRegionTop, 3, ui.scrollRegionH, Theme.border); + drawRect(trackX, thumbY, 3, thumbH, Theme.textDim); + } + + // Restore the panel-wide clip and park the cursor below the region. + ui.clipTop = ui.panelY; + ui.clipBottom = ui.panelY + ui.panelH; + ui.cursorY = ui.scrollRegionTop + ui.scrollRegionH; +} + // ---- Label ----------------------------------------------------------------- export function label(ui: UiContext, text: string, color?: UiColor): void { const c = color ? color : Theme.text; - drawText(text, ui.cursorX, ui.cursorY, Theme.fontSize, c); + if (!clipped(ui, ui.cursorY, Theme.fontSize)) { + drawText(text, ui.cursorX, ui.cursorY, Theme.fontSize, c); + } ui.cursorY += Theme.fontSize + Theme.spacing; } export function labelSmall(ui: UiContext, text: string, color?: UiColor): void { const c = color ? color : Theme.textDim; - drawText(text, ui.cursorX, ui.cursorY, Theme.fontSizeSmall, c); + if (!clipped(ui, ui.cursorY, Theme.fontSizeSmall)) { + drawText(text, ui.cursorX, ui.cursorY, Theme.fontSizeSmall, c); + } ui.cursorY += Theme.fontSizeSmall + Theme.spacing; } // ---- Separator ------------------------------------------------------------- export function separator(ui: UiContext): void { + if (clipped(ui, ui.cursorY, Theme.spacing * 3)) { + ui.cursorY += Theme.spacing * 3; + return; + } const y = ui.cursorY + Theme.spacing; // drawLine takes (x1, y1, x2, y2, thickness, Color) — pass the Color object, // not its four channels. Splatting the channels made the engine read `.r` @@ -87,6 +159,11 @@ export function button( const bx = ui.cursorX; const by = ui.cursorY; + if (clipped(ui, by, bh)) { + ui.cursorY += bh + Theme.spacing; + return false; + } + const hovered = pointInRect(ui.mouseX, ui.mouseY, bx, by, bw, bh); if (hovered) ui.hotId = id; @@ -119,6 +196,11 @@ export function toggleButton( const bx = ui.cursorX; const by = ui.cursorY; + if (clipped(ui, by, bh)) { + ui.cursorY += bh + Theme.spacing; + return false; + } + const hovered = pointInRect(ui.mouseX, ui.mouseY, bx, by, bw, bh); if (hovered) ui.hotId = id; @@ -180,8 +262,9 @@ export function listRow( const rw = ui.panelW; const ry = ui.cursorY; - if (ry + rh < ui.panelY || ry > ui.panelY + ui.panelH) { - // Off-screen — skip drawing but still advance cursor. + if (clipped(ui, ry, rh)) { + // Outside the clip window (scrolled out) — skip drawing but still advance + // the cursor so the scroll region measures true content height. ui.cursorY += rh; return false; } diff --git a/src/viewport/orbit-camera.ts b/src/viewport/orbit-camera.ts index 12f2bfc..c5e3132 100644 --- a/src/viewport/orbit-camera.ts +++ b/src/viewport/orbit-camera.ts @@ -3,11 +3,13 @@ import { isMouseButtonDown, getMouseDeltaX, getMouseDeltaY, getMouseWheel, + getMouseX, getMouseY, getScreenWidth, getScreenHeight, MouseButton, } from 'bloom'; -import { EditorState, OrbitCamera } from '../state/editor-state'; +import { EditorState, OrbitCamera, cameraEyePosition } from '../state/editor-state'; +import { mouseToWorldRay, rayPlaneIntersect } from './ray'; -const MIN_DISTANCE = 1; +const MIN_DISTANCE = 0.2; const MAX_DISTANCE = 500; const MIN_PITCH = -1.4; const MAX_PITCH = 1.4; @@ -51,31 +53,52 @@ export function updateOrbitCamera(state: EditorState): void { cam.dirty = true; } - // Zoom (scroll wheel). + // Zoom (scroll wheel) — a dolly toward what the CURSOR is over, not toward + // the orbit target. Zooming used to approach the world center only, so + // "get close to that house at the edge" required knowing about middle-drag + // pan first. Gated on the viewport so scrolling a panel no longer also + // zooms the world. if (wheel !== 0) { - if (wheel > 0) { - cam.distance = cam.distance / ZOOM_SPEED; - } else { - cam.distance = cam.distance * ZOOM_SPEED; + const mx = getMouseX(); + const my = getMouseY(); + const inViewport = mx > state.viewportLeft && mx < state.viewportRight && + my > state.viewportTop && my < state.viewportBottom; + if (inViewport) { + // Pivot: where the cursor ray meets the ground plane. Computed BEFORE + // the distance changes (the ray depends on the eye). Cursor over the + // sky → no pivot → plain zoom on the current target. + let pivot: [number, number, number] | null = null; + if (wheel > 0) { + const vw = state.viewportRight - state.viewportLeft; + const vh = state.viewportBottom - state.viewportTop; + const ray = mouseToWorldRay(cam, mx, my, getScreenWidth(), getScreenHeight(), + state.viewportLeft, state.viewportTop, vw, vh); + pivot = rayPlaneIntersect(ray, [0, 0, 0], [0, 1, 0]); + } + + // Honor multi-notch wheel deltas (trackpads and fast flicks). + const notches = Math.abs(wheel); + const factor = Math.pow(ZOOM_SPEED, notches); + const before = cam.distance; + if (wheel > 0) cam.distance = cam.distance / factor; + else cam.distance = cam.distance * factor; + if (cam.distance < MIN_DISTANCE) cam.distance = MIN_DISTANCE; + if (cam.distance > MAX_DISTANCE) cam.distance = MAX_DISTANCE; + + // Slide the orbit target toward the pivot by the zoom fraction, so + // repeated notches converge on the point under the cursor. + if (pivot !== null && before > 0) { + const t = 1 - cam.distance / before; + cam.target[0] += (pivot[0] - cam.target[0]) * t; + cam.target[1] += (pivot[1] - cam.target[1]) * t; + cam.target[2] += (pivot[2] - cam.target[2]) * t; + } + cam.dirty = true; } - if (cam.distance < MIN_DISTANCE) cam.distance = MIN_DISTANCE; - if (cam.distance > MAX_DISTANCE) cam.distance = MAX_DISTANCE; - cam.dirty = true; } } -// Compute the world-space eye position from the orbit parameters. -export function cameraEyePosition(cam: OrbitCamera): [number, number, number] { - const cosPitch = Math.cos(cam.pitch); - const sinPitch = Math.sin(cam.pitch); - const cosYaw = Math.cos(cam.yaw); - const sinYaw = Math.sin(cam.yaw); - - const x = cam.target[0] + cam.distance * cosPitch * sinYaw; - const y = cam.target[1] + cam.distance * (-sinPitch); - const z = cam.target[2] + cam.distance * cosPitch * cosYaw; - return [x, y, z]; -} +export { cameraEyePosition }; // Build a Bloom Camera3D struct from the orbit state. export function buildCamera3D(cam: OrbitCamera): { diff --git a/src/viewport/ray.ts b/src/viewport/ray.ts index 476625a..021f3d6 100644 --- a/src/viewport/ray.ts +++ b/src/viewport/ray.ts @@ -4,8 +4,7 @@ import { mat4Multiply, mat4Invert, mat4Perspective, mat4LookAt, } from 'bloom'; -import { OrbitCamera } from '../state/editor-state'; -import { cameraEyePosition } from './orbit-camera'; +import { OrbitCamera, cameraEyePosition } from '../state/editor-state'; export interface Ray3 { origin: [number, number, number]; @@ -15,6 +14,23 @@ export interface Ray3 { // Unproject a screen-space pixel (x, y) into a world-space ray originating // from the camera eye and passing through the pixel on the near plane. // `screenW` and `screenH` are the viewport pixel dimensions. +// ⚠ SHAPE MATTERS HERE — Perry 0.5.1208 miscompile (found 2026-07-17). +// +// The previous body computed each unprojected coordinate as one large +// expression of indexed matrix reads (`m[0]*ndcX + m[4]*ndcY + …`, 16 reads, +// twice, via an inlined helper). Perry emitted an element load with the BASE +// REGISTER DROPPED — `vmovq 0x8, %xmm6`, a read from absolute address 8 — +// and the editor died with 0xc0000005 on the first placement click or gizmo +// grab. The fault was LAYOUT-SENSITIVE: adding console.error lines made it +// vanish, which is why it dodged every earlier build (and why the smoke +// tests, which never click, never saw it). Repro binary + dump + pdb: +// main-repro.exe / crash_main_10580_6a59eadc.dmp. +// +// The dodge, same family as shooter perry-quirks #8 (clamp) and the Map-field +// AV: hoist every element into a named scalar FIRST, then do only scalar +// arithmetic — the exact idiom mat4Invert uses, which has always compiled +// correctly. Pinned by testMouseRay in the self-tests, which calls this +// function headless and would have crashed in the broken binary. export function mouseToWorldRay( cam: OrbitCamera, mouseX: number, mouseY: number, @@ -36,18 +52,33 @@ export function mouseToWorldRay( const vp = mat4Multiply(proj, view); const ivp = mat4Invert(vp); + // Hoist the inverse view-projection into scalars (see the header comment — + // do NOT fold these back into the expressions below). + const m0 = ivp[0], m1 = ivp[1], m2 = ivp[2], m3 = ivp[3]; + const m4 = ivp[4], m5 = ivp[5], m6 = ivp[6], m7 = ivp[7]; + const m8 = ivp[8], m9 = ivp[9], m10 = ivp[10], m11 = ivp[11]; + const m12 = ivp[12], m13 = ivp[13], m14 = ivp[14], m15 = ivp[15]; + // NDC coords: -1..1 range, Y flipped. const ndcX = ((mouseX - viewportLeft) / viewportW) * 2 - 1; const ndcY = 1 - ((mouseY - viewportTop) / viewportH) * 2; - // Unproject near point and far point. - const near = unprojectPoint(ivp, ndcX, ndcY, -1); - const far = unprojectPoint(ivp, ndcX, ndcY, 1); + // Unproject the near-plane point (ndcZ = -1)... + const nwInv = 1 / (m3 * ndcX + m7 * ndcY - m11 + m15); + const nx = (m0 * ndcX + m4 * ndcY - m8 + m12) * nwInv; + const ny = (m1 * ndcX + m5 * ndcY - m9 + m13) * nwInv; + const nz = (m2 * ndcX + m6 * ndcY - m10 + m14) * nwInv; + + // ...and the far-plane point (ndcZ = +1). + const fwInv = 1 / (m3 * ndcX + m7 * ndcY + m11 + m15); + const fx = (m0 * ndcX + m4 * ndcY + m8 + m12) * fwInv; + const fy = (m1 * ndcX + m5 * ndcY + m9 + m13) * fwInv; + const fz = (m2 * ndcX + m6 * ndcY + m10 + m14) * fwInv; // Direction = far - near, normalized. - let dx = far[0] - near[0]; - let dy = far[1] - near[1]; - let dz = far[2] - near[2]; + let dx = fx - nx; + let dy = fy - ny; + let dz = fz - nz; const len = Math.sqrt(dx * dx + dy * dy + dz * dz); if (len > 0) { dx /= len; dy /= len; dz /= len; } @@ -130,18 +161,6 @@ export function rayPlaneIntersect( // ---- internals ------------------------------------------------------------- -function unprojectPoint( - invViewProj: number[], ndcX: number, ndcY: number, ndcZ: number, -): [number, number, number] { - const m = invViewProj; - const x = m[0] * ndcX + m[4] * ndcY + m[8] * ndcZ + m[12]; - const y = m[1] * ndcX + m[5] * ndcY + m[9] * ndcZ + m[13]; - const z = m[2] * ndcX + m[6] * ndcY + m[10] * ndcZ + m[14]; - const w = m[3] * ndcX + m[7] * ndcY + m[11] * ndcZ + m[15]; - const iw = 1 / w; - return [x * iw, y * iw, z * iw]; -} - function clamp01(v: number): number { if (v < 0) return 0; if (v > 1) return 1; diff --git a/src/world-sync/sync.ts b/src/world-sync/sync.ts index 7fb174d..b35f616 100644 --- a/src/world-sync/sync.ts +++ b/src/world-sync/sync.ts @@ -18,15 +18,13 @@ import { setSceneNodeColor, setSceneNodeVisible, setSceneNodeParent, updateSceneNodeGeometry, enableShadows, disableShadows, - setAmbientLight, setDirectionalLight, - genMeshCube, vec3, + genMeshCube, mat4Identity, } from 'bloom'; -import { setFog } from 'bloom/core'; import { trsToMat4 } from 'bloom/world'; import { buildHeightmapMesh } from 'bloom/world'; import { expandPrefab, PrefabLeaf, createPrefabRegistry, registerPrefab, PrefabRegistry } from 'bloom/world'; -import { spawnWaterVolume, spawnRiver, applyWorldLights } from 'bloom/world'; +import { spawnWaterVolume, spawnRiver, applyWorldEnvironment } from 'bloom/world'; import { EntityData, Vec3Lit, Mat4Lit } from 'bloom/world'; import { EditorState, bindEntity, unbindEntity, handleOfEntity, @@ -123,9 +121,19 @@ function hueToRgb(hue: number): Vec3Lit { return [Math.floor((r + m) * 255), Math.floor((g + m) * 255), Math.floor((b + m) * 255)]; } -function placeholderColor(entity: EntityData): Vec3Lit { +function placeholderColor(state: EditorState, entity: EntityData): Vec3Lit { const kind = entity.userData['kind']; if (kind === undefined || kind === '') return [255, 0, 255]; + + // Project-supplied colors first (editor.project.json `kindColors`) — the + // game's own vocabulary beats the editor's built-in shooter-era table. + if (state.project !== null) { + const keys = state.project.kindColorKeys; + for (let i = 0; i < keys.length; i++) { + if (keys[i] === kind) return state.project.kindColorValues[i]; + } + } + if (kind === 'static_mesh') { const tag = entity.tags.length > 0 ? entity.tags[0] : ''; const byTag = MESH_TAG_COLORS.get(tag); @@ -280,7 +288,7 @@ function syncRebuilds(state: EditorState): void { if (entity.tint !== null) { applyTint(node, entity.tint); } else { - const c = placeholderColor(entity); + const c = placeholderColor(state, entity); setSceneNodeColor(node, c[0], c[1], c[2], 255); } } @@ -341,53 +349,18 @@ function syncTerrain(state: EditorState): void { // ---- environment ----------------------------------------------------------- function syncEnvironment(state: EditorState): void { - // The renderer's begin_frame resets the lighting block every frame - // (immediate-mode convention — the same reason the shooter re-sets - // sun/ambient per frame), so ambient, sun, and fog are re-applied - // unconditionally. setDirectionalLight replaces the sun in place; - // addDirectionalLight here would accumulate one extra light per call. - const env = state.world.environment; - - setAmbientLight( - { - r: Math.floor(env.ambientColor[0] * 255), - g: Math.floor(env.ambientColor[1] * 255), - b: Math.floor(env.ambientColor[2] * 255), - a: 255, - }, - env.ambientIntensity, - ); - - setDirectionalLight( - vec3(env.sunDirection[0], env.sunDirection[1], env.sunDirection[2]), - { - r: Math.floor(env.sunColor[0] * 255), - g: Math.floor(env.sunColor[1] * 255), - b: Math.floor(env.sunColor[2] * 255), - a: 255, - }, - env.sunIntensity, - ); - - // The world's point lights, re-submitted for the same reason as the sun: the - // renderer clears its lighting block every frame. This is what lets the editor - // preview a world's lighting rather than guessing at it. - applyWorldLights(state.world); - - // The engine's fog is exponential height fog while the schema stores a - // linear start/end pair — approximate with a density that reaches ~95% - // extinction at fogEnd, near-uniform over height. - if (env.fogEnd > 0.0001) { - setFog(env.fogColor[0], env.fogColor[1], env.fogColor[2], 3.0 / env.fogEnd, 0, 0.02); - } else { - setFog(env.fogColor[0], env.fogColor[1], env.fogColor[2], 0, 0, 0.02); - } + // Ambient, sun, point lights, and fog re-apply every frame through the + // ENGINE's shared helper — the same call the world-viewer example and any + // instantiateWorld consumer makes — so the editor's lighting preview cannot + // drift from what games render. (The renderer clears its lighting block in + // begin_frame; once-at-load lights a world for exactly one frame.) + applyWorldEnvironment(state.world); // Shadow toggling swaps render passes — only on explicit change. if (!state.pendingEnvironmentSync) return; state.pendingEnvironmentSync = false; - if (env.shadowsEnabled) { + if (state.world.environment.shadowsEnabled) { enableShadows(); } else { disableShadows(); diff --git a/tools/ui-smoke.ps1 b/tools/ui-smoke.ps1 new file mode 100644 index 0000000..b34b991 --- /dev/null +++ b/tools/ui-smoke.ps1 @@ -0,0 +1,138 @@ +# ui-smoke.ps1 - drive the REAL editor binary with injected input and assert +# it survives and saves correctly. Run after every build, and after every +# Perry upgrade. +# +# Why this exists: `main --test` exercises logic, not input paths. Two shipped +# bugs proved the difference (2026-07-16/17): the catalog-key bug rendered the +# whole world as placeholder boxes with 152 tests green, and Perry 0.5.1208 +# miscompiled the ray unprojection into a crash on the FIRST viewport click - +# invisible to every test that never clicks. This script clicks. +# +# What it does: +# 1. copies the arena_01 fixture to %TEMP% (never touches repo data), +# 2. launches main.exe --project ../shooter/editor.project.json --world , +# 3. injects: asset-cell click -> 5 placement clicks -> camera drag -> +# select + move-gizmo drag -> Ctrl+S, +# 4. asserts: process alive, no FATAL on stderr, saved file parses, entity +# count grew by the 5 placements, and the safe-save siblings (.bak/.tmp) +# exist. +# Exit code 0 = pass. + +param( + [string]$EditorExe = (Join-Path $PSScriptRoot "..\main.exe"), + [string]$Project = (Join-Path $PSScriptRoot "..\..\shooter\editor.project.json"), + [string]$Fixture = (Join-Path $PSScriptRoot "..\src\tests\fixtures\arena_01.world.json") +) + +$ErrorActionPreference = "Stop" + +function Fail($msg) { Write-Host "UI-SMOKE FAIL: $msg"; exit 1 } + +Add-Type @' +using System; +using System.Runtime.InteropServices; +public class UiSmoke { + [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out SRECT r); + [DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); + [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra); + [StructLayout(LayoutKind.Sequential)] + public struct SRECT { public int Left; public int Top; public int Right; public int Bottom; } +} +'@ +[UiSmoke]::SetProcessDPIAware() | Out-Null + +if (-not (Test-Path $EditorExe)) { Fail "editor exe not found: $EditorExe" } +if (-not (Test-Path $Project)) { Fail "project not found: $Project" } +if (-not (Test-Path $Fixture)) { Fail "fixture not found: $Fixture" } + +# 1. Scratch world copy. +$scratch = Join-Path $env:TEMP "ui-smoke.world.json" +Remove-Item "$scratch*" -Force -ErrorAction SilentlyContinue +Copy-Item $Fixture $scratch -Force +$beforeCount = (Get-Content $scratch -Raw | ConvertFrom-Json).entities.Count + +# 2. Launch. +$errLog = Join-Path $env:TEMP "ui-smoke-stderr.txt" +$proc = Start-Process -FilePath $EditorExe -ArgumentList "--project", $Project, "--world", $scratch ` + -RedirectStandardError $errLog -PassThru -WorkingDirectory (Split-Path $EditorExe) +Start-Sleep -Seconds 10 +if ($proc.HasExited) { Fail "editor exited during startup (code $($proc.ExitCode)); stderr: $(Get-Content $errLog -Raw)" } + +$h = $proc.MainWindowHandle +if ($h -eq 0) { Stop-Process -Id $proc.Id -Force; Fail "no main window after 10s" } +[UiSmoke]::SetForegroundWindow($h) | Out-Null +Start-Sleep -Milliseconds 600 +$r = New-Object UiSmoke+SRECT +[UiSmoke]::GetWindowRect($h, [ref]$r) | Out-Null +$dpi = [UiSmoke]::GetDpiForWindow($h) +if ($dpi -eq 0) { $dpi = 96 } +$s = $dpi / 96.0 + +function Pt($lx, $ly) { @([int]($r.Left + $lx * $s), [int]($r.Top + $ly * $s)) } +function LClick($lx, $ly) { + $p = Pt $lx $ly + [UiSmoke]::SetCursorPos($p[0], $p[1]) | Out-Null; Start-Sleep -Milliseconds 120 + [UiSmoke]::mouse_event(0x02,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 50 + [UiSmoke]::mouse_event(0x04,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 150 +} +function Drag($btnDown, $btnUp, $x1, $y1, $x2, $y2) { + $p = Pt $x1 $y1 + [UiSmoke]::SetCursorPos($p[0], $p[1]) | Out-Null; Start-Sleep -Milliseconds 100 + [UiSmoke]::mouse_event($btnDown,0,0,0,[UIntPtr]::Zero) + for ($i = 1; $i -le 8; $i++) { + $q = Pt ($x1 + ($x2-$x1)*$i/8) ($y1 + ($y2-$y1)*$i/8) + [UiSmoke]::SetCursorPos($q[0], $q[1]) | Out-Null; Start-Sleep -Milliseconds 25 + } + [UiSmoke]::mouse_event($btnUp,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 150 +} +function Key($vk) { + [UiSmoke]::keybd_event($vk,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 40 + [UiSmoke]::keybd_event($vk,0,2,[UIntPtr]::Zero); Start-Sleep -Milliseconds 120 +} +function Alive() { + $p2 = Get-Process -Id $proc.Id -ErrorAction SilentlyContinue + return ($p2 -and -not $p2.HasExited) +} + +# 3. Interact. Logical coordinates for the fixed 1280x800 window: +# first asset cell ~ (1043, 199); viewport spans x 240..~1004, y 36..~770. +LClick 1043 199 # pick first model -> place tool +$spots = @(@(620,460),@(420,260),@(820,600),@(520,650),@(720,350)) +foreach ($sp in $spots) { + LClick $sp[0] $sp[1] + if (-not (Alive)) { Fail "editor died during placement at $($sp[0]),$($sp[1]); stderr: $(Get-Content $errLog -Raw | Select-Object -Last 4)" } +} +Drag 0x08 0x10 620 420 760 450 # right-drag: orbit the camera +Key 0x51 # Q -> select tool +LClick 620 460 # select something +Key 0x47 # G -> move gizmo +Drag 0x02 0x04 650 435 740 435 # left-drag on/near the gizmo +if (-not (Alive)) { Fail "editor died during gizmo drag" } + +# Ctrl+S save. +[UiSmoke]::keybd_event(0x11,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 60 +Key 0x53 +[UiSmoke]::keybd_event(0x11,0,2,[UIntPtr]::Zero); Start-Sleep -Milliseconds 800 +if (-not (Alive)) { Fail "editor died during save" } + +Stop-Process -Id $proc.Id -Force + +# 4. Assertions. +$stderr = Get-Content $errLog -Raw -ErrorAction SilentlyContinue +if ($stderr -match "FATAL") { Fail "FATAL on stderr: $stderr" } + +if (-not (Test-Path $scratch)) { Fail "saved world missing" } +$saved = Get-Content $scratch -Raw | ConvertFrom-Json +$afterCount = $saved.entities.Count +if ($afterCount -lt ($beforeCount + $spots.Count)) { + Fail "expected >= $($beforeCount + $spots.Count) entities after placing $($spots.Count), got $afterCount (placements did not land - key-identity class bug?)" +} +if (-not (Test-Path "$scratch.bak")) { Fail "safe-save .bak missing" } +if (-not (Test-Path "$scratch.tmp")) { Fail "safe-save .tmp missing" } + +Write-Host "UI-SMOKE PASS: $beforeCount -> $afterCount entities, save verified, no crashes." +exit 0