From f6d28f679441965d59427316cc35dde0c907d7b7 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 23 Jul 2026 14:31:09 -0400 Subject: [PATCH 1/2] Fix pass: walkthrough, deck-coupled wall items, live-preview elevation, room snap modes, import validation (#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): elect wall slab support from the carrying profile, not face coverage An elevated deck drawn against a house wall covers the wall's outer face line end-to-end (boundary contact counts), so the max-across-polylines election handed the wall origin to the deck: every wall-hosted window/door rode along whenever the deck height changed, and placement local-Y was clamped above the deck top. Elect from the carrying profile instead (per arc segment: highest support per face, min across supported faces), with the pointer cap applied inside the profile so a capped-away deck still falls back to the floor that carries the wall. Also pass curveOffset/ thickness/supportSlabId at the window/door tool query sites so their cursor agrees with the rendered wall frame. Co-Authored-By: Claude Fable 5 * fix(core): honor live node overrides in spatial-grid support queries Group drags publish translated slab polygons and wall endpoints to useLiveNodeOverrides only; the committed spatial index made floor items and walls re-elect support against the pre-drag slab footprint, so multi-selection moves and room-preset placement jumped vertically until the validating click committed the batch. Support queries now read live-effective slab/wall records and bypass the rendered-polygon cache while a slab or wall on the level has an override; the committed cached path stays the fast path otherwise. Co-Authored-By: Claude Fable 5 * feat(editor): give the room-preset stamp tool a polygon snap context The host app's room stamp drives placement with tool='room', which has no registry entry, so snapContextOf resolved null: Shift never cycled the snapping mode and the HUD chip stayed hidden during room preset placement. A tool-level context map hands the stamp the no-angle polygon set. Co-Authored-By: Claude Fable 5 * fix(editor): only synthesize walkthrough fallback floors for ground levels Every slab-less visible level got a >=30x30m opening-free fallback floor box at its elevation, so a walkthrough spawned on an upper level (the no-spawn-node fallback ray picks the highest surface) stood on a phantom plane it could never descend from. Match the baked-GLB viewer policy: only the lowest level of each building (derived baseY === 0) gets the fallback; upper levels rely on their real slabs, whose stair openings are cut into the geometry. Co-Authored-By: Claude Fable 5 * fix(core): fold useLiveTransforms slab deltas into support queries The slab move tool and the room-preset stamp publish a translation DELTA to useLiveTransforms (no polygon override), so the spatial index still elected support against the slab's committed footprint: furniture riding a room-preset preview (or sitting on a dragged deck) dropped to ground under the visually-moved deck until the validating click. Effective slab records now apply the live delta to polygon/holes/elevation, mapped exactly once at each query's loop entry. Co-Authored-By: Claude Fable 5 * fix(viewer): reapply floor lift every frame for nodes with live previews FloorElevationSystem only wrote mesh Y for dirty nodes, but the React commit that rebinds a dragged node's base-Y group position can land between frames, after priority-2 systems consumed the dirty mark — the lift then vanished until the next pointer tick re-dirtied the node, blinking the Y of items dragged over elevated slabs. Nodes holding a live override or transform now get the lift reapplied every frame. Co-Authored-By: Claude Fable 5 * fix(editor): stop the entry camera swap from insta-cancelling walkthrough Entering walkthrough with a persisted orthographic camera swaps it to perspective, which recreates the interaction callbacks and re-ran the pointer-lock effect: its cleanup called exitPointerLock, and the unlock handler read that as "user left walkthrough" — instantly cancelling the fresh entry and arming the browser's ~1.25s re-lock cooldown (hence needing multiple button presses). The effect is now mount-stable (the changing callback rides a ref, deps down to [gl]), and the entry lock request swallows async cooldown rejections like the P-resume path. Co-Authored-By: Claude Fable 5 * fix(core): don't block build-JSON import over plugin-node children Exports from projects with plugins carry nodes like trees:tree whose ids sit in level.children; the import validator parsed parents against the static children id union, so one tree id hard-failed the level schema and blocked the whole import — while the same data loads fine from the DB (setScene never runs this gate). Parents are now validated against a copy with non-static-schema child ids filtered out; those nodes keep surfacing through the unknown-types warning and the imported payload is unchanged. Co-Authored-By: Claude Fable 5 * feat(core): treat registered plugin kinds as first-class in import validation Nodes of runtime-registered plugin kinds (trees:tree, trees:grass) were lumped into the unknown-types warning even when the plugin is loaded. The validator now consults the node registry: registered kinds validate against their own registered schema (corrupt plugin nodes still block), count under stats.pluginTypes, and raise no warning — only genuinely unregistered types do, which stays correct for hosts without the plugin. Co-Authored-By: Claude Fable 5 * style: appease biome (format + forEach block body) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../live-override-support.test.ts | 150 ++++++++++++++++ .../spatial-grid/spatial-grid-manager.ts | 95 +++++++++-- .../spatial-grid/wall-slab-overlap.test.ts | 40 +++++ .../core/src/systems/slab/slab-support.ts | 160 +++++++++++------- .../validation/validate-build-json.test.ts | 128 ++++++++++++++ .../src/validation/validate-build-json.ts | 63 ++++++- .../editor/first-person-controls.tsx | 18 +- .../first-person/build-collider-world.test.ts | 32 +++- .../first-person/build-collider-world.ts | 3 + .../sidebar/panels/settings-panel/index.tsx | 2 +- .../editor/src/components/viewer-overlay.tsx | 6 +- packages/editor/src/lib/snapping-mode.test.ts | 8 + packages/editor/src/lib/snapping-mode.ts | 10 +- packages/nodes/src/door/move-tool.tsx | 6 + packages/nodes/src/door/tool.tsx | 9 +- .../src/shared/opening-guides-runtime.ts | 3 + packages/nodes/src/window/move-tool.tsx | 6 + packages/nodes/src/window/tool.tsx | 9 +- .../floor-elevation-system.tsx | 27 ++- 19 files changed, 688 insertions(+), 87 deletions(-) create mode 100644 packages/core/src/hooks/spatial-grid/live-override-support.test.ts create mode 100644 packages/core/src/validation/validate-build-json.test.ts diff --git a/packages/core/src/hooks/spatial-grid/live-override-support.test.ts b/packages/core/src/hooks/spatial-grid/live-override-support.test.ts new file mode 100644 index 00000000..368c2e7b --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/live-override-support.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, SlabNode } from '../../schema' +import useLiveNodeOverrides from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' +import useScene from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' + +// Group drags publish translated slab polygons/elevations to +// `useLiveNodeOverrides` only — the scene store (and thus the manager's +// committed index) doesn't change until the validating click. Support +// queries must honor those live records, otherwise items and walls +// re-elect against the pre-drag footprint and jump to ground mid-preview. + +const LEVEL_ID = 'level_test' + +const SQUARE: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +function makeLevel(children: string[] = []): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: 0, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +const ITEM_DIMENSIONS: [number, number, number] = [0.5, 0.5, 0.5] +const NO_ROTATION: [number, number, number] = [0, 0, 0] + +const itemSupportAt = (x: number, z: number) => + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [x, 0, z], ITEM_DIMENSIONS, NO_ROTATION) + +describe('support queries honor live node overrides', () => { + beforeEach(() => { + spatialGridManager.clear() + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + const deck = makeSlab('slab_deck', SQUARE, 0.5) + useScene.setState({ nodes: { [LEVEL_ID]: makeLevel([deck.id]), [deck.id]: deck } as never }) + spatialGridManager.handleNodeCreated(deck as AnyNode, LEVEL_ID) + }) + + afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + useLiveTransforms.getState().clearAll() + useScene.setState({ nodes: {} }) + spatialGridManager.clear() + }) + + test('an item over a live-translated deck keeps electing it at the moved footprint', () => { + // Prime the committed rendered-polygon cache before the drag starts. + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + + const translated = SQUARE.map(([x, z]) => [x + 10, z]) as Array<[number, number]> + useLiveNodeOverrides.getState().set('slab_deck', { polygon: translated }) + + // The moved footprint elects the deck; the vacated one no longer does. + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0, slabId: null }) + + // Release/cancel: committed data wins again (cached fast path). + useLiveNodeOverrides.getState().clearAll() + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0, slabId: null }) + }) + + test('a live elevation change is visible to item and host queries', () => { + useLiveNodeOverrides.getState().set('slab_deck', { elevation: 1.2 }) + expect(itemSupportAt(0, 0)).toEqual({ elevation: 1.2, slabId: 'slab_deck' }) + expect( + spatialGridManager.getHostSlabElevationForFootprint( + LEVEL_ID, + 'slab_deck', + [0, 0, 0], + ITEM_DIMENSIONS, + NO_ROTATION, + ), + ).toBeCloseTo(1.2) + + useLiveNodeOverrides.getState().clearAll() + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + }) + + test('a deck translated via a useLiveTransforms delta supports items at the moved spot', () => { + // The slab move tool and the room-preset stamp publish a translation + // DELTA to useLiveTransforms (no polygon override) — the mesh moves but + // the committed polygon stays put, so furniture riding the preview used + // to elect ground and render under the deck until the validating click. + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + + useLiveTransforms.getState().set('slab_deck', { position: [10, 0, 0], rotation: 0 }) + + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0, slabId: null }) + expect( + spatialGridManager.getSlabSupportForWall(LEVEL_ID, [9.5, 0], [10.5, 0]).elevation, + ).toBeCloseTo(0.5) + + useLiveTransforms.getState().clearAll() + expect(itemSupportAt(0, 0)).toEqual({ elevation: 0.5, slabId: 'slab_deck' }) + expect(itemSupportAt(10, 0)).toEqual({ elevation: 0, slabId: null }) + }) + + test('wall support follows a live-translated deck', () => { + const committed = spatialGridManager.getSlabSupportForWall(LEVEL_ID, [-0.5, 0], [0.5, 0]) + expect(committed.elevation).toBeCloseTo(0.5) + + const translated = SQUARE.map(([x, z]) => [x + 10, z]) as Array<[number, number]> + useLiveNodeOverrides.getState().set('slab_deck', { polygon: translated }) + + const moved = spatialGridManager.getSlabSupportForWall(LEVEL_ID, [9.5, 0], [10.5, 0]) + expect(moved.elevation).toBeCloseTo(0.5) + expect(moved.electedSlabId).toBe('slab_deck') + + const vacated = spatialGridManager.getSlabSupportForWall(LEVEL_ID, [-0.5, 0], [0.5, 0]) + expect(vacated.elevation).toBe(0) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index fa4b27bc..f73fce4c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,8 +1,10 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' -import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' +import type { AnyNode, AnyNodeId, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import { getWallPlaneTop } from '../../services/storey' +import useLiveNodeOverrides, { getEffectiveNode } from '../../store/use-live-node-overrides' +import useLiveTransforms from '../../store/use-live-transforms' import useScene from '../../store/use-scene' import { computeWallSlabSupport, @@ -407,19 +409,79 @@ export class SpatialGridManager { for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) } + /** + * True while a slab or wall on `levelId` has a live preview: group drags + * publish translated slab polygons and wall endpoints to + * `useLiveNodeOverrides`, and the slab move tool / room-preset stamp + * publish a translation DELTA to `useLiveTransforms` — either way the + * scene store commits only on release, so the committed cache and index + * would elect support against pre-drag footprints (items and walls + * visibly drop to ground mid-preview). Support queries then read + * live-effective records and skip the rendered-polygon cache. + */ + private levelHasLivePreview(levelId: string): boolean { + const nodes = useScene.getState().nodes + const structuralOnLevel = (id: string) => { + const node = nodes[id as AnyNodeId] + if (!node || (node.type !== 'slab' && node.type !== 'wall')) return false + return resolveNodeLevelId(node, nodes) === levelId + } + const overrides = useLiveNodeOverrides.getState().overrides + for (const id of overrides.keys()) { + if (structuralOnLevel(id)) return true + } + const transforms = useLiveTransforms.getState().transforms + for (const id of transforms.keys()) { + if (structuralOnLevel(id)) return true + } + return false + } + + /** + * The live-effective slab record: field overrides merged, then the + * `useLiveTransforms` DELTA (slab publishers — move tool, room-preset + * stamp — store a translation, not an absolute position) applied to the + * polygon, holes, and elevation. Mapping happens exactly ONCE at each + * public query's loop entry: `slabSupportsFootprint` / + * `getRenderedSlabPolygon` take the already-effective record and must + * never re-map, or the delta would apply twice. + */ + private effectiveSlabRecord(slab: SlabNode): SlabNode { + let effective = getEffectiveNode(slab) + const live = useLiveTransforms.getState().get(slab.id) + if (live) { + const [dx, dy, dz] = live.position + if (dx !== 0 || dy !== 0 || dz !== 0) { + effective = { + ...effective, + polygon: effective.polygon.map(([x, z]) => [x + dx, z + dz] as [number, number]), + holes: (effective.holes || []).map((hole) => + hole.map(([x, z]) => [x + dx, z + dz] as [number, number]), + ), + elevation: (effective.elevation ?? 0.05) + dy, + } + } + } + return effective + } + private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { - const cached = this.renderedSlabPolygons.get(slab.id) - if (cached) return cached + const live = this.levelHasLivePreview(levelId) + if (!live) { + const cached = this.renderedSlabPolygons.get(slab.id) + if (cached) return cached + } const siblingSlabs: SlabNode[] = [] for (const other of this.getSlabMap(levelId).values()) { - if (other.id !== slab.id) siblingSlabs.push(other) + if (other.id !== slab.id) siblingSlabs.push(live ? this.effectiveSlabRecord(other) : other) } + const walls = this.getLevelWallNodes(levelId) const polygon = getRenderableSlabPolygon(slab, { - walls: this.getLevelWallNodes(levelId), + walls: live ? walls.map((wall) => getEffectiveNode(wall)) : walls, siblingSlabs, }) - this.renderedSlabPolygons.set(slab.id, polygon) + if (!live) this.renderedSlabPolygons.set(slab.id, polygon) return polygon } @@ -774,7 +836,8 @@ export class SpatialGridManager { if (!slabMap) return 0 let maxElevation = 0 - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) { // Check if point is in any hole let inHole = false @@ -836,7 +899,8 @@ export class SpatialGridManager { let winningElevation = Number.NEGATIVE_INFINITY let winnerId: string | null = null - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) const elevation = slab.elevation ?? 0.05 if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue @@ -873,7 +937,8 @@ export class SpatialGridManager { let best: { t: number; elevation: number; slabId: string } | null = null if (slabMap) { - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) if (slab.polygon.length < 3) continue const elevation = slab.elevation ?? 0.05 const t = (elevation - oy) / dy @@ -925,7 +990,8 @@ export class SpatialGridManager { if (!slabMap) return [] const candidates: SlabSupportCandidate[] = [] - for (const slab of slabMap.values()) { + for (const stored of slabMap.values()) { + const slab = this.effectiveSlabRecord(stored) if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) } @@ -951,8 +1017,9 @@ export class SpatialGridManager { dimensions: [number, number, number], rotation: [number, number, number], ): number | null { - const slab = this.slabsByLevel.get(levelId)?.get(slabId) - if (!slab) return null + const stored = this.slabsByLevel.get(levelId)?.get(slabId) + if (!stored) return null + const slab = this.effectiveSlabRecord(stored) if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null return slab.elevation ?? 0.05 } @@ -997,8 +1064,8 @@ export class SpatialGridManager { return computeWallSlabSupport( { start, end, curveOffset, thickness }, - [...slabMap.values()], - this.getLevelWallNodes(levelId), + [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), + this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), preferredSlabId, maxElevation, ) diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts index 3a1b79fe..88414bae 100644 --- a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -133,6 +133,46 @@ describe('computeWallSlabElevation', () => { expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1) }) + it('keeps a house wall on its floor when an elevated deck abuts the outer face', () => { + // Regression: a deck drawn against the wall covers the outer face line + // end-to-end (boundary contact counts), so the old max-across-polylines + // election handed the wall origin to the deck — every wall-hosted + // window/door rode along whenever the deck height changed. The carrying + // profile (min across supported faces) must stay on the interior floor. + const walls = [ + parseWall([0, 0], [4, 0]), + parseWall([4, 0], [4, 4]), + parseWall([4, 4], [0, 4]), + parseWall([0, 4], [0, 0]), + ] + const floor = SlabNode.parse({ polygon: SLAB, elevation: 0.05, thickness: 0.05 }) + const houseWall = walls[0]! + const wallLike = { + start: houseWall.start, + end: houseWall.end, + thickness: houseWall.thickness, + } + + for (const deckElevation of [1, 2]) { + const deck = SlabNode.parse({ + polygon: [ + [0, 0], + [4, 0], + [4, -3], + [0, -3], + ], + elevation: deckElevation, + }) + const support = computeWallSlabSupport(wallLike, [floor, deck], walls) + expect(support.elevation).toBeCloseTo(0.05) + expect(support.electedSlabId).toBe(floor.id) + expect(support.baseElevation).toBeCloseTo(0.05) + for (const segment of support.baseSegments) { + expect(segment.elevation).toBeCloseTo(0.05) + } + } + }) + it('lifts a wall whose body a legacy stored polygon falls short of', () => { // Legacy hand-adjusted slab: edges 6cm inside the wall centerlines — // 1cm short of even the inner faces, so the STORED polygon never diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts index fb308d57..c24fb3e1 100644 --- a/packages/core/src/systems/slab/slab-support.ts +++ b/packages/core/src/systems/slab/slab-support.ts @@ -448,10 +448,14 @@ const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point * contact, endpoint grazes). * - * Same-elevation slabs pool their coverage. `elevation` preserves the - * existing wall-relative origin: the highest elevation covering at - * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered - * elevation when none reaches majority. `baseElevation` only fills down + * Same-elevation slabs pool their coverage. `elevation` is elected from + * the wall's carrying profile: per arc segment, the highest support on + * each face, then the min across supported faces — so a slab that only + * brushes one face (e.g. an elevated deck adjacent along the outer face) + * never lifts the wall origin. The highest carrying elevation covering + * at least `WALL_SLAB_SUPPORT_MAJORITY` of the wall wins, or the + * best-covered carrying elevation when none reaches majority. + * `baseElevation` only fills down * where a lower support remains exposed on a wall face after higher, * overlapping support is accounted for. Coincident floor/platform slabs * therefore keep the wall on the platform, while slabs on opposite wall @@ -560,58 +564,13 @@ export function computeWallSlabSupport( } type EvaluatedGroup = ElevationGroup & { - coverage: number mergedPerPolyline: LengthInterval[][] } - const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { - let coverage = 0 - const mergedPerPolyline = group.perPolyline.map(mergeIntervals) - for (let i = 0; i < group.perPolyline.length; i++) { - const lineLength = polylineLengths[i]! - if (lineLength < 1e-9) continue - coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) - } - return { ...group, coverage, mergedPerPolyline } - }) + const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => ({ + ...group, + mergedPerPolyline: group.perPolyline.map(mergeIntervals), + })) - const electableGroups = - maxElevation == null - ? evaluatedGroups - : evaluatedGroups.filter( - (group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, - ) - - let majorityElevation = Number.NEGATIVE_INFINITY - let bestElevation = Number.NEGATIVE_INFINITY - let bestCoverage = -1 - for (const group of electableGroups) { - if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { - majorityElevation = Math.max(majorityElevation, group.elevation) - } - if ( - group.coverage > bestCoverage + 1e-6 || - (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) - ) { - bestCoverage = group.coverage - bestElevation = group.elevation - } - } - - const elevation = - preferredElevation !== null - ? preferredElevation - : majorityElevation !== Number.NEGATIVE_INFINITY - ? majorityElevation - : bestElevation === Number.NEGATIVE_INFINITY - ? 0 - : bestElevation - const electedSlabId = - preferredElectedSlabId ?? - electableGroups - .find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) - ?.slabIds.slice() - .sort()[0] ?? - null const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { const lineLength = polylineLengths[polylineIndex]! if (lineLength < 1e-9) return [] @@ -638,9 +597,9 @@ export function computeWallSlabSupport( (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, ) - const highestAt = (polylineIndex: number, t: number) => { + const highestAt = (groupList: typeof normalizedByGroup, polylineIndex: number, t: number) => { let highest = Number.NEGATIVE_INFINITY - for (const group of normalizedByGroup) { + for (const group of groupList) { if ( group.perPolyline[polylineIndex]?.some( ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, @@ -652,17 +611,66 @@ export function computeWallSlabSupport( return highest } + // The pointer cap filters the ELECTION's carrying profile, not the base + // profile: with a deck capped away, the floor that also carries the wall + // must still win (geometry fill-down stays uncapped). + const electableNormalizedGroups = + maxElevation == null + ? normalizedByGroup + : normalizedByGroup.filter( + (group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, + ) + const baseSegments: WallSlabSupportSegment[] = [] + type CarryCandidate = { elevation: number; length: number } + const carryCandidates: CarryCandidate[] = [] + const accumulateCarry = (elevation: number, length: number) => { + let candidate = carryCandidates.find( + (existing) => Math.abs(existing.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, + ) + if (!candidate) { + candidate = { elevation, length: 0 } + carryCandidates.push(candidate) + } + candidate.length += length + } for (let index = 1; index < uniqueBreakpoints.length; index++) { const start = uniqueBreakpoints[index - 1]! const end = uniqueBreakpoints[index]! if (end - start < 1e-7) continue const midpoint = (start + end) / 2 - const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY - const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY + const centerElevation = highestAt(normalizedByGroup, 0, midpoint) + const leftElevation = + polylines.length >= 3 ? highestAt(normalizedByGroup, 1, midpoint) : Number.NEGATIVE_INFINITY + const rightElevation = + polylines.length >= 3 ? highestAt(normalizedByGroup, 2, midpoint) : Number.NEGATIVE_INFINITY const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) const segmentElevation = - faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) + faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(centerElevation, 0) + + if (electableNormalizedGroups === normalizedByGroup) { + if (faceElevations.length > 0 || Number.isFinite(centerElevation)) { + accumulateCarry(segmentElevation, end - start) + } + } else { + const electCenter = highestAt(electableNormalizedGroups, 0, midpoint) + const electLeft = + polylines.length >= 3 + ? highestAt(electableNormalizedGroups, 1, midpoint) + : Number.NEGATIVE_INFINITY + const electRight = + polylines.length >= 3 + ? highestAt(electableNormalizedGroups, 2, midpoint) + : Number.NEGATIVE_INFINITY + const electFaces = [electLeft, electRight].filter(Number.isFinite) + if (electFaces.length > 0 || Number.isFinite(electCenter)) { + accumulateCarry( + electFaces.length > 0 ? Math.min(...electFaces) : Math.max(electCenter, 0), + end - start, + ) + } + } + const previous = baseSegments[baseSegments.length - 1] if ( previous && @@ -674,6 +682,42 @@ export function computeWallSlabSupport( } } + let majorityElevation = Number.NEGATIVE_INFINITY + let bestElevation = Number.NEGATIVE_INFINITY + let bestCoverage = -1 + for (const candidate of carryCandidates) { + if (candidate.length >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { + majorityElevation = Math.max(majorityElevation, candidate.elevation) + } + if ( + candidate.length > bestCoverage + 1e-6 || + (Math.abs(candidate.length - bestCoverage) <= 1e-6 && candidate.elevation > bestElevation) + ) { + bestCoverage = candidate.length + bestElevation = candidate.elevation + } + } + + const elevation = + preferredElevation !== null + ? preferredElevation + : majorityElevation !== Number.NEGATIVE_INFINITY + ? majorityElevation + : bestElevation === Number.NEGATIVE_INFINITY + ? 0 + : bestElevation + const electedSlabId = + preferredElectedSlabId ?? + evaluatedGroups + .filter( + (group) => + maxElevation == null || group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, + ) + .find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) + ?.slabIds.slice() + .sort()[0] ?? + null + if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) return { elevation, electedSlabId, baseElevation, baseSegments } diff --git a/packages/core/src/validation/validate-build-json.test.ts b/packages/core/src/validation/validate-build-json.test.ts new file mode 100644 index 00000000..64e3d097 --- /dev/null +++ b/packages/core/src/validation/validate-build-json.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../registry' +import type { AnyNodeDefinition } from '../registry/types' +import { LevelNode, WallNode } from '../schema' +import { validateBuildJson } from './validate-build-json' + +function makeScene() { + const wall = WallNode.parse({ + id: 'wall_test1', + parentId: 'level_test', + start: [0, 0], + end: [4, 0], + thickness: 0.1, + }) + const level = LevelNode.parse({ + id: 'level_test', + level: 0, + children: [wall.id], + }) + return { + nodes: { [level.id]: level, [wall.id]: wall } as Record, + rootNodeIds: [level.id], + } +} + +describe('validateBuildJson', () => { + test('accepts a minimal valid scene', () => { + const result = validateBuildJson(makeScene()) + expect(result.ok).toBe(true) + expect(result.schemaIssueCount).toBe(0) + }) + + test('plugin-typed children do not hard-fail their parent level', () => { + // Exports from projects with plugins carry nodes like `trees:tree` + // whose ids sit in level.children. The static children id union would + // reject them, but the scene store loads them fine (same data + // round-trips through the DB) — they must only surface as the + // unknown-types warning, never as an import-blocking schema error. + const scene = makeScene() + const level = scene.nodes.level_test as { children: string[] } + scene.nodes.tree_plugin1 = { + id: 'tree_plugin1', + type: 'trees:tree', + object: 'node', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + position: [1, 0, 1], + } + level.children = [...level.children, 'tree_plugin1'] + + const result = validateBuildJson(scene) + expect(result.ok).toBe(true) + expect(result.schemaIssueCount).toBe(0) + expect(result.warnings.some((w) => w.code === 'unknown_types')).toBe(true) + // The parsed payload keeps the plugin child — only validation filters it. + const parsedLevel = result.parsed?.nodes.level_test as { children: string[] } + expect(parsedLevel.children).toContain('tree_plugin1') + }) + + test('a genuinely malformed known-type node still blocks import', () => { + const scene = makeScene() + ;(scene.nodes.wall_test1 as { start: unknown }).start = 'not-a-point' + + const result = validateBuildJson(scene) + expect(result.ok).toBe(false) + expect(result.schemaIssueCount).toBe(1) + expect(result.schemaIssues[0]?.nodeId).toBe('wall_test1') + }) +}) + +describe('validateBuildJson with registered plugin kinds', () => { + const sceneWithTree = (position: unknown) => { + const scene = makeScene() + const level = scene.nodes.level_test as { children: string[] } + scene.nodes.tree_plugin1 = { + id: 'tree_plugin1', + type: 'trees:tree', + object: 'node', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + position, + } + level.children = [...level.children, 'tree_plugin1'] + return scene + } + + beforeEach(() => { + nodeRegistry._reset() + registerNode({ + kind: 'trees:tree', + schemaVersion: 1, + schema: z.looseObject({ + id: z.string(), + type: z.literal('trees:tree'), + position: z.tuple([z.number(), z.number(), z.number()]), + }), + category: 'utility', + defaults: () => ({}), + capabilities: {}, + } as unknown as AnyNodeDefinition) + }) + + afterEach(() => { + nodeRegistry._reset() + }) + + test('a registered plugin kind is first-class: no unknown-types warning', () => { + const result = validateBuildJson(sceneWithTree([1, 0, 1])) + expect(result.ok).toBe(true) + expect(result.schemaIssueCount).toBe(0) + expect(result.warnings.some((w) => w.code === 'unknown_types')).toBe(false) + expect(result.stats.pluginTypes['trees:tree']).toBe(1) + expect(result.stats.unknownTypes).toEqual({}) + }) + + test('a corrupt registered plugin node is caught by its own schema', () => { + const result = validateBuildJson(sceneWithTree('not-a-position')) + expect(result.ok).toBe(false) + expect(result.schemaIssueCount).toBe(1) + expect(result.schemaIssues[0]?.nodeId).toBe('tree_plugin1') + expect(result.schemaIssues[0]?.nodeType).toBe('trees:tree') + }) +}) diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index df4d0ba6..1257a43e 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,3 +1,4 @@ +import { nodeRegistry } from '../registry' import { AnyNode, type AnyNodeType } from '../schema/types' import { healSceneNodes } from '../utils/heal-scene-graph' @@ -13,6 +14,8 @@ export type ValidationIssue = { export type BuildStats = { total: number byType: Partial> + /** Kinds outside the static schema union but registered at runtime (plugins). */ + pluginTypes: Record unknownTypes: Record floorAreaM2: number } @@ -80,7 +83,13 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { const errors: ValidationIssue[] = [] const warnings: ValidationIssue[] = [] const schemaIssues: SchemaIssue[] = [] - const stats: BuildStats = { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 } + const stats: BuildStats = { + total: 0, + byType: {}, + pluginTypes: {}, + unknownTypes: {}, + floorAreaM2: 0, + } if (!isPlainObject(input)) { errors.push({ @@ -167,6 +176,33 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { }) } + // Ids of nodes whose type falls outside the static schema union — plugin + // kinds (`trees:tree`) or genuinely unknown types. The scene store accepts + // them on load (they already round-trip through the DB fine) and they're + // surfaced by the unknown-types warning, but a parent's strict `children` + // id union would hard-fail over them: validate parents against a copy with + // those ids filtered out. The imported data itself keeps them. + const nonSchemaNodeIds = new Set() + for (const [key, value] of Object.entries(nodes)) { + if (!isPlainObject(value)) continue + const type = typeof value.type === 'string' ? value.type : null + if (type && KNOWN_TYPES.has(type)) continue + nonSchemaNodeIds.add(typeof value.id === 'string' ? value.id : key) + } + const withoutNonSchemaChildren = (value: Record): Record => { + const children = value.children + if (!Array.isArray(children)) return value + if (!children.some((child) => typeof child === 'string' && nonSchemaNodeIds.has(child))) { + return value + } + return { + ...value, + children: children.filter( + (child) => !(typeof child === 'string' && nonSchemaNodeIds.has(child)), + ), + } + } + let validRootCount = 0 let mismatchedKeyCount = 0 let schemaFailureCount = 0 @@ -206,7 +242,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { const t = type as AnyNodeType stats.byType[t] = (stats.byType[t] ?? 0) + 1 - const parseResult = AnyNode.safeParse(value) + const parseResult = AnyNode.safeParse(withoutNonSchemaChildren(value)) if (!parseResult.success) { schemaFailureCount += 1 const issue = parseResult.error.issues[0] @@ -232,7 +268,28 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { } } } else { - stats.unknownTypes[type] = (stats.unknownTypes[type] ?? 0) + 1 + const registered = nodeRegistry.get(type) + if (registered) { + // A runtime-registered plugin kind (e.g. `trees:tree`) is a + // first-class citizen: validate it with its own registered schema + // instead of flagging it unknown. Files from projects whose plugin + // is NOT loaded here still fall through to the unknown-types + // warning below. + stats.pluginTypes[type] = (stats.pluginTypes[type] ?? 0) + 1 + const parseResult = registered.schema.safeParse(value) + if (!parseResult.success) { + schemaFailureCount += 1 + const issue = parseResult.error.issues[0] + schemaIssues.push({ + nodeId: key, + nodeType: type, + path: issue ? issue.path.join('.') : '', + message: issue ? issue.message : 'schema mismatch', + }) + } + } else { + stats.unknownTypes[type] = (stats.unknownTypes[type] ?? 0) + 1 + } } if (parentId && !(parentId in nodes)) { diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 9f4e43e8..d5517896 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1128,6 +1128,20 @@ export const FirstPersonControls = () => { return () => window.cancelAnimationFrame(frame) }, [gl]) + // The pointer-lock effect below must be mount-stable. Its cleanup exits + // pointer lock, and `toggleInteractableTarget` is recreated whenever the + // camera object changes — which happens right after entry when the + // persisted orthographic mode swaps to perspective. If the async lock + // grant lands before that re-run, the cleanup's exitPointerLock fires an + // unlock the handler reads as "user left walkthrough", instantly + // cancelling a fresh entry (and arming the browser's ~1.25s re-lock + // cooldown, so the next presses fail too). Route the callback through a + // ref so the effect deps stay `[gl]`. + const toggleInteractableTargetRef = useRef(toggleInteractableTarget) + useEffect(() => { + toggleInteractableTargetRef.current = toggleInteractableTarget + }, [toggleInteractableTarget]) + useEffect(() => { const canvas = gl.domElement const handleMouseMove = (e: MouseEvent) => { @@ -1155,7 +1169,7 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() - toggleInteractableTarget() + toggleInteractableTargetRef.current() } const handlePointerLockChange = () => { @@ -1192,7 +1206,7 @@ export const FirstPersonControls = () => { document.exitPointerLock() } } - }, [gl, toggleInteractableTarget]) + }, [gl]) useEffect(() => { const canvas = gl.domElement diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts index 24f0ad46..610618cd 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.test.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeDefinition, + BuildingNode, CeilingNode, ColumnNode, ElevatorNode, @@ -46,8 +47,9 @@ function mountNode( sceneRegistry.byType[node.type]!.add(node.id) } -function mountRegistryGroup(node: AnyNode) { +function mountRegistryGroup(node: AnyNode, position: [number, number, number] = [0, 0, 0]) { const group = new Group() + group.position.set(position[0], position[1], position[2]) group.updateMatrixWorld(true) sceneRegistry.nodes.set(node.id, group) sceneRegistry.byType[node.type]!.add(node.id) @@ -160,6 +162,34 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => { world?.dispose() }) + test('adds a fallback floor only for the lowest slab-less level in a building', () => { + const building = BuildingNode.parse({ + id: 'building_test', + children: ['level_ground', 'level_upper'], + }) + const groundLevel = LevelNode.parse({ + id: 'level_ground', + parentId: building.id, + level: 0, + height: 3, + }) + const upperLevel = LevelNode.parse({ + id: 'level_upper', + parentId: building.id, + level: 1, + }) + setSceneNodes([building, groundLevel, upperLevel]) + mountRegistryGroup(groundLevel) + mountRegistryGroup(upperLevel, [0, 3, 0]) + + const world = buildFirstPersonColliderWorldFromRegistry() + + expect(world).not.toBeNull() + expect(world?.bounds?.min.y).toBeCloseTo(-0.08) + expect(world?.bounds?.max.y).toBeCloseTo(0) + world?.dispose() + }) + test('adds a site ground collider so a spawn on bare ground has a floor', () => { const site = SiteNode.parse({ id: 'site_test' }) setSceneNodes([site]) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 24fafaa6..14267ecc 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -3,6 +3,7 @@ import { type AnyNodeId, type DoorNode, getGarageVisibleOpeningRatio, + getLevelElevations, isOperationDoorType, nodeRegistry, sceneRegistry, @@ -129,10 +130,12 @@ function createLevelFallbackFloorGeometry(level: LevelNode, nodes: SceneNodes) { function collectLevelFallbackFloorGeometries(nodes: SceneNodes) { const geometries: THREE.BufferGeometry[] = [] + const levelElevations = getLevelElevations(nodes) for (const levelId of sceneRegistry.byType.level!) { const node = nodes[levelId as AnyNodeId] if (node?.type !== 'level') continue + if (levelElevations.get(node.id)?.baseY !== 0) continue const geometry = createLevelFallbackFloorGeometry(node, nodes) if (geometry) geometries.push(geometry) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 49ef0bdd..1cc48671 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -241,7 +241,7 @@ export function SettingsPanel({ result: { ok: false, parsed: null, - stats: { total: 0, byType: {}, unknownTypes: {}, floorAreaM2: 0 }, + stats: { total: 0, byType: {}, pluginTypes: {}, unknownTypes: {}, floorAreaM2: 0 }, errors: [ { severity: 'error', diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 7918f9f5..b4b0f51b 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -24,7 +24,11 @@ function requestWalkthroughPointerLock() { if (document.pointerLockElement === canvas) return try { - canvas.requestPointerLock?.() + // The request can also reject ASYNC (browser cooldown after a recent + // unlock) — swallow it like the P-resume path; clicking the canvas + // re-requests once the cooldown passes. + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) } catch { return } diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 728db198..e91fb093 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -134,6 +134,14 @@ describe('snapContextOf (profile-driven, node-declared)', () => { expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull() }) + it('the room-preset stamp tool (not a node kind) resolves to polygon', () => { + // The host app's room stamp drives placement with `tool='room'`, which has + // no registry entry — the tool map must still give it the no-angle set so + // Shift cycling and the HUD chip work during preset placement. + expect(ctx({ kind: 'idle' }, 'build', 'room')).toBe('polygon') + expect(ctx({ kind: 'idle' }, 'select', 'room')).toBeNull() + }) + it('drafting a non-directional structural kind is angle-less (polygon, not wall)', () => { // Roof / stair / elevator are placed as footprints, not directional draws → // declared `snapDraftDirectional: false`, so their draft context drops the diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 634f16ff..368ffe31 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -122,6 +122,13 @@ function contextForProfile( return null } +// Tools that are not registered node kinds (no `snapProfile` to look up) but +// still place a whole footprint — the host app's room-preset stamp. A stamp is +// a whole-footprint translate: no direction to set → the no-angle 'polygon' +// set. Without an entry here the tool has no snap context at all, so Shift +// cycling and the HUD chip stay dead while it drives placement. +const TOOL_SNAP_CONTEXTS: Record = { room: 'polygon' } + /** * The active snapping context, derived from what the user is doing — fully * node-declared: the kind's `snapProfile` (looked up via the injected @@ -177,7 +184,8 @@ export function snapContextOf(args: { : null default: return mode === 'build' && tool - ? contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true) + ? (TOOL_SNAP_CONTEXTS[tool] ?? + contextForProfile(profileOf(tool), draftDirectionalOf?.(tool) ?? true)) : null } } diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 2f88ed4f..e545dfb3 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -227,6 +227,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallEvent.node.parentId ?? '', wallEvent.node.start, wallEvent.node.end, + wallEvent.node.curveOffset ?? 0, + wallEvent.node.thickness, + wallEvent.node.supportSlabId, ) const hideCursor = () => { @@ -948,6 +951,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => hostWall.parentId ?? '', hostWall.start, hostWall.end, + hostWall.curveOffset ?? 0, + hostWall.thickness, + hostWall.supportSlabId, ), ) publishPlacementSurface( diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 8abf2635..bb131933 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -160,7 +160,14 @@ const DoorTool: React.FC = () => { return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } const getSlabElevationForWall = (wall: WallNode) => - spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) + spatialGridManager.getSlabElevationForWall( + wall.parentId ?? '', + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 8d73eca7..151cf20f 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -197,6 +197,9 @@ export function wallToWorld(wall: WallNode): ToWorld { wall.parentId ?? '', wall.start, wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, ) return makeWallToWorld(wall, levelYOffset, slabElevation) } diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 475a5c44..115ac253 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -260,6 +260,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallEvent.node.parentId ?? '', wallEvent.node.start, wallEvent.node.end, + wallEvent.node.curveOffset ?? 0, + wallEvent.node.thickness, + wallEvent.node.supportSlabId, ) const hideCursor = () => { @@ -984,6 +987,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode hostWall.parentId ?? '', hostWall.start, hostWall.end, + hostWall.curveOffset ?? 0, + hostWall.thickness, + hostWall.supportSlabId, ), ) publishPlacementSurface( diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index d7d63a76..3987bfaa 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -172,7 +172,14 @@ const WindowTool: React.FC = () => { return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } const getSlabElevationForWall = (wall: WallNode) => - spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) + spatialGridManager.getSlabElevationForWall( + wall.parentId ?? '', + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 33976fae..361862f8 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -6,6 +6,7 @@ import { type LiveTransform, nodeRegistry, sceneRegistry, + useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -61,10 +62,18 @@ export const FloorElevationSystem = () => { const clearDirty = useScene((s) => s.clearDirty) useFrame(() => { - if (dirtyNodes.size === 0) return + // Nodes with a live preview (override / transform) are reapplied EVERY + // frame, not only while dirty: the React commit that rebinds the group's + // base-Y position can land between frames, after the dirty mark was + // already consumed by the priority-2 systems — without this the lift + // vanishes until the next pointer tick re-dirties (visible Y blink + // during group drags over elevated slabs). + const overrides = useLiveNodeOverrides.getState().overrides + const transforms = useLiveTransforms.getState().transforms + if (dirtyNodes.size === 0 && overrides.size === 0 && transforms.size === 0) return const nodes = useScene.getState().nodes - dirtyNodes.forEach((id) => { + const applyLift = (id: AnyNodeId) => { const node = nodes[id] if (!node) return @@ -99,9 +108,19 @@ export const FloorElevationSystem = () => { }) mesh.position.y = visualPosition[1] - if (!(def.geometry || def.system)) { - clearDirty(id as AnyNodeId) + if (!(def.geometry || def.system) && dirtyNodes.has(id)) { + clearDirty(id) } + } + + dirtyNodes.forEach((id) => { + applyLift(id) + }) + overrides.forEach((_values, id) => { + if (!dirtyNodes.has(id as AnyNodeId)) applyLift(id as AnyNodeId) + }) + transforms.forEach((_transform, id) => { + if (!dirtyNodes.has(id as AnyNodeId) && !overrides.has(id)) applyLift(id as AnyNodeId) }) }, 1) From 7cfb88dcadcf0c3461f9c3ed4c5d97f5d9e41ad1 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 23 Jul 2026 17:43:00 -0400 Subject: [PATCH 2/2] viewer: snapshot capture pipeline + hero framing for thumbnails (#538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(viewer,editor): extract snapshot pipeline, add hero framing + BakeThumbnail Move the offscreen SSGI capture pipeline out of ThumbnailGenerator into viewer's snapshot-pipeline (adding the missing scene-referred GRADE, uniform- driven), add hero-pose per-node-box corner framing helpers, and a BakeThumbnail component so the community bake page can render a publish-quality hero shot headlessly. Auto-save thumbnails now re-pose onto the same hero angle instead of copying the user's mid-edit camera; user-driven captures keep the exact viewport pose. Co-Authored-By: Claude Fable 5 * feat(viewer): tune hero framing — 19° elevation, tighter fit Co-Authored-By: Claude Fable 5 * feat(viewer,editor): ink edges in snapshot captures Captures now run the viewport's AO -> ink -> grade order with the same soft/strong edge derivation (edgeColorFor/edgeOpacityScaleFor), uniform-gated so one cached pipeline serves all modes. Radius scales with render height so supersampled captures keep the viewport's line weight. Preset/item (transparent) captures stay ink-free. Co-Authored-By: Claude Fable 5 * feat(viewer): lower hero elevation to 13°, exact fit — match reference framing Co-Authored-By: Claude Fable 5 * feat(viewer): hero framing — site plate in frame, building-centered aim, facade-relative 45° Three framing fixes from review: the intentionally-shaped site plate joins the fit constraints (from scene data — the site Object3D carries the ±400m horizon disc and can't be measured), the aim centers the building (plate+structure on XZ, structure alone on Y) so outlying boxes take asymmetric margin instead of shifting the subject, and the azimuth sits 45° to the dominant wall axis (length-weighted fold-4 vector sum) so rotated plans still read as a proper corner shot. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../src/components/editor/bake-thumbnail.tsx | 95 +++++ .../components/editor/thumbnail-generator.tsx | 360 +++-------------- packages/editor/src/index.tsx | 1 + packages/viewer/src/index.ts | 18 + packages/viewer/src/lib/hero-pose.ts | 275 +++++++++++++ packages/viewer/src/lib/snapshot-pipeline.ts | 379 ++++++++++++++++++ 6 files changed, 834 insertions(+), 294 deletions(-) create mode 100644 packages/editor/src/components/editor/bake-thumbnail.tsx create mode 100644 packages/viewer/src/lib/hero-pose.ts create mode 100644 packages/viewer/src/lib/snapshot-pipeline.ts diff --git a/packages/editor/src/components/editor/bake-thumbnail.tsx b/packages/editor/src/components/editor/bake-thumbnail.tsx new file mode 100644 index 00000000..a78fd876 --- /dev/null +++ b/packages/editor/src/components/editor/bake-thumbnail.tsx @@ -0,0 +1,95 @@ +'use client' + +import { + computeHeroFraming, + createSnapshotPipeline, + GRID_LAYER, + heroCameraPose, + temporarilyHideNodeTypes, + useViewer, +} from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { PerspectiveCamera } from 'three' +import type { WebGPURenderer } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' + +export function BakeThumbnail({ + active, + onComplete, + onError, +}: { + active: boolean + onComplete: (blob: Blob, size: { w: number; h: number }) => void + onError: (message: string) => void +}) { + const renderer = useThree((state) => state.gl) + const scene = useThree((state) => state.scene) + const doneRef = useRef(false) + + useEffect(() => { + if (!(active && !doneRef.current)) return + doneRef.current = true + + const run = async () => { + const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn']) + let pipeline: Awaited> = null + + try { + const framing = computeHeroFraming() + if (!framing) { + onError('scene has no framable content') + return + } + + const { width, height } = renderer.domElement + const aspect = width / height + const camera = new PerspectiveCamera(60, aspect, 0.1, 1000) + camera.layers.disable(EDITOR_LAYER) + camera.layers.disable(GRID_LAYER) + const pose = heroCameraPose({ + boxes: framing.boxes, + aim: framing.aim, + azimuthRad: framing.azimuthRad, + aspect, + }) + camera.position.set(pose.position[0], pose.position[1], pose.position[2]) + camera.lookAt(pose.target[0], pose.target[1], pose.target[2]) + camera.updateMatrixWorld() + + pipeline = await createSnapshotPipeline({ + renderer: renderer as unknown as WebGPURenderer, + scene, + camera, + }) + if (!pipeline) { + onError('thumbnail pipeline failed to build') + return + } + + pipeline.applyEnvironment({ + theme: useViewer.getState().sceneTheme, + transparent: false, + grade: true, + edges: useViewer.getState().edges, + camera, + }) + const { blob, outW, outH } = await pipeline.capture({ captureMode: 'standard' }) + onComplete(blob, { w: outW, h: outH }) + } catch (error) { + console.error( + '[bake-thumbnail]', + error instanceof Error ? (error.stack ?? error.message) : error, + ) + onError(error instanceof Error ? error.message : String(error)) + } finally { + pipeline?.dispose() + restoreNodeVisibility() + } + } + + void run() + }, [active, onComplete, onError, renderer, scene]) + + return null +} diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 5a50ba5f..e0be3b65 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -1,47 +1,25 @@ 'use client' -import { emitter, sceneRegistry } from '@pascal-app/core' +import { emitter } from '@pascal-app/core' import { - backdropGradient, - deepSkyColor, + computeHeroFraming, + createSnapshotPipeline, GRID_LAYER, - getSceneTheme, - horizonHazeColor, - packNormalToRGB, - SSGI_PARAMS, + heroCameraPose, + type SnapshotPipeline, snapLevelsToTruePositions, - unpackRGBToNormal, + THUMBNAIL_HEIGHT, + THUMBNAIL_WIDTH, + temporarilyHideNodeTypes, useViewer, } from '@pascal-app/viewer' import type { CameraControls } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' import * as THREE from 'three' -import { UnsignedByteType } from 'three' -import { ssgi } from 'three/addons/tsl/display/SSGINode.js' -import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' -import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js' -import { - convertToTexture, - diffuseColor, - float, - mix, - mrt, - normalView, - output, - pass, - sample, - screenUV, - smoothstep, - uniform, - vec4, -} from 'three/tsl' -import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu' +import type { WebGPURenderer } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' -const THUMBNAIL_WIDTH = 1920 -const THUMBNAIL_HEIGHT = 1080 - export interface SnapshotCameraData { position: [number, number, number] target: [number, number, number] | null @@ -64,20 +42,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const onThumbnailCaptureRef = useRef(onThumbnailCapture) const thumbnailCameraRef = useRef(null) - const pipelineRef = useRef(null) - const renderTargetRef = useRef(null) - - // Backdrop compositing for scene snapshots (studio renders, project - // thumbnails): theme background + sky gradient, same world-ray math as the - // viewport backdrop in viewer's post-processing. Uniform-driven so the one - // cached pipeline serves both opaque and transparent (preset/item) captures. - const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff'))) - const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff'))) - const bgSkyDeepUniform = useRef(uniform(new THREE.Color('#ffffff'))) - const bgHazeUniform = useRef(uniform(new THREE.Color('#ffffff'))) - const bgProjInvUniform = useRef(uniform(new THREE.Matrix4())) - const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4())) - const bgMixUniform = useRef(uniform(1)) + const pipelineRef = useRef(null) useEffect(() => { onThumbnailCaptureRef.current = onThumbnailCapture @@ -93,116 +58,24 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro let mounted = true const buildPipeline = async () => { - try { - if ((gl as any).init) await (gl as any).init() - if (!mounted) return - - // pass() handles MRT internally for all material types, including custom - // shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials. - // pass() also respects camera.layers, so EDITOR_LAYER + GRID_LAYER objects are filtered. - const scenePass = pass(scene, cam) - scenePass.setMRT( - mrt({ - output, - diffuseColor, - normal: packNormalToRGB(normalView), - }), - ) - - const scenePassColor = scenePass.getTextureNode('output') - const scenePassDepth = scenePass.getTextureNode('depth') - const scenePassNormal = scenePass.getTextureNode('normal') - - scenePass.getTexture('diffuseColor').type = UnsignedByteType - scenePass.getTexture('normal').type = UnsignedByteType - - const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv))) - - const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any) - giPass.sliceCount.value = SSGI_PARAMS.sliceCount - giPass.stepCount.value = SSGI_PARAMS.stepCount - giPass.radius.value = SSGI_PARAMS.radius - giPass.expFactor.value = SSGI_PARAMS.expFactor - giPass.thickness.value = SSGI_PARAMS.thickness - giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting - giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity - giPass.giIntensity.value = SSGI_PARAMS.giIntensity - giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness - giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling - giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering - - // r185: SSGI's AO lives in its own single-channel texture (getAONode) - // rather than the alpha of one packed rgba texture. - const aoTexture = (giPass as any).getAONode() - const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1)) - const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, cam) - denoisePass.index.value = 0 - denoisePass.radius.value = 4 - - // Same far-field AO fade as the viewport pipeline — without it the - // horizon picks up a visible AO line in captures. - const aoFarFade = smoothstep( - float(0.9994), - float(0.9998), - scenePassDepth.sample(screenUV).r, - ) - const ao = mix((denoisePass as any).r, float(1), aoFarFade) - const sceneRgb = scenePassColor.rgb.mul(ao) - - // Per-pixel world ray from the capture camera → sky gradient above the - // horizon (dir.y = 0), flat background below — mirrors the viewport - // backdrop. bgMix 0 bypasses it and keeps the capture transparent. - const ndc = vec4( - screenUV.x.mul(2).sub(1), - float(1).sub(screenUV.y).mul(2).sub(1), - 1, - 1, - ) as any - const viewRay = (bgProjInvUniform.current as any).mul(ndc) - const worldDir = (bgCamWorldUniform.current as any) - .mul(vec4(viewRay.xyz, 0)) - .xyz.normalize() - const bgGradient = backdropGradient({ - dirY: worldDir.y, - background: bgColorUniform.current, - haze: bgHazeUniform.current, - sky: bgSkyUniform.current, - skyDeep: bgSkyDeepUniform.current, - }) - const alpha = scenePassColor.a - const finalOutput = vec4( - mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform.current), - mix(alpha, float(1), bgMixUniform.current), - ) - - // FXAA requires a texture node as input; convertToTexture renders finalOutput - // into an intermediate RT so FXAA can sample it with neighbour UV offsets. - const aaOutput = fxaa(convertToTexture(finalOutput)) - - const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer) - pipeline.outputNode = aaOutput - pipelineRef.current = pipeline - - // Dedicated render target — pipeline outputs here instead of the canvas, - // so R3F's main render loop can never overwrite our capture. - const { width, height } = gl.domElement - renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true }) - } catch (error) { - console.error( - '[thumbnail] Failed to build post-processing pipeline, will use fallback render.', - error, - ) + const pipeline = await createSnapshotPipeline({ + renderer: gl as unknown as WebGPURenderer, + scene, + camera: cam, + }) + if (!mounted) { + pipeline?.dispose() + return } + pipelineRef.current = pipeline } - buildPipeline() + void buildPipeline() return () => { mounted = false pipelineRef.current?.dispose() pipelineRef.current = null - renderTargetRef.current?.dispose() - renderTargetRef.current = null } }, [gl, scene]) @@ -242,16 +115,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // uniforms below. thumbnailCamera.updateMatrixWorld() - const theme = getSceneTheme(useViewer.getState().sceneTheme) - bgColorUniform.current.value.set(theme.background) - bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background) - bgSkyDeepUniform.current.value.set(deepSkyColor(theme.backgroundSky ?? theme.background)) - bgHazeUniform.current.value.set( - horizonHazeColor(theme.backgroundSky ?? theme.background, theme.appearance), - ) - bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse) - bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld) - bgMixUniform.current.value = transparent ? 0 : 1 + const pipeline = pipelineRef.current + pipeline?.applyEnvironment({ + theme: useViewer.getState().sceneTheme, + transparent, + grade: useViewer.getState().shading === 'rendered', + // Preset/item captures stay clean; scene captures mirror the canvas. + edges: transparent ? 'off' : useViewer.getState().edges, + camera: thumbnailCamera, + }) // Capture camera data for snapshot storage const pos = mainCamera.position @@ -286,166 +158,66 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // are registered. Spawn renders on SCENE_LAYER for occlusion, so the // thumbnail camera's layer mask can't filter it either. Returns a // function that restores the original visibility. - const restoreNodeVisibility = (() => { - const saved = new Map() - for (const type of ['scan', 'guide', 'spawn'] as const) { - const ids = sceneRegistry.byType[type]! - ids.forEach((id) => { - const node = sceneRegistry.nodes.get(id) - if (node) { - saved.set(node, node.visible) - node.visible = false - } + const restoreNodeVisibility = temporarilyHideNodeTypes(['scan', 'guide', 'spawn']) + + // Auto-save shots don't copy the user's mid-edit camera — they re-pose + // onto the same computed hero angle the published thumbnail uses, so a + // project's card never shows a half-zoomed working view. Measured after + // the level snap so stacked positions frame correctly. User-driven + // captures (captureMode set) keep the exact viewport pose. + if (snapLevels) { + const framing = computeHeroFraming() + if (framing) { + const pose = heroCameraPose({ + boxes: framing.boxes, + aim: framing.aim, + azimuthRad: framing.azimuthRad, + aspect: width / height, }) - } - return () => { - saved.forEach((wasVisible, node) => { - node.visible = wasVisible + thumbnailCamera.position.set(pose.position[0], pose.position[1], pose.position[2]) + thumbnailCamera.lookAt(pose.target[0], pose.target[1], pose.target[2]) + thumbnailCamera.updateMatrixWorld() + pipeline?.applyEnvironment({ + theme: useViewer.getState().sceneTheme, + transparent, + grade: useViewer.getState().shading === 'rendered', + edges: transparent ? 'off' : useViewer.getState().edges, + camera: thumbnailCamera, }) + cameraData.position = pose.position + cameraData.target = pose.target } - })() + } let blob: Blob - if (pipelineRef.current && renderTargetRef.current) { - const rt = renderTargetRef.current - - // Resize RT if the canvas dimensions changed - if (rt.width !== width || rt.height !== height) { - rt.setSize(width, height) - } - - const renderer = gl as unknown as WebGPURenderer + if (pipeline) { + let capturePromise: ReturnType // Notify other systems (wall cutouts, selection manager) to restore // their overrides before capture and re-apply them after. try { emitter.emit('thumbnail:before-capture', undefined) - ;(renderer as any).setClearAlpha(0) - renderer.setRenderTarget(rt) - pipelineRef.current.render() + capturePromise = pipeline.capture({ + captureMode, + cropRegion, + standardSize, + }) } finally { // Restore level positions, levelMode, and node visibility immediately // after the render — before the async GPU readback. Runs in `finally` // so a render failure can't leave helpers permanently hidden. - renderer.setRenderTarget(null) emitter.emit('thumbnail:after-capture', undefined) restoreLevels() restoreLevelMode?.() restoreNodeVisibility() } - // Read pixels from the RT asynchronously. - // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must - // depad the rows before constructing ImageData. - const pixels = (await (renderer as any).readRenderTargetPixelsAsync( - rt, - 0, - 0, - width, - height, - )) as Uint8Array - - const actualBytesPerRow = width * 4 - const tightTotal = actualBytesPerRow * height - const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256 - // Two readback shapes to handle: - // - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding - // when width*4 isn't already a multiple of 256. - // - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up - // (OpenGL framebuffer convention). - // `isWebGPURenderer` lies — it stays true even when the renderer - // falls back to the WebGL backend. Inspect the actual backend - // instead (presence of a GPU device, or backend constructor name). - const backend = (renderer as any).backend - const isWebGPU = - !!backend?.device || - backend?.isWebGPUBackend === true || - backend?.constructor?.name === 'WebGPUBackend' - let tightPixels: Uint8ClampedArray - if (isWebGPU) { - // WebGPU: depad rows if needed; orientation is already top-down. - if (paddedBytesPerRow === actualBytesPerRow) { - tightPixels = new Uint8ClampedArray( - pixels.buffer, - pixels.byteOffset, - Math.min(pixels.byteLength, tightTotal), - ) - } else { - tightPixels = new Uint8ClampedArray(tightTotal) - for (let row = 0; row < height; row++) { - tightPixels.set( - pixels.subarray( - row * paddedBytesPerRow, - row * paddedBytesPerRow + actualBytesPerRow, - ), - row * actualBytesPerRow, - ) - } - } - } else { - // WebGL2: tight buffer in bottom-up order — flip rows. - tightPixels = new Uint8ClampedArray(tightTotal) - for (let row = 0; row < height; row++) { - const srcStart = (height - 1 - row) * actualBytesPerRow - tightPixels.set( - pixels.subarray(srcStart, srcStart + actualBytesPerRow), - row * actualBytesPerRow, - ) - } - } - - const imageData = new ImageData( - tightPixels as unknown as Uint8ClampedArray, - width, - height, - ) - const srcCanvas = new OffscreenCanvas(width, height) - srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0) - - let outW: number - let outH: number - - if (captureMode === 'viewport') { - outW = width - outH = height - const offscreen = new OffscreenCanvas(outW, outH) - offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0) - blob = await offscreen.convertToBlob({ type: 'image/png' }) - } else if (captureMode === 'area' && cropRegion) { - const sx = Math.round(cropRegion.x * width) - const sy = Math.round(cropRegion.y * height) - outW = Math.round(cropRegion.width * width) - outH = Math.round(cropRegion.height * height) - const offscreen = new OffscreenCanvas(outW, outH) - offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH) - blob = await offscreen.convertToBlob({ type: 'image/png' }) - } else { - // Standard: center-crop to the requested aspect (default 1920×1080) - const srcAspect = width / height - const dstAspect = standardW / standardH - let sx = 0, - sy = 0, - sWidth = width, - sHeight = height - if (srcAspect > dstAspect) { - sWidth = Math.round(height * dstAspect) - sx = Math.round((width - sWidth) / 2) - } else if (srcAspect < dstAspect) { - sHeight = Math.round(width / dstAspect) - sy = Math.round((height - sHeight) / 2) - } - outW = standardW - outH = standardH - const offscreen = new OffscreenCanvas(outW, outH) - offscreen - .getContext('2d')! - .drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH) - blob = await offscreen.convertToBlob({ type: 'image/png' }) - } + const result = await capturePromise + blob = result.blob if (captureMode !== undefined) cameraData.captureMode = captureMode - cameraData.resolution = { w: outW, h: outH } + cameraData.resolution = { w: result.outW, h: result.outH } } else { // Fallback: plain render directly to the canvas try { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index c9a0c81e..88d2890a 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -26,6 +26,7 @@ export { default as Editor } from './components/editor' // surface uses the shorter, shell-friendly names from the unified // preset-system spec. export { BakeExporter } from './components/editor/bake-exporter' +export { BakeThumbnail } from './components/editor/bake-thumbnail' export { FirstPersonControls } from './components/editor/first-person-controls' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' // Embed surface — the editor's real in-canvas affordances, so a host can mount diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8aba75c9..316d3a46 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -74,6 +74,14 @@ export { SUBTRACTION, } from './lib/csg-utils' export type { EdgeMode } from './lib/edge-style' +export { + computeHeroFraming, + DEFAULT_FRAMING_EXCLUDED_TYPES, + type HeroFraming, + heroCameraPose, + temporarilyHideNodeTypes, + unionRegisteredNodeBounds, +} from './lib/hero-pose' export { applyIsolation, clearIsolation, @@ -119,6 +127,16 @@ export { SCENE_THEMES, type SceneTheme, } from './lib/scene-themes' +export { + createSnapshotPipeline, + type SnapshotCaptureMode, + type SnapshotCaptureResult, + type SnapshotCropRegion, + type SnapshotPipeline, + type SnapshotSize, + THUMBNAIL_HEIGHT, + THUMBNAIL_WIDTH, +} from './lib/snapshot-pipeline' export { getPascalTextureRef, type PascalTextureColorSpace, diff --git a/packages/viewer/src/lib/hero-pose.ts b/packages/viewer/src/lib/hero-pose.ts new file mode 100644 index 00000000..bdcd768d --- /dev/null +++ b/packages/viewer/src/lib/hero-pose.ts @@ -0,0 +1,275 @@ +import { sceneRegistry, useScene } from '@pascal-app/core' +import { Box3, type Object3D, Vector3 } from 'three' + +export const DEFAULT_FRAMING_EXCLUDED_TYPES = ['site', 'scan', 'guide', 'spawn'] as const + +export function heroCameraPose({ + boxes, + aspect, + aim, + fovDeg = 60, + azimuthRad = Math.PI / 4, + elevationRad = (13 * Math.PI) / 180, + padding = 1.03, + minDistance = 4, + frameShift = 0, +}: { + boxes: Box3 | readonly Box3[] + aspect: number + /** Where the camera looks (frame center). Defaults to the union-box center; + * pass the building's center to keep it dead-center while outlying boxes + * (lot plate, far palms) simply take asymmetric margin — the corner fit + * still guarantees every box stays in frame. */ + aim?: [number, number, number] + fovDeg?: number + azimuthRad?: number + elevationRad?: number + padding?: number + minDistance?: number + /** Fraction of the frustum half-height to drop the aim by. 0 keeps the aim + * (the building center) exactly at frame center. */ + frameShift?: number +}): { + position: [number, number, number] + target: [number, number, number] +} { + const list = Array.isArray(boxes) ? (boxes as readonly Box3[]) : [boxes as Box3] + const union = new Box3() + for (const box of list) union.union(box) + const center = aim ? new Vector3(aim[0], aim[1], aim[2]) : union.getCenter(new Vector3()) + const tanVertical = Math.tan(((fovDeg / 2) * Math.PI) / 180) + const tanHorizontal = tanVertical * aspect + + // Camera basis for the chosen azimuth/elevation: `dir` points from the + // target toward the camera, `forward` is the view direction. + const dir = new Vector3( + Math.sin(azimuthRad) * Math.cos(elevationRad), + Math.sin(elevationRad), + Math.cos(azimuthRad) * Math.cos(elevationRad), + ) + const forward = dir.clone().negate() + const right = new Vector3().crossVectors(forward, new Vector3(0, 1, 0)).normalize() + const up = new Vector3().crossVectors(right, forward) + + // Exact fit: for every corner of every per-node box, the minimal camera + // distance along `dir` that keeps it inside the frustum. Fitting the corners + // of the UNION box instead reads far too wide at a diagonal azimuth — the + // union AABB's extreme corners are the empty diamond tips nothing actually + // occupies. (A bounding-sphere fit is worse still for flat, spread scenes.) + const corner = new Vector3() + const offset = new Vector3() + let distance = minDistance + for (const box of list) { + for (const x of [box.min.x, box.max.x]) { + for (const y of [box.min.y, box.max.y]) { + for (const z of [box.min.z, box.max.z]) { + corner.set(x, y, z) + offset.subVectors(corner, center) + const lateral = offset.dot(right) + const vertical = offset.dot(up) + const depth = offset.dot(forward) + distance = Math.max( + distance, + (Math.abs(lateral) / tanHorizontal - depth) * padding, + (Math.abs(vertical) / tanVertical - depth) * padding, + ) + } + } + } + } + + // Reframe: slide aim and camera together along the view-up axis — pure + // composition shift, no perspective change. + const drop = up.clone().multiplyScalar(-frameShift * distance * tanVertical) + const target = center.clone().add(drop) + + return { + position: [ + target.x + dir.x * distance, + target.y + dir.y * distance, + target.z + dir.z * distance, + ], + target: [target.x, target.y, target.z], + } +} + +export function unionRegisteredNodeBounds({ + excludeTypes, +}: { + excludeTypes: readonly string[] +}): Box3 | null { + const excluded = new Set(excludeTypes) + const result = new Box3() + + for (const [type, ids] of Object.entries(sceneRegistry.byType)) { + if (excluded.has(type)) continue + + for (const id of ids) { + const object = sceneRegistry.nodes.get(id) + if (!object) continue + const bounds = new Box3().setFromObject(object) + if (!bounds.isEmpty()) result.union(bounds) + } + } + + return result.isEmpty() ? null : result +} + +function perNodeBoxes(excludeTypes: readonly string[]): Box3[] { + const excluded = new Set(excludeTypes) + const boxes: Box3[] = [] + for (const [type, ids] of Object.entries(sceneRegistry.byType)) { + if (excluded.has(type)) continue + for (const id of ids) { + const object = sceneRegistry.nodes.get(id) + if (!object) continue + const bounds = new Box3().setFromObject(object) + if (!bounds.isEmpty()) boxes.push(bounds) + } + } + return boxes +} + +/** + * Matches the `SiteNode` bootstrap polygon (a 30×30 square at the origin) — + * same rule as the editor's `computeSceneBoundsXZ`: an untouched default lot + * says nothing about the user's intent, so it shouldn't drive framing. + */ +function isDefaultSitePolygon(points: unknown[]): boolean { + if (points.length !== 4) return false + const expected: [number, number][] = [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ] + for (let i = 0; i < 4; i++) { + const p = points[i] + const e = expected[i]! + if (!Array.isArray(p) || p.length < 2) return false + if (p[0] !== e[0] || p[1] !== e[1]) return false + } + return true +} + +/** Flat boxes for intentionally-shaped site plates, from scene DATA — the + * site's Object3D can't be measured (it carries the ±400 m horizon disc). */ +function sitePlateBoxes(): Box3[] { + const boxes: Box3[] = [] + for (const node of Object.values(useScene.getState().nodes)) { + if ((node as { type?: string }).type !== 'site') continue + const polygon = (node as { polygon?: { points?: unknown[] } }).polygon + const points = polygon?.points + if (!Array.isArray(points) || isDefaultSitePolygon(points)) continue + const box = new Box3() + for (const point of points) { + if (Array.isArray(point) && point.length >= 2) { + box.expandByPoint(new Vector3(Number(point[0]), 0, Number(point[1]))) + } + } + if (!box.isEmpty()) boxes.push(box) + } + return boxes +} + +/** Dominant wall direction folded to a 90°-periodic axis (length-weighted + * vector sum of 4θ), so the hero azimuth sits at a true 45° to the building's + * facades no matter how the user rotated their plan. */ +function dominantWallYaw(): number | null { + let vx = 0 + let vz = 0 + for (const node of Object.values(useScene.getState().nodes)) { + if ((node as { type?: string }).type !== 'wall') continue + const { start, end } = node as { start?: unknown; end?: unknown } + if (!(Array.isArray(start) && Array.isArray(end))) continue + const dx = Number(end[0]) - Number(start[0]) + const dz = Number(end[1]) - Number(start[1]) + const length = Math.hypot(dx, dz) + if (!(Number.isFinite(length) && length > 0.01)) continue + const theta = Math.atan2(dz, dx) + vx += length * Math.cos(4 * theta) + vz += length * Math.sin(4 * theta) + } + if (vx === 0 && vz === 0) return null + return Math.atan2(vz, vx) / 4 +} + +export type HeroFraming = { + /** Fit constraints — everything that must stay in frame. */ + boxes: Box3[] + /** Frame center: plate/structure center on XZ, building center on Y. */ + aim: [number, number, number] + /** 45° to the dominant facade (falls back to world 45°). */ + azimuthRad: number +} + +/** + * Framing for a scene hero shot. The base set is the built structure (walls, + * roofs, slabs, …) plus any intentionally-shaped site plate; an item joins + * only when it sits near that base — one palm at the far lot corner must not + * shrink the building. `building`/`level` container groups are skipped (their + * Object3D holds the whole subtree). The aim keeps the building dead-center: + * XZ from the plate+structure union, Y from the structure alone so the + * building — not the ground — is vertically centered. Scenes with no + * structure (pure furniture arrangements) fall back to framing every + * non-helper node. + */ +export function computeHeroFraming(): HeroFraming | null { + const structural = perNodeBoxes([...DEFAULT_FRAMING_EXCLUDED_TYPES, 'item', 'building', 'level']) + if (structural.length === 0) { + const all = perNodeBoxes([...DEFAULT_FRAMING_EXCLUDED_TYPES, 'building', 'level']) + if (all.length === 0) return null + const union = new Box3() + for (const box of all) union.union(box) + const center = union.getCenter(new Vector3()) + return { boxes: all, aim: [center.x, center.y, center.z], azimuthRad: Math.PI / 4 } + } + + const structuralUnion = new Box3() + for (const box of structural) structuralUnion.union(box) + + const plates = sitePlateBoxes() + const groundUnion = structuralUnion.clone() + for (const box of plates) groundUnion.union(box) + + const nearby = groundUnion + .clone() + .expandByVector(groundUnion.getSize(new Vector3()).multiplyScalar(0.15)) + + const boxes = [...structural, ...plates] + const itemIds = sceneRegistry.byType.item! + for (const id of itemIds) { + const object = sceneRegistry.nodes.get(id) + if (!object) continue + const bounds = new Box3().setFromObject(object) + if (!bounds.isEmpty() && nearby.intersectsBox(bounds)) boxes.push(bounds) + } + + const groundCenter = groundUnion.getCenter(new Vector3()) + const structuralCenter = structuralUnion.getCenter(new Vector3()) + const yaw = dominantWallYaw() + return { + boxes, + aim: [groundCenter.x, structuralCenter.y, groundCenter.z], + azimuthRad: Math.PI / 4 - (yaw ?? 0), + } +} + +export function temporarilyHideNodeTypes(types: readonly string[]): () => void { + const saved = new Map() + for (const type of types) { + const ids = sceneRegistry.byType[type]! + ids.forEach((id) => { + const node = sceneRegistry.nodes.get(id) + if (node) { + saved.set(node, node.visible) + node.visible = false + } + }) + } + return () => { + saved.forEach((wasVisible, node) => { + node.visible = wasVisible + }) + } +} diff --git a/packages/viewer/src/lib/snapshot-pipeline.ts b/packages/viewer/src/lib/snapshot-pipeline.ts new file mode 100644 index 00000000..44d8488c --- /dev/null +++ b/packages/viewer/src/lib/snapshot-pipeline.ts @@ -0,0 +1,379 @@ +import { type Camera, Color, Matrix4, type Scene, UnsignedByteType } from 'three' +import { ssgi } from 'three/addons/tsl/display/SSGINode.js' +import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' +import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js' +import { + convertToTexture, + diffuseColor, + float, + mix, + mrt, + normalView, + output, + pass, + sample, + saturation, + screenUV, + smoothstep, + uniform, + vec3, + vec4, +} from 'three/tsl' +import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu' +import { GRADE_PARAMS, SSGI_PARAMS } from '../components/viewer/post-processing' +import { backdropGradient, deepSkyColor, horizonHazeColor } from './backdrop' +import { type EdgeMode, edgeColorFor, edgeOpacityScaleFor } from './edge-style' +import { inkedEdges } from './ink-edges' +import { getSceneTheme } from './scene-themes' +import { packNormalToRGB, unpackRGBToNormal } from './tsl-compat' + +export const THUMBNAIL_WIDTH = 1920 +export const THUMBNAIL_HEIGHT = 1080 + +export type SnapshotCaptureMode = 'standard' | 'viewport' | 'area' + +export type SnapshotCropRegion = { + x: number + y: number + width: number + height: number +} + +export type SnapshotSize = { + w: number + h: number +} + +export type SnapshotCaptureResult = { + blob: Blob + outW: number + outH: number +} + +export type SnapshotPipeline = { + applyEnvironment: ({ + theme, + transparent, + grade, + edges, + camera, + }: { + theme: string + transparent: boolean + grade: boolean + edges: EdgeMode + camera: Camera + }) => void + capture: ({ + captureMode, + cropRegion, + standardSize, + }: { + captureMode?: SnapshotCaptureMode + cropRegion?: SnapshotCropRegion + standardSize?: SnapshotSize + }) => Promise + dispose: () => void +} + +export async function createSnapshotPipeline({ + renderer, + scene, + camera, +}: { + renderer: WebGPURenderer + scene: Scene + camera: Camera +}): Promise { + try { + if ((renderer as any).init) await (renderer as any).init() + + // Backdrop compositing for scene snapshots (studio renders, project + // thumbnails): theme background + sky gradient, same world-ray math as the + // viewport backdrop in viewer's post-processing. Uniform-driven so the one + // cached pipeline serves both opaque and transparent (preset/item) captures. + const bgColorUniform = uniform(new Color('#ffffff')) + const bgSkyUniform = uniform(new Color('#ffffff')) + const bgSkyDeepUniform = uniform(new Color('#ffffff')) + const bgHazeUniform = uniform(new Color('#ffffff')) + const bgProjInvUniform = uniform(new Matrix4()) + const bgCamWorldUniform = uniform(new Matrix4()) + const bgMixUniform = uniform(1) + const gradeMixUniform = uniform(0) + const inkMixUniform = uniform(0) + const inkColorUniform = uniform(new Color('#1a1d24')) + const inkOpacityUniform = uniform(0.5) + const inkOpacityScaleUniform = uniform(1) + + // pass() handles MRT internally for all material types, including custom + // shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials. + // pass() also respects camera.layers, so caller-disabled objects are filtered. + const scenePass = pass(scene, camera) + scenePass.setMRT( + mrt({ + output, + diffuseColor, + normal: packNormalToRGB(normalView), + }), + ) + + const scenePassColor = scenePass.getTextureNode('output') + const scenePassDepth = scenePass.getTextureNode('depth') + const scenePassNormal = scenePass.getTextureNode('normal') + + scenePass.getTexture('diffuseColor').type = UnsignedByteType + scenePass.getTexture('normal').type = UnsignedByteType + + const sceneNormal = sample((uv) => unpackRGBToNormal(scenePassNormal.sample(uv))) + + const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) + giPass.sliceCount.value = SSGI_PARAMS.sliceCount + giPass.stepCount.value = SSGI_PARAMS.stepCount + giPass.radius.value = SSGI_PARAMS.radius + giPass.expFactor.value = SSGI_PARAMS.expFactor + giPass.thickness.value = SSGI_PARAMS.thickness + giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting + giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity + giPass.giIntensity.value = SSGI_PARAMS.giIntensity + giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness + giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling + giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering + + // r185: SSGI's AO lives in its own single-channel texture (getAONode) + // rather than the alpha of one packed rgba texture. + const aoTexture = (giPass as any).getAONode() + const aoAsRgb = vec4(aoTexture.r, aoTexture.r, aoTexture.r, float(1)) + const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera) + denoisePass.index.value = 0 + denoisePass.radius.value = 4 + + // Same far-field AO fade as the viewport pipeline — without it the + // horizon picks up a visible AO line in captures. + const aoFarFade = smoothstep(float(0.9994), float(0.9998), scenePassDepth.sample(screenUV).r) + const ao = mix((denoisePass as any).r, float(1), aoFarFade) + const aoRgb = scenePassColor.rgb.mul(ao) + + // Ink edges, mirroring the viewport pipeline (AO → ink → grade) so + // captures carry the same soft/strong edge look the canvas shows. + // Uniform-gated like grade/backdrop: one cached pipeline serves all modes. + // Radius scales with render height: a supersampled capture (4K → 1080p) + // would otherwise halve the apparent line weight vs the viewport's 1px. + const inkRadius = Math.max(1, Math.round(renderer.domElement.height / 1080)) + const inkedRgb = inkedEdges({ + sceneRgb: aoRgb, + depthTex: scenePassDepth, + normalTex: scenePassNormal, + inkColor: inkColorUniform, + radius: inkRadius, + opacity: float(inkOpacityUniform).mul(inkOpacityScaleUniform), + }) + const ungradedSceneRgb = mix(aoRgb, inkedRgb, inkMixUniform) + const gradeRgb = (rgb: any) => + saturation(rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), GRADE_PARAMS.saturation) + const sceneRgb = mix(ungradedSceneRgb, gradeRgb(ungradedSceneRgb), gradeMixUniform) + + // Per-pixel world ray from the capture camera → sky gradient above the + // horizon (dir.y = 0), flat background below — mirrors the viewport + // backdrop. bgMix 0 bypasses it and keeps the capture transparent. + const ndc = vec4(screenUV.x.mul(2).sub(1), float(1).sub(screenUV.y).mul(2).sub(1), 1, 1) as any + const viewRay = (bgProjInvUniform as any).mul(ndc) + const worldDir = (bgCamWorldUniform as any).mul(vec4(viewRay.xyz, 0)).xyz.normalize() + const ungradedBgGradient = backdropGradient({ + dirY: worldDir.y, + background: bgColorUniform, + haze: bgHazeUniform, + sky: bgSkyUniform, + skyDeep: bgSkyDeepUniform, + }) + const bgGradient = mix(ungradedBgGradient, gradeRgb(ungradedBgGradient), gradeMixUniform) + const alpha = scenePassColor.a + const finalOutput = vec4( + mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform), + mix(alpha, float(1), bgMixUniform), + ) + + // FXAA requires a texture node as input; convertToTexture renders finalOutput + // into an intermediate RT so FXAA can sample it with neighbour UV offsets. + const aaOutput = fxaa(convertToTexture(finalOutput)) + + const pipeline = new RenderPipeline(renderer) + pipeline.outputNode = aaOutput + + // Dedicated render target — pipeline outputs here instead of the canvas, + // so R3F's main render loop can never overwrite our capture. + const { width, height } = renderer.domElement + const renderTarget = new RenderTarget(width, height, { depthBuffer: true }) + + return { + applyEnvironment: ({ theme, transparent, grade, edges, camera: captureCamera }) => { + const sceneTheme = getSceneTheme(theme) + inkMixUniform.value = edges === 'off' ? 0 : 1 + inkOpacityUniform.value = edges === 'strong' ? 1 : 0.5 + inkColorUniform.value.set(edgeColorFor(sceneTheme.background)) + inkOpacityScaleUniform.value = edgeOpacityScaleFor(sceneTheme.background) + bgColorUniform.value.set(sceneTheme.background) + bgSkyUniform.value.set(sceneTheme.backgroundSky ?? sceneTheme.background) + bgSkyDeepUniform.value.set(deepSkyColor(sceneTheme.backgroundSky ?? sceneTheme.background)) + bgHazeUniform.value.set( + horizonHazeColor( + sceneTheme.backgroundSky ?? sceneTheme.background, + sceneTheme.appearance, + ), + ) + bgMixUniform.value = transparent ? 0 : 1 + gradeMixUniform.value = grade ? 1 : 0 + + // The capture camera never joins the scene graph, so its matrixWorld + // is only refreshed by the render itself — too late for the backdrop + // uniforms below. + captureCamera.updateMatrixWorld() + bgProjInvUniform.value.copy(captureCamera.projectionMatrixInverse) + bgCamWorldUniform.value.copy(captureCamera.matrixWorld) + }, + capture: async ({ captureMode, cropRegion, standardSize }) => { + const standardW = standardSize?.w ?? THUMBNAIL_WIDTH + const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT + const { width: captureWidth, height: captureHeight } = renderer.domElement + + // Resize RT if the canvas dimensions changed + if (renderTarget.width !== captureWidth || renderTarget.height !== captureHeight) { + renderTarget.setSize(captureWidth, captureHeight) + } + + try { + ;(renderer as any).setClearAlpha(0) + renderer.setRenderTarget(renderTarget) + pipeline.render() + } finally { + renderer.setRenderTarget(null) + } + + // Let callers restore visibility and other capture policy immediately + // after the render, before the asynchronous GPU readback begins. + await Promise.resolve() + + // Read pixels from the RT asynchronously. + // WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must + // depad the rows before constructing ImageData. + const pixels = (await (renderer as any).readRenderTargetPixelsAsync( + renderTarget, + 0, + 0, + captureWidth, + captureHeight, + )) as Uint8Array + + const actualBytesPerRow = captureWidth * 4 + const tightTotal = actualBytesPerRow * captureHeight + const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256 + // Two readback shapes to handle: + // - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding + // when width*4 isn't already a multiple of 256. + // - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up + // (OpenGL framebuffer convention). + // `isWebGPURenderer` lies — it stays true even when the renderer + // falls back to the WebGL backend. Inspect the actual backend + // instead (presence of a GPU device, or backend constructor name). + const backend = (renderer as any).backend + const isWebGPU = + !!backend?.device || + backend?.isWebGPUBackend === true || + backend?.constructor?.name === 'WebGPUBackend' + let tightPixels: Uint8ClampedArray + if (isWebGPU) { + // WebGPU: depad rows if needed; orientation is already top-down. + if (paddedBytesPerRow === actualBytesPerRow) { + tightPixels = new Uint8ClampedArray( + pixels.buffer, + pixels.byteOffset, + Math.min(pixels.byteLength, tightTotal), + ) + } else { + tightPixels = new Uint8ClampedArray(tightTotal) + for (let row = 0; row < captureHeight; row++) { + tightPixels.set( + pixels.subarray( + row * paddedBytesPerRow, + row * paddedBytesPerRow + actualBytesPerRow, + ), + row * actualBytesPerRow, + ) + } + } + } else { + // WebGL2: tight buffer in bottom-up order — flip rows. + tightPixels = new Uint8ClampedArray(tightTotal) + for (let row = 0; row < captureHeight; row++) { + const srcStart = (captureHeight - 1 - row) * actualBytesPerRow + tightPixels.set( + pixels.subarray(srcStart, srcStart + actualBytesPerRow), + row * actualBytesPerRow, + ) + } + } + + const imageData = new ImageData( + tightPixels as unknown as Uint8ClampedArray, + captureWidth, + captureHeight, + ) + const srcCanvas = new OffscreenCanvas(captureWidth, captureHeight) + srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0) + + let outW: number + let outH: number + let blob: Blob + + if (captureMode === 'viewport') { + outW = captureWidth + outH = captureHeight + const offscreen = new OffscreenCanvas(outW, outH) + offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0) + blob = await offscreen.convertToBlob({ type: 'image/png' }) + } else if (captureMode === 'area' && cropRegion) { + const sx = Math.round(cropRegion.x * captureWidth) + const sy = Math.round(cropRegion.y * captureHeight) + outW = Math.round(cropRegion.width * captureWidth) + outH = Math.round(cropRegion.height * captureHeight) + const offscreen = new OffscreenCanvas(outW, outH) + offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH) + blob = await offscreen.convertToBlob({ type: 'image/png' }) + } else { + // Standard: center-crop to the requested aspect (default 1920×1080) + const srcAspect = captureWidth / captureHeight + const dstAspect = standardW / standardH + let sx = 0 + let sy = 0 + let sWidth = captureWidth + let sHeight = captureHeight + if (srcAspect > dstAspect) { + sWidth = Math.round(captureHeight * dstAspect) + sx = Math.round((captureWidth - sWidth) / 2) + } else if (srcAspect < dstAspect) { + sHeight = Math.round(captureWidth / dstAspect) + sy = Math.round((captureHeight - sHeight) / 2) + } + outW = standardW + outH = standardH + const offscreen = new OffscreenCanvas(outW, outH) + offscreen + .getContext('2d')! + .drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH) + blob = await offscreen.convertToBlob({ type: 'image/png' }) + } + + return { blob, outW, outH } + }, + dispose: () => { + pipeline.dispose() + renderTarget.dispose() + }, + } + } catch (error) { + console.error( + '[thumbnail] Failed to build post-processing pipeline, will use fallback render.', + error, + ) + return null + } +}