From 1d28e4208f4175915faae55c86de53cdda1cf6d5 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 20 Mar 2026 23:18:30 -0400 Subject: [PATCH] sync: update core and viewer from monorepo (#143) Major changes: - Roof system rewrite with roof-segment support - Scene store refactor - Spatial grid improvements - Item light system - Post-processing and selection manager updates - Perf monitor component Co-authored-by: Claude Opus 4.6 (1M context) --- packages/core/src/events/bus.ts | 3 + .../hooks/scene-registry/scene-registry.ts | 9 + .../spatial-grid/spatial-grid-manager.ts | 20 +- .../src/hooks/spatial-grid/spatial-grid.ts | 12 +- .../hooks/spatial-grid/wall-spatial-grid.ts | 6 +- packages/core/src/index.ts | 3 +- packages/core/src/lib/space-detection.ts | 2 +- packages/core/src/schema/index.ts | 1 + .../core/src/schema/nodes/roof-segment.ts | 44 + packages/core/src/schema/nodes/roof.ts | 25 +- packages/core/src/schema/types.ts | 2 + .../core/src/store/actions/node-actions.ts | 14 +- packages/core/src/store/use-scene.ts | 503 ++++--- .../core/src/systems/roof/roof-system.tsx | 1154 +++++++++++++---- .../core/src/systems/wall/wall-mitering.ts | 4 +- .../core/src/systems/wall/wall-system.tsx | 2 +- .../renderers/item/item-renderer.tsx | 62 +- .../components/renderers/node-renderer.tsx | 2 + .../roof-segment/roof-segment-renderer.tsx | 29 + .../renderers/roof/roof-materials.ts | 18 + .../renderers/roof/roof-renderer.tsx | 30 +- .../renderers/site/site-renderer.tsx | 4 +- .../viewer/src/components/viewer/index.tsx | 54 +- .../src/components/viewer/perf-monitor.tsx | 60 + .../src/components/viewer/post-processing.tsx | 77 +- .../components/viewer/selection-manager.tsx | 28 +- packages/viewer/src/hooks/use-node-events.ts | 3 + packages/viewer/src/r3f.d.ts | 8 + .../viewer/src/store/use-item-light-pool.ts | 53 + packages/viewer/src/store/use-viewer.ts | 6 + .../systems/item-light/item-light-system.tsx | 296 +++++ .../viewer/src/systems/wall/wall-cutout.tsx | 6 +- 32 files changed, 1921 insertions(+), 619 deletions(-) create mode 100644 packages/core/src/schema/nodes/roof-segment.ts create mode 100644 packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx create mode 100644 packages/viewer/src/components/renderers/roof/roof-materials.ts create mode 100644 packages/viewer/src/components/viewer/perf-monitor.tsx create mode 100644 packages/viewer/src/r3f.d.ts create mode 100644 packages/viewer/src/store/use-item-light-pool.ts create mode 100644 packages/viewer/src/systems/item-light/item-light-system.tsx diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 3309aaff..6f1bce1d 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -7,6 +7,7 @@ import type { ItemNode, LevelNode, RoofNode, + RoofSegmentNode, SiteNode, SlabNode, WallNode, @@ -39,6 +40,7 @@ export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent +export type RoofSegmentEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent @@ -100,6 +102,7 @@ type EditorEvents = GridEvents & NodeEvents<'slab', SlabEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'roof', RoofEvent> & + NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & CameraControlEvents & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index fe6a3af4..02cb06b6 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -17,11 +17,20 @@ export const sceneRegistry = { slab: new Set(), zone: new Set(), roof: new Set(), + 'roof-segment': new Set(), scan: new Set(), guide: new Set(), window: new Set(), door: new Set(), }, + + /** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */ + clear() { + this.nodes.clear() + for (const set of Object.values(this.byType)) { + set.clear() + } + }, } export function useRegistry( 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 a906d8ee..b9ba288b 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -273,15 +273,19 @@ export function wallOverlapsPolygon( } export class SpatialGridManager { - private floorGrids = new Map() // levelId -> grid - private wallGrids = new Map() // levelId -> wall grid - private walls = new Map() // wallId -> wall data (for length calculations) - private slabsByLevel = new Map>() // levelId -> (slabId -> slab) - private ceilingGrids = new Map() // ceilingId -> grid - private ceilings = new Map() // ceilingId -> ceiling data - private itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) + private readonly floorGrids = new Map() // levelId -> grid + private readonly wallGrids = new Map() // levelId -> wall grid + private readonly walls = new Map() // wallId -> wall data (for length calculations) + private readonly slabsByLevel = new Map>() // levelId -> (slabId -> slab) + private readonly ceilingGrids = new Map() // ceilingId -> grid + private readonly ceilings = new Map() // ceilingId -> ceiling data + private readonly itemCeilingMap = new Map() // itemId -> ceilingId (reverse lookup) - constructor(private cellSize = 0.5) {} + private readonly cellSize: number + + constructor(cellSize = 0.5) { + this.cellSize = cellSize + } private getFloorGrid(levelId: string): SpatialGrid { if (!this.floorGrids.has(levelId)) { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid.ts b/packages/core/src/hooks/spatial-grid/spatial-grid.ts index d23c0340..844284dd 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid.ts @@ -9,10 +9,14 @@ interface SpatialGridConfig { } export class SpatialGrid { - private cells = new Map() - private itemCells = new Map>() // reverse lookup + private readonly cells = new Map() + private readonly itemCells = new Map>() // reverse lookup - constructor(private config: SpatialGridConfig) {} + private readonly config: SpatialGridConfig + + constructor(config: SpatialGridConfig) { + this.config = config + } private posToCell(x: number, z: number): [number, number] { return [Math.floor(x / this.config.cellSize), Math.floor(z / this.config.cellSize)] @@ -75,7 +79,7 @@ export class SpatialGrid { if (!this.cells.has(key)) { this.cells.set(key, { itemIds: new Set() }) } - this.cells.get(key)!.itemIds.add(itemId) + this.cells.get(key)?.itemIds.add(itemId) } } diff --git a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts index 8d9c178c..003079a7 100644 --- a/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts +++ b/packages/core/src/hooks/spatial-grid/wall-spatial-grid.ts @@ -49,8 +49,8 @@ function autoAdjustYPosition( } export class WallSpatialGrid { - private wallItems = new Map() // wallId -> placements - private itemToWall = new Map() // itemId -> wallId (reverse lookup) + private readonly wallItems = new Map() // wallId -> placements + private readonly itemToWall = new Map() // itemId -> wallId (reverse lookup) /** * Check if an item can be placed on a wall with auto-adjustment for vertical position @@ -152,7 +152,7 @@ export class WallSpatialGrid { if (!this.wallItems.has(wallId)) { this.wallItems.set(wallId, []) } - this.wallItems.get(wallId)!.push(placement) + this.wallItems.get(wallId)?.push(placement) this.itemToWall.set(itemId, wallId) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 86a14946..5a2f7ffe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export type { LevelEvent, NodeEvent, RoofEvent, + RoofSegmentEvent, SiteEvent, SlabEvent, WallEvent, @@ -46,7 +47,7 @@ export { type ItemInteractiveState, useInteractive, } from './store/use-interactive' -export { default as useScene } from './store/use-scene' +export { clearSceneHistory, default as useScene } from './store/use-scene' // Systems export { CeilingSystem } from './systems/ceiling/ceiling-system' export { DoorSystem } from './systems/door/door-system' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index c4eecbdf..5d69bb89 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -42,7 +42,7 @@ export function initSpaceDetectionSync( if (!currentWallsByLevel.has(levelId)) { currentWallsByLevel.set(levelId, new Set()) } - currentWallsByLevel.get(levelId)!.add((node as any).id) + currentWallsByLevel.get(levelId)?.add((node as any).id) } } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 4c9f568f..e8f90cca 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -23,6 +23,7 @@ export type { export { getScaledDimensions, ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' export { RoofNode } from './nodes/roof' +export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts new file mode 100644 index 00000000..08852775 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -0,0 +1,44 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat']) + +export type RoofType = z.infer + +export const RoofSegmentNode = BaseNode.extend({ + id: objectId('rseg'), + type: nodeType('roof-segment'), + // Position relative to parent roof group + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around Y axis in radians + rotation: z.number().default(0), + // Roof shape type + roofType: RoofType.default('gable'), + // Footprint dimensions + width: z.number().default(8), + depth: z.number().default(6), + // Vertical dimensions + wallHeight: z.number().default(0.5), + roofHeight: z.number().default(2.5), + // Structure thicknesses + wallThickness: z.number().default(0.1), + deckThickness: z.number().default(0.1), + overhang: z.number().default(0.3), + shingleThickness: z.number().default(0.05), +}).describe( + dedent` + Roof segment node - an individual roof module within a roof group. + Each segment generates a complete architectural volume (walls + roof). + Multiple segments can be combined to form complex roof shapes. + - roofType: hip, gable, shed, gambrel, dutch, mansard, flat + - width/depth: footprint dimensions + - wallHeight: height of walls below the roof + - roofHeight: height of the roof peak above the walls + - wallThickness/deckThickness: structural thicknesses + - overhang: eave overhang distance + - shingleThickness: outer shingle layer thickness + `, +) + +export type RoofSegmentNode = z.infer diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index 39e38080..488d34a9 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -1,32 +1,25 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { RoofSegmentNode } from './roof-segment' export const RoofNode = BaseNode.extend({ id: objectId('roof'), type: nodeType('roof'), - // Position of the roof center (Y should typically be 0) + // Position of the roof group center position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), - // Length of the roof along the ridge direction (in meters) - length: z.number().default(4), - // Height of the roof peak from the base - height: z.number().default(1.5), - // Width of the left slope (in meters, measured horizontally from ridge) - leftWidth: z.number().default(1.5), - // Width of the right slope (in meters, measured horizontally from ridge) - rightWidth: z.number().default(1.5), + // Child roof segment IDs + children: z.array(RoofSegmentNode.shape.id).default([]), }).describe( dedent` - Roof node - used to represent a gable roof in the building - - position: center position of the roof (Y typically 0) + Roof node - a container for roof segments. + Acts as a group that holds one or more RoofSegmentNodes. + When not being edited, segments are visually combined into a single solid. + - position: center position of the roof group - rotation: rotation around Y axis - - length: length of the roof along the ridge - - height: height of the roof peak - - leftWidth: horizontal width of the left slope (from ridge to eave) - - rightWidth: horizontal width of the right slope (from ridge to eave) - Total width = leftWidth + rightWidth + - children: array of RoofSegmentNode IDs `, ) diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 901e49a6..da0df16c 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -6,6 +6,7 @@ import { GuideNode } from './nodes/guide' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' import { RoofNode } from './nodes/roof' +import { RoofSegmentNode } from './nodes/roof-segment' import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' @@ -23,6 +24,7 @@ export const AnyNode = z.discriminatedUnion('type', [ SlabNode, CeilingNode, RoofNode, + RoofSegmentNode, ScanNode, GuideNode, WindowNode, diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 7959607e..d05ec0ca 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -164,9 +164,15 @@ export const deleteNodesAction = ( return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections } }) - // Trigger a full scene re-validation after deleting node (as deleting a slab can cause widespread changes to level elevations) - const currentNodes = get().nodes - Object.values(currentNodes).forEach((node) => { - get().markDirty(node.id) + // Mark affected nodes dirty: parents of deleted nodes and their remaining children + // (e.g. deleting a slab affects sibling walls via level elevation changes) + parentsToMarkDirty.forEach((parentId) => { + get().markDirty(parentId) + const parent = get().nodes[parentId] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { + get().markDirty(childId as AnyNodeId) + } + } }) } diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 10c5e35c..eeb969e2 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -3,16 +3,57 @@ import type { TemporalState } from 'zundo' import { temporal } from 'zundo' import { create, type StoreApi, type UseBoundStore } from 'zustand' -import { persist } from 'zustand/middleware' import { BuildingNode } from '../schema' import type { Collection, CollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections' import { LevelNode } from '../schema/nodes/level' import { SiteNode } from '../schema/nodes/site' import type { AnyNode, AnyNodeId } from '../schema/types' -import { isObject } from '../utils/types' import * as nodeActions from './actions/node-actions' +function migrateNodes(nodes: Record): Record { + const patchedNodes = { ...nodes } + for (const [id, node] of Object.entries(patchedNodes)) { + // 1. Item scale migration + if (node.type === 'item' && !('scale' in node)) { + patchedNodes[id] = { ...node, scale: [1, 1, 1] } + } + // 2. Old roof to new roof + segment migration + if (node.type === 'roof' && !('children' in node)) { + const oldRoof = node + const suffix = id.includes('_') ? id.split('_')[1] : Math.random().toString(36).slice(2) + const segmentId = `rseg_${suffix}` + + const segment = { + object: 'node', + id: segmentId, + type: 'roof-segment', + parentId: id, + visible: oldRoof.visible ?? true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + roofType: 'gable', + width: oldRoof.length ?? 8, + depth: (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2), + wallHeight: 0, + roofHeight: oldRoof.height ?? 2.5, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + } + + patchedNodes[segmentId] = segment + patchedNodes[id] = { + ...oldRoof, + children: [segmentId], + } + } + } + return patchedNodes as Record +} + export type SceneState = { // 1. The Data: A flat dictionary of all nodes nodes: Record @@ -29,6 +70,7 @@ export type SceneState = { // Actions loadScene: () => void clearScene: () => void + unloadScene: () => void setScene: (nodes: Record, rootNodeIds: AnyNodeId[]) => void markDirty: (id: AnyNodeId) => void @@ -58,290 +100,217 @@ type UseSceneStore = UseBoundStore> & { } const useScene: UseSceneStore = create()( - persist( - temporal( - (set, get) => ({ - // 1. Flat dictionary of all nodes - nodes: {}, + temporal( + (set, get) => ({ + // 1. Flat dictionary of all nodes + nodes: {}, - // 2. Root node IDs - rootNodeIds: [], + // 2. Root node IDs + rootNodeIds: [], - // 3. Dirty set - dirtyNodes: new Set(), + // 3. Dirty set + dirtyNodes: new Set(), - // 4. Collections - collections: {} as Record, + // 4. Collections + collections: {} as Record, - clearScene: () => { - set({ - nodes: {}, - rootNodeIds: [], - dirtyNodes: new Set(), - collections: {}, - }) - get().loadScene() // Default scene - }, + unloadScene: () => { + set({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + }) + }, - setScene: (nodes, rootNodeIds) => { - // Backward compat: add default scale to item nodes loaded from external sources - // (pascal_local_projects, Supabase) saved before scale was added to ItemNode - const patchedNodes = { ...nodes } - for (const [id, node] of Object.entries(patchedNodes)) { - if (node.type === 'item' && !('scale' in node)) { - patchedNodes[id as AnyNodeId] = { ...(node as object), scale: [1, 1, 1] } as AnyNode - } - } - set({ - nodes: patchedNodes, - rootNodeIds, - dirtyNodes: new Set(), - }) - // Mark all nodes as dirty to trigger re-validation - Object.values(patchedNodes).forEach((node) => { + clearScene: () => { + get().unloadScene() + get().loadScene() // Default scene + }, + + setScene: (nodes, rootNodeIds) => { + // Apply backward compatibility migrations + const patchedNodes = migrateNodes(nodes) + + set({ + nodes: patchedNodes, + rootNodeIds, + dirtyNodes: new Set(), + }) + // Mark all nodes as dirty to trigger re-validation + Object.values(patchedNodes).forEach((node) => { + get().markDirty(node.id) + }) + }, + + loadScene: () => { + if (get().rootNodeIds.length > 0) { + // Assign all nodes as dirty to force re-validation + Object.values(get().nodes).forEach((node) => { get().markDirty(node.id) }) - }, + return // Scene already loaded + } - loadScene: () => { - if (get().rootNodeIds.length > 0) { - // Assign all nodes as dirty to force re-validation - Object.values(get().nodes).forEach((node) => { - get().markDirty(node.id) - }) - return // Scene already loaded - } + // Create hierarchy: Site → Building → Level + const level0 = LevelNode.parse({ + level: 0, + children: [], + }) - // Create hierarchy: Site → Building → Level - const level0 = LevelNode.parse({ - level: 0, - children: [], - }) + const building = BuildingNode.parse({ + children: [level0.id], + }) - const building = BuildingNode.parse({ - children: [level0.id], - }) + const site = SiteNode.parse({ + children: [building], + }) - const site = SiteNode.parse({ - children: [building], - }) + // Define all nodes flat + const nodes: Record = { + [site.id]: site, + [building.id]: building, + [level0.id]: level0, + } - // Define all nodes flat - const nodes: Record = { - [site.id]: site, - [building.id]: building, - [level0.id]: level0, - } + // Site is the root + const rootNodeIds = [site.id] - // Site is the root - const rootNodeIds = [site.id] + set({ nodes, rootNodeIds }) + }, - set({ nodes, rootNodeIds }) - }, + markDirty: (id) => { + get().dirtyNodes.add(id) + }, - markDirty: (id) => { - get().dirtyNodes.add(id) - }, + clearDirty: (id) => { + get().dirtyNodes.delete(id) + }, - clearDirty: (id) => { - get().dirtyNodes.delete(id) - }, + createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), + createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), - createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), - createNode: (node, parentId) => - nodeActions.createNodesAction(set, get, [{ node, parentId }]), + updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), + updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), - updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), - updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), + // --- DELETE --- - // --- DELETE --- + deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids), - deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids), + deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]), - deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]), + // --- COLLECTIONS --- - // --- COLLECTIONS --- - - createCollection: (name, nodeIds = []) => { - const id = generateCollectionId() - const collection: Collection = { id, name, nodeIds } - set((state) => { - const nextCollections = { ...state.collections, [id]: collection } - // Denormalize: stamp collectionId onto each node - const nextNodes = { ...state.nodes } - for (const nodeId of nodeIds) { - const node = nextNodes[nodeId] - if (!node) continue - const existing = - ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? [] - nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode - } - return { collections: nextCollections, nodes: nextNodes } - }) - return id - }, - - deleteCollection: (id) => { - set((state) => { - const col = state.collections[id] - const nextCollections = { ...state.collections } - delete nextCollections[id] - // Remove collectionId from all member nodes - const nextNodes = { ...state.nodes } - for (const nodeId of col?.nodeIds ?? []) { - const node = nextNodes[nodeId] - if (!(node && 'collectionIds' in node)) continue - nextNodes[nodeId] = { - ...node, - collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id), - } as AnyNode - } - return { collections: nextCollections, nodes: nextNodes } - }) - }, - - updateCollection: (id, data) => { - set((state) => { - const col = state.collections[id] - if (!col) return state - return { collections: { ...state.collections, [id]: { ...col, ...data } } } - }) - }, - - addToCollection: (id, nodeId) => { - set((state) => { - const col = state.collections[id] - if (!col || col.nodeIds.includes(nodeId)) return state - const nextCollections = { - ...state.collections, - [id]: { ...col, nodeIds: [...col.nodeIds, nodeId] }, - } - const node = state.nodes[nodeId] - if (!node) return { collections: nextCollections } + createCollection: (name, nodeIds = []) => { + const id = generateCollectionId() + const collection: Collection = { id, name, nodeIds } + set((state) => { + const nextCollections = { ...state.collections, [id]: collection } + // Denormalize: stamp collectionId onto each node + const nextNodes = { ...state.nodes } + for (const nodeId of nodeIds) { + const node = nextNodes[nodeId] + if (!node) continue const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? [] - const nextNodes = { - ...state.nodes, - [nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode, - } - return { collections: nextCollections, nodes: nextNodes } - }) - }, - - removeFromCollection: (id, nodeId) => { - set((state) => { - const col = state.collections[id] - if (!col) return state - const nextCollections = { - ...state.collections, - [id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) }, - } - const node = state.nodes[nodeId] - if (!(node && 'collectionIds' in node)) return { collections: nextCollections } - const nextNodes = { - ...state.nodes, - [nodeId]: { - ...node, - collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id), - } as AnyNode, - } - return { collections: nextCollections, nodes: nextNodes } - }) - }, - }), - { - partialize: (state) => { - const { nodes, rootNodeIds, collections } = state - return { nodes, rootNodeIds, collections } - }, - limit: 50, // Limit to last 50 actions + nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode + } + return { collections: nextCollections, nodes: nextNodes } + }) + return id }, - ), + + deleteCollection: (id) => { + set((state) => { + const col = state.collections[id] + const nextCollections = { ...state.collections } + delete nextCollections[id] + // Remove collectionId from all member nodes + const nextNodes = { ...state.nodes } + for (const nodeId of col?.nodeIds ?? []) { + const node = nextNodes[nodeId] + if (!(node && 'collectionIds' in node)) continue + nextNodes[nodeId] = { + ...node, + collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id), + } as AnyNode + } + return { collections: nextCollections, nodes: nextNodes } + }) + }, + + updateCollection: (id, data) => { + set((state) => { + const col = state.collections[id] + if (!col) return state + return { collections: { ...state.collections, [id]: { ...col, ...data } } } + }) + }, + + addToCollection: (id, nodeId) => { + set((state) => { + const col = state.collections[id] + if (!col || col.nodeIds.includes(nodeId)) return state + const nextCollections = { + ...state.collections, + [id]: { ...col, nodeIds: [...col.nodeIds, nodeId] }, + } + const node = state.nodes[nodeId] + if (!node) return { collections: nextCollections } + const existing = + ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? [] + const nextNodes = { + ...state.nodes, + [nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode, + } + return { collections: nextCollections, nodes: nextNodes } + }) + }, + + removeFromCollection: (id, nodeId) => { + set((state) => { + const col = state.collections[id] + if (!col) return state + const nextCollections = { + ...state.collections, + [id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) }, + } + const node = state.nodes[nodeId] + if (!(node && 'collectionIds' in node)) return { collections: nextCollections } + const nextNodes = { + ...state.nodes, + [nodeId]: { + ...node, + collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id), + } as AnyNode, + } + return { collections: nextCollections, nodes: nextNodes } + }) + }, + }), { - name: 'editor-storage', - version: 1, - // Keep existing local scenes when the persist version changes. - migrate: (persistedState) => - persistedState as Pick, - partialize: (state) => ({ - nodes: Object.fromEntries( - Object.entries(state.nodes).filter(([_, node]) => { - const meta = node.metadata - const isTransient = isObject(meta) && 'isTransient' in meta && meta.isTransient === true - - return !isTransient - }), - ), - rootNodeIds: state.rootNodeIds, - collections: state.collections, - }), - merge: (persistedState, currentState) => { - const persisted = persistedState as Partial - // Backward compat: add default scale to item nodes saved before scale was added - if (persisted.nodes) { - for (const [id, node] of Object.entries(persisted.nodes)) { - if (node.type === 'item' && !('scale' in node)) { - persisted.nodes[id as AnyNodeId] = { - ...(node as object), - scale: [1, 1, 1], - } as AnyNode - } - } - } - return { ...currentState, ...persisted } - }, - onRehydrateStorage: (state) => { - console.log('hydrating...') - - return (state, error) => { - if (error) { - console.log('an error happened during hydration', error) - return - } - - if (!state) { - console.log('hydration finished - no state') - return - } - - // Migration: Wrap old scenes (where root is not a SiteNode) in a SiteNode - const rootId = state.rootNodeIds?.[0] - const rootNode = rootId ? state.nodes[rootId] : null - - if (rootNode && rootNode.type !== 'site') { - console.log('Migrating old scene: wrapping in SiteNode') - - // Collect existing root nodes (should be BuildingNode or ItemNode) - const existingRoots = (state.rootNodeIds || []) - .map((id) => state.nodes[id]) - .filter((node) => node?.type === 'building' || node?.type === 'item') - - // Create a new SiteNode with existing roots as children - const site = SiteNode.parse({ - children: existingRoots, - }) - - // Add site to nodes - state.nodes[site.id] = site - - // Update root to be the site - state.rootNodeIds = [site.id] - - console.log('Migration complete: scene now has SiteNode as root') - } - - console.log('hydration finished') - } + partialize: (state) => { + const { nodes, rootNodeIds, collections } = state + return { nodes, rootNodeIds, collections } }, + limit: 50, // Limit to last 50 actions }, ), ) export default useScene -// Track previous temporal state lengths +// Track previous temporal state lengths and node snapshot for diffing let prevPastLength = 0 let prevFutureLength = 0 +let prevNodesSnapshot: Record | null = null + +export function clearSceneHistory() { + useScene.temporal.getState().clear() + prevPastLength = 0 + prevFutureLength = 0 + prevNodesSnapshot = null +} // Subscribe to the temporal store (Undo/Redo events) useScene.temporal.subscribe((state) => { @@ -354,18 +323,40 @@ useScene.temporal.subscribe((state) => { const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength if (didUndo || didRedo) { + // Capture the previous snapshot before RAF fires + const snapshotBefore = prevNodesSnapshot + // Use RAF to ensure all middleware and store updates are complete requestAnimationFrame(() => { const currentNodes = useScene.getState().nodes + const { markDirty } = useScene.getState() - // Trigger a full scene re-validation after undo/redo - Object.values(currentNodes).forEach((node) => { - useScene.getState().markDirty(node.id) - }) + if (snapshotBefore) { + // Diff: only mark nodes that actually changed + for (const [id, node] of Object.entries(currentNodes) as [AnyNodeId, AnyNode][]) { + if (snapshotBefore[id] !== node) { + markDirty(id) + // Also mark parent so merged geometries update + if (node.parentId) markDirty(node.parentId as AnyNodeId) + } + } + // Nodes that were deleted (exist in prev but not current) + for (const [id, node] of Object.entries(snapshotBefore) as [AnyNodeId, AnyNode][]) { + if (!currentNodes[id]) { + if (node.parentId) markDirty(node.parentId as AnyNodeId) + } + } + } else { + // No snapshot to diff against — fall back to marking all + for (const node of Object.values(currentNodes)) { + markDirty(node.id) + } + } }) } - // Update tracked lengths + // Update tracked lengths and snapshot prevPastLength = currentPastLength prevFutureLength = currentFutureLength + prevNodesSnapshot = useScene.getState().nodes }) diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx index 3afce72c..45184b49 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -1,20 +1,28 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' +import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { computeBoundsTree } from 'three-mesh-bvh' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' -import type { AnyNodeId, RoofNode } from '../../schema' +import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '../../schema' +import type { RoofType } from '../../schema/nodes/roof-segment' import useScene from '../../store/use-scene' -// ============================================================================ -// ROOF GEOMETRY CONSTANTS -// ============================================================================ +const csgEvaluator = new Evaluator() +csgEvaluator.useGroups = true +csgEvaluator.attributes = ['position', 'normal'] -const THICKNESS_A = 0.05 // Roof cover thickness (5cm) -const THICKNESS_B = 0.1 // Structure thickness (10cm) -const ROOF_COVER_OVERHANG = 0.05 // Extension of cover past structure (5cm) -const EAVE_OVERHANG = 0.4 // Horizontal eave overhang (40cm) -const RAKE_OVERHANG = 0.3 // Overhang at gable ends (30cm) -const WALL_THICKNESS = 0.2 // Gable wall thickness (20cm) -const BASE_HEIGHT = 0.5 // Base height / knee wall / truss heel (50cm) +// Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry +const _matrix = new THREE.Matrix4() +const _position = new THREE.Vector3() +const _quaternion = new THREE.Quaternion() +const _scale = new THREE.Vector3(1, 1, 1) +const _yAxis = new THREE.Vector3(0, 1, 0) + +// Pending merged-roof updates carried across frames (for throttling) +const pendingRoofUpdates = new Set() +const MAX_ROOFS_PER_FRAME = 1 +const MAX_SEGMENTS_PER_FRAME = 3 // ============================================================================ // ROOF SYSTEM @@ -23,295 +31,957 @@ const BASE_HEIGHT = 0.5 // Base height / knee wall / truss heel (50cm) export const RoofSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) + const rootNodeIds = useScene((state) => state.rootNodeIds) useFrame(() => { - if (dirtyNodes.size === 0) return + // Clear stale pending updates when the scene is unloaded + if (rootNodeIds.length === 0) { + pendingRoofUpdates.clear() + return + } + + if (dirtyNodes.size === 0 && pendingRoofUpdates.size === 0) return const nodes = useScene.getState().nodes - // Process dirty roofs + // --- Pass 1: Process dirty roof-segments (throttled) --- + let segmentsProcessed = 0 dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'roof') return + if (!node) return - const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh - if (mesh) { - updateRoofGeometry(node as RoofNode, mesh) + if (node.type === 'roof-segment') { + const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh + if (mesh) { + // Only compute expensive individual CSG when the segment is actually rendered + // (its parent group is visible = the roof is selected for editing) + const isVisible = mesh.parent?.visible !== false + if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) { + updateRoofSegmentGeometry(node as RoofSegmentNode, mesh) + segmentsProcessed++ + } else if (isVisible) { + return // Over budget — keep dirty, process next frame + } else { + // Just sync transform, skip CSG — the merged roof handles visuals. + // But replace the initial BoxGeometry once: it has 6 groups (materialIndex 0-5) + // while roofMaterials only has 4 entries. Three.js raycasts into invisible groups, + // so MeshBVH hits groups[4].materialIndex → undefined.side → crash. + if (mesh.geometry.type === 'BoxGeometry') { + mesh.geometry.dispose() + const placeholder = new THREE.BufferGeometry() + placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + placeholder.computeBoundsTree = computeBoundsTree + placeholder.computeBoundsTree({ maxLeafSize: 10 }) + mesh.geometry = placeholder + } + mesh.position.set(node.position[0], node.position[1], node.position[2]) + mesh.rotation.y = node.rotation + } + clearDirty(id as AnyNodeId) + } + // Queue the parent roof for a merged geometry update + if (node.parentId) { + pendingRoofUpdates.add(node.parentId as AnyNodeId) + } + } else if (node.type === 'roof') { + pendingRoofUpdates.add(id as AnyNodeId) clearDirty(id as AnyNodeId) } - // If mesh not found, keep it dirty for next frame }) - }) + + // --- Pass 2: Process pending merged-roof updates (max 1 per frame) --- + let roofsProcessed = 0 + for (const id of pendingRoofUpdates) { + if (roofsProcessed >= MAX_ROOFS_PER_FRAME) break + + const node = nodes[id] + if (!node || node.type !== 'roof') { + pendingRoofUpdates.delete(id) + continue + } + const group = sceneRegistry.nodes.get(id) as THREE.Group + if (group) { + const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined + if (mergedMesh?.visible !== false) { + // Only rebuild when visible — RoofEditSystem re-triggers via markDirty on edit mode exit + updateMergedRoofGeometry(node as RoofNode, group, nodes) + roofsProcessed++ + } + } + pendingRoofUpdates.delete(id) + } + }, 5) // Priority 5: run after all other systems have settled return null } -/** - * Updates the geometry and transform for a single roof - */ -function updateRoofGeometry(node: RoofNode, mesh: THREE.Mesh) { - const newGeo = generateRoofGeometry(node) +// ============================================================================ +// GEOMETRY GENERATION +// ============================================================================ + +function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) { + const newGeo = generateRoofSegmentGeometry(node) mesh.geometry.dispose() mesh.geometry = newGeo + newGeo.computeBoundsTree = computeBoundsTree + newGeo.computeBoundsTree({ maxLeafSize: 10 }) - // Update position and rotation mesh.position.set(node.position[0], node.position[1], node.position[2]) mesh.rotation.y = node.rotation } -/** - * Helper to solve pitch angle analytically given rise, run and thicknesses - * Solves: run * tan(a) + (ThickA + ThickB)/cos(a) = rise - */ -function solvePitch(rise: number, run: number, thickA: number, thickB: number): number { - const T = thickA + thickB - if (run < 0.01) return 0 +function updateMergedRoofGeometry( + roofNode: RoofNode, + group: THREE.Group, + nodes: Record, +) { + const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined + if (!mergedMesh) return - const R = Math.sqrt(run * run + rise * rise) - if (R <= T) { - return Math.atan2(rise, run) * 0.5 // Fallback + const children = (roofNode.children ?? []) + .map((id) => nodes[id] as RoofSegmentNode) + .filter(Boolean) + + if (children.length === 0) { + mergedMesh.geometry.dispose() + // Keep a valid position attribute so Drei's BVH can index safely. + mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0) + return } - const phi = Math.atan2(rise, run) - const shift = Math.asin(T / R) + let totalShinSlab: Brush | null = null + let totalDeckSlab: Brush | null = null + let totalWall: Brush | null = null + let totalInner: Brush | null = null - return phi - shift -} + for (const child of children) { + const brushes = getRoofSegmentBrushes(child) + if (!brushes) continue -/** - * Helper to create a Three.js Shape from polygon points - */ -function createShape(points: { x: number; y: number }[]): THREE.Shape { - const shape = new THREE.Shape() - if (points.length === 0) return shape - const firstPoint = points[0] - if (!firstPoint) return shape - shape.moveTo(firstPoint.x, firstPoint.y) - for (let i = 1; i < points.length; i++) { - const point = points[i] - if (point) { - shape.lineTo(point.x, point.y) + _matrix.compose( + _position.set(child.position[0], child.position[1], child.position[2]), + _quaternion.setFromAxisAngle(_yAxis, child.rotation), + _scale, + ) + + const applyTransform = (brush: Brush) => { + brush.geometry.applyMatrix4(_matrix) + brush.updateMatrixWorld() + } + + applyTransform(brushes.shinSlab) + applyTransform(brushes.deckSlab) + applyTransform(brushes.wallBrush) + applyTransform(brushes.innerBrush) + + if (totalShinSlab) { + const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush + totalShinSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + totalShinSlab = next + } else { + totalShinSlab = brushes.shinSlab + } + + if (totalDeckSlab) { + const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush + totalDeckSlab.geometry.dispose() + brushes.deckSlab.geometry.dispose() + totalDeckSlab = next + } else { + totalDeckSlab = brushes.deckSlab + } + + if (totalWall) { + const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush + totalWall.geometry.dispose() + brushes.wallBrush.geometry.dispose() + totalWall = next + } else { + totalWall = brushes.wallBrush + } + + if (totalInner) { + const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush + totalInner.geometry.dispose() + brushes.innerBrush.geometry.dispose() + totalInner = next + } else { + totalInner = brushes.innerBrush } } - shape.closePath() - return shape + + if (totalShinSlab && totalDeckSlab && totalWall && totalInner) { + try { + const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION) + const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION) + const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) + + const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION) + const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) + + const resultGeo = combined.geometry + + const resultMaterials: THREE.Material[] = Array.isArray(combined.material) + ? combined.material + : [combined.material] + + const matToIndex = new Map([ + [dummyMats[0], 0], + [dummyMats[1], 1], + [dummyMats[2], 2], + [dummyMats[3], 3], + ]) + + for (const g of resultGeo.groups) { + g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) + } + + resultGeo.computeVertexNormals() + mergedMesh.geometry.dispose() + mergedMesh.geometry = resultGeo + + finalShinTrimmed.geometry.dispose() + finalDeckTrimmed.geometry.dispose() + finalWallTrimmed.geometry.dispose() + shinDeck.geometry.dispose() + } catch (e) { + console.error('Merged roof CSG failed:', e) + } + + totalShinSlab.geometry.dispose() + totalDeckSlab.geometry.dispose() + totalWall.geometry.dispose() + totalInner.geometry.dispose() + } +} + +const dummyMats: [ + THREE.MeshBasicMaterial, + THREE.MeshBasicMaterial, + THREE.MeshBasicMaterial, + THREE.MeshBasicMaterial, +] = [ + new THREE.MeshBasicMaterial(), + new THREE.MeshBasicMaterial(), + new THREE.MeshBasicMaterial(), + new THREE.MeshBasicMaterial(), +] +const ROOF_MATERIAL_SLOT_COUNT = 4 + +function mapRoofGroupMaterialIndex( + groupMaterialIndex: number | undefined, + csgMaterials: THREE.Material[], + matToIndex: Map, +): number { + if (groupMaterialIndex === undefined) return 0 + const sourceMaterial = csgMaterials[groupMaterialIndex] + const mappedIndex = sourceMaterial ? matToIndex.get(sourceMaterial) : undefined + return mappedIndex ?? 0 +} + +function normalizeRoofMaterialIndex(materialIndex: number | undefined): number { + if (materialIndex === undefined || !Number.isFinite(materialIndex)) return 0 + const normalized = Math.trunc(materialIndex) + if (normalized < 0 || normalized >= ROOF_MATERIAL_SLOT_COUNT) return 0 + return normalized +} + +const SHINGLE_SURFACE_EPSILON = 0.02 +const RAKE_FACE_NORMAL_EPSILON = 0.3 +const RAKE_FACE_ALIGNMENT_EPSILON = 0.35 + +/** + * Generate complete hollow-shell geometry for a roof segment. + * Ports the prototype's CSG approach using three-bvh-csg. + */ +export function getRoofSegmentBrushes( + node: RoofSegmentNode, +): { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush } | null { + const { + roofType, + width, + depth, + wallHeight, + roofHeight, + wallThickness, + deckThickness, + overhang, + shingleThickness, + } = node + + const activeRh = roofType === 'flat' ? 0 : roofHeight + + let run = Math.min(width, depth) / 2 + let rise = activeRh + if (roofType === 'shed') { + run = depth + } + if (roofType === 'gable') { + run = depth / 2 + } + if (roofType === 'gambrel') { + run = depth / 4 + rise = activeRh * 0.6 + } + if (roofType === 'mansard') { + run = Math.min(width, depth) * 0.15 + rise = activeRh * 0.7 + } + if (roofType === 'dutch') { + run = Math.min(width, depth) * 0.25 + rise = activeRh * 0.5 + } + + const tanTheta = run > 0 ? rise / run : 0 + const cosTheta = Math.cos(Math.atan2(rise, run)) || 1 + const sinTheta = Math.sin(Math.atan2(rise, run)) || 0 + + const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness + const baseI = Math.min(width, depth) * 0.25 + + const getVol = ( + wExt: number, + vOffset: number, + baseY: number, + matIndex: number, + isVoid: boolean, + ) => { + const wV = Math.max(0.01, width + 2 * wExt) + const dV = Math.max(0.01, depth + 2 * wExt) + + const autoDrop = wExt * tanTheta + const whV = wallHeight - autoDrop + vOffset + + let rhV = activeRh + if (activeRh > 0) { + rhV = activeRh + autoDrop + if (roofType === 'shed') rhV = activeRh + 2 * autoDrop + } + + const safeBaseY = Math.min(baseY, whV - 0.05) + + let structuralI = baseI + if (isVoid) { + structuralI += deckThickness + } + + const faces = getModuleFaces( + roofType, + wV, + dV, + whV, + rhV, + safeBaseY, + { dutchI: structuralI }, + width, + depth, + tanTheta, + ) + return createGeometryFromFaces(faces, matIndex) + } + + const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false) + const innerGeo = getVol(-wallThickness / 2, 0, -5, 2, false) + + const horizontalOverhang = overhang * cosTheta + const deckExt = wallThickness / 2 + horizontalOverhang + + const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false) + const deckBotGeo = getVol(deckExt, 0, -5, 0, true) + + const stSin = shingleThickness * sinTheta + const stCos = shingleThickness * cosTheta + + const shinBotW = Math.max(0.01, width + 2 * deckExt) + const shinBotD = Math.max(0.01, depth + 2 * deckExt) + + const deckDrop = deckExt * tanTheta + const shinBotWh = wallHeight - deckDrop + verticalRt + + let shinBotRh = activeRh + if (activeRh > 0) { + shinBotRh = activeRh + deckDrop + if (roofType === 'shed') shinBotRh = activeRh + 2 * deckDrop + } + + let shinTopW = shinBotW + let shinTopD = shinBotD + let transZ = 0 + + if (['hip', 'mansard', 'dutch'].includes(roofType)) { + shinTopW += 2 * stSin + shinTopD += 2 * stSin + } else if (['gable', 'gambrel'].includes(roofType)) { + shinTopD += 2 * stSin + } else if (roofType === 'shed') { + shinTopD += stSin + transZ = stSin / 2 + } + + const shinTopWh = shinBotWh + stCos + + let shinTopRh = shinBotRh + if (activeRh > 0) { + shinTopRh = shinBotRh + stSin * tanTheta + } + + const availableR = (Math.min(shinBotW, shinBotD) / 2) * 0.95 + const maxDrop = tanTheta > 0.001 ? availableR / tanTheta : 2.0 + const dropTop = Math.min(1.0, maxDrop * 0.4) + const dropBot = Math.min(2.0, maxDrop * 0.8) + + const topBaseY = shinBotWh - dropTop + const botBaseY = shinBotWh - dropBot + + const getInsets = (wh: number, bY: number, isVoid: boolean, brushW: number, brushD: number) => { + let inset = (wh - bY) * tanTheta + const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005 + if (inset > maxSafeInset) { + inset = maxSafeInset + } + + let iF = 0, + iB = 0, + iL = 0, + iR = 0 + if (['hip', 'mansard', 'dutch'].includes(roofType)) { + iF = inset + iB = inset + iL = inset + iR = inset + } else if (['gable', 'gambrel'].includes(roofType)) { + iF = inset + iB = inset + } else if (roofType === 'shed') { + iF = inset + } + + let structuralI = baseI + if (isVoid) { + structuralI += shingleThickness + } + return { iF, iB, iL, iR, dutchI: structuralI } + } + + const insetsBot = getInsets(shinBotWh, botBaseY, true, shinBotW, shinBotD) + const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD) + + const botFaces = getModuleFaces( + roofType, + shinBotW, + shinBotD, + shinBotWh, + shinBotRh, + botBaseY, + insetsBot, + width, + depth, + tanTheta, + ) + const topFaces = getModuleFaces( + roofType, + shinTopW, + shinTopD, + shinTopWh, + shinTopRh, + topBaseY, + insetsTop, + width, + depth, + tanTheta, + ) + + const shinBotGeo = createGeometryFromFaces(botFaces, 1) + const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, + ) + + if (transZ !== 0) { + shinTopGeo.translate(0, 0, transZ) + } + + const toBrush = (geo: THREE.BufferGeometry): Brush | null => { + if (!geo?.attributes.position || geo.attributes.position.count === 0) return null + if (!geo.index) return null + geo.computeBoundsTree = computeBoundsTree + geo.computeBoundsTree({ maxLeafSize: 10 }) + const brush = new Brush(geo, dummyMats) + brush.updateMatrixWorld() + return brush + } + + const eps = 0.002 + + const wallBrush = toBrush(wallGeo) + const innerBrush = toBrush(innerGeo) + if (innerBrush) { + const wV = Math.max(0.01, width - wallThickness) + const dV = Math.max(0.01, depth - wallThickness) + innerBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV) + innerBrush.updateMatrixWorld() + } + + const deckTopBrush = toBrush(deckTopGeo) + const deckBotBrush = toBrush(deckBotGeo) + if (deckBotBrush) { + const wV = Math.max(0.01, width + 2 * deckExt) + const dV = Math.max(0.01, depth + 2 * deckExt) + deckBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV) + deckBotBrush.updateMatrixWorld() + } + + const shinTopBrush = toBrush(shinTopGeo) + const shinBotBrush = toBrush(shinBotGeo) + if (shinBotBrush) { + const wV = shinBotW + const dV = shinBotD + shinBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV) + shinBotBrush.updateMatrixWorld() + } + + wallGeo.dispose() + innerGeo.dispose() + deckTopGeo.dispose() + deckBotGeo.dispose() + shinTopGeo.dispose() + shinBotGeo.dispose() + + if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) { + try { + const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION) + const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION) + + deckTopBrush.geometry.dispose() + deckBotBrush.geometry.dispose() + shinTopBrush.geometry.dispose() + shinBotBrush.geometry.dispose() + + return { deckSlab, shinSlab, wallBrush, innerBrush } + } catch (e) { + console.error('CSG prep failed:', e) + } + } + + if (deckTopBrush) deckTopBrush.geometry.dispose() + if (deckBotBrush) deckBotBrush.geometry.dispose() + if (shinTopBrush) shinTopBrush.geometry.dispose() + if (shinBotBrush) shinBotBrush.geometry.dispose() + if (wallBrush) wallBrush.geometry.dispose() + if (innerBrush) innerBrush.geometry.dispose() + + return null +} + +export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry { + const brushes = getRoofSegmentBrushes(node) + if (!brushes) { + // Fallback: simple box + return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth) + } + + const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes + let resultGeo = new THREE.BufferGeometry() + + try { + const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION) + const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION) + const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) + + resultGeo = combined.geometry + + const resultMaterials: THREE.Material[] = Array.isArray(combined.material) + ? combined.material + : [combined.material] + + const matToIndex = new Map([ + [dummyMats[0], 0], + [dummyMats[1], 1], + [dummyMats[2], 2], + [dummyMats[3], 3], + ]) + + for (const group of resultGeo.groups) { + group.materialIndex = mapRoofGroupMaterialIndex( + group.materialIndex, + resultMaterials, + matToIndex, + ) + } + + remapRoofShellFaces(resultGeo, node) + + hollowWall.geometry.dispose() + shinDeck.geometry.dispose() + } catch (e) { + console.error('Roof CSG failed:', e) + resultGeo = wallBrush.geometry.clone() + } + + deckSlab.geometry.dispose() + shinSlab.geometry.dispose() + wallBrush.geometry.dispose() + innerBrush.geometry.dispose() + + resultGeo.computeVertexNormals() + return resultGeo +} + +// ============================================================================ +// FACE-BASED GEOMETRY HELPERS (ported from prototype) +// ============================================================================ + +type Insets = { + iF?: number + iB?: number + iL?: number + iR?: number + dutchI?: number +} + +function remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSegmentNode) { + const position = geometry.getAttribute('position') + const index = geometry.getIndex() + + if (!(position && index) || index.count === 0 || geometry.groups.length === 0) return + + geometry.computeBoundingBox() + + const triangleCount = index.count / 3 + const triangleMaterials = new Array(triangleCount).fill(0) + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const centroid = new THREE.Vector3() + const normal = new THREE.Vector3() + + for (const group of geometry.groups) { + const startTriangle = Math.floor(group.start / 3) + const endTriangle = Math.min(triangleCount, Math.floor((group.start + group.count) / 3)) + + for (let triangleIndex = startTriangle; triangleIndex < endTriangle; triangleIndex++) { + const indexOffset = triangleIndex * 3 + let materialIndex = normalizeRoofMaterialIndex(group.materialIndex) + + if (materialIndex === 1 || materialIndex === 3) { + const ia = index.getX(indexOffset) + const ib = index.getX(indexOffset + 1) + const ic = index.getX(indexOffset + 2) + + a.fromBufferAttribute(position, ia) + b.fromBufferAttribute(position, ib) + c.fromBufferAttribute(position, ic) + + ab.subVectors(b, a) + ac.subVectors(c, a) + normal.crossVectors(ab, ac).normalize() + + centroid + .copy(a) + .add(b) + .add(c) + .multiplyScalar(1 / 3) + + if (normal.y > SHINGLE_SURFACE_EPSILON) { + materialIndex = 3 + } else if (isRakeFace(node, geometry, centroid, normal)) { + materialIndex = 0 + } else { + materialIndex = 1 + } + } + + triangleMaterials[triangleIndex] = materialIndex + } + } + + geometry.clearGroups() + + let currentMaterial = triangleMaterials[0] ?? 0 + let groupStart = 0 + + for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex++) { + const materialIndex = triangleMaterials[triangleIndex] ?? 0 + if (materialIndex === currentMaterial) continue + + geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) + groupStart = triangleIndex + currentMaterial = materialIndex + } + + geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) +} + +function isRakeFace( + node: RoofSegmentNode, + geometry: THREE.BufferGeometry, + centroid: THREE.Vector3, + normal: THREE.Vector3, +) { + const rakeAxis = getRakeAxis(node) + const bounds = geometry.boundingBox + + if (!(rakeAxis && bounds)) return false + if (Math.abs(normal.y) > RAKE_FACE_NORMAL_EPSILON) return false + + const axisNormal = rakeAxis === 'x' ? Math.abs(normal.x) : Math.abs(normal.z) + if (axisNormal < RAKE_FACE_ALIGNMENT_EPSILON) return false + + const halfExtent = + rakeAxis === 'x' + ? Math.max(Math.abs(bounds.min.x), Math.abs(bounds.max.x)) + : Math.max(Math.abs(bounds.min.z), Math.abs(bounds.max.z)) + const axisCoord = rakeAxis === 'x' ? Math.abs(centroid.x) : Math.abs(centroid.z) + const planeTolerance = Math.max( + node.overhang + node.wallThickness + node.deckThickness + node.shingleThickness, + 0.25, + ) + + if (halfExtent - axisCoord > planeTolerance) return false + + return true +} + +function getRakeAxis(node: RoofSegmentNode): 'x' | 'z' | null { + if (node.roofType === 'gable' || node.roofType === 'gambrel') return 'x' + if (node.roofType === 'dutch') return node.width >= node.depth ? 'x' : 'z' + return null } /** - * Generate profile for one side of the roof (left or right) + * Generates faces for a roof module volume. + * Supports: hip, gable, shed, gambrel, dutch, mansard, flat. */ -function getSideProfile( - dir: 1 | -1, - width: number, - roofHeight: number, -): { - pointsA: { x: number; y: number }[] - pointsB: { x: number; y: number }[] - pointsSide: { x: number; y: number }[] - pointsC1: { x: number; y: number }[] - pointsC2: { x: number; y: number }[] -} { - const halfWall = WALL_THICKNESS / 2 +function getModuleFaces( + type: RoofType, + w: number, + d: number, + wh: number, + rh: number, + baseY: number, + insets: Insets, + baseW: number, + baseD: number, + tanTheta: number, +): THREE.Vector3[][] { + const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) + const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets - const rise = Math.max(0, roofHeight - BASE_HEIGHT) - const run = width - halfWall + const b1 = v(-w / 2 + iL, baseY, d / 2 - iF) + const b2 = v(w / 2 - iR, baseY, d / 2 - iF) + const b3 = v(w / 2 - iR, baseY, -d / 2 + iB) + const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB) + const bottom = [b4, b3, b2, b1] - const angle = solvePitch(rise, run, THICKNESS_A, THICKNESS_B) - const tanA = Math.tan(angle) - const cosA = Math.cos(angle) - const sinA = Math.sin(angle) + const e1 = v(-w / 2, wh, d / 2) + const e2 = v(w / 2, wh, d / 2) + const e3 = v(w / 2, wh, -d / 2) + const e4 = v(-w / 2, wh, -d / 2) - const ridgeUnderY = BASE_HEIGHT + run * tanA - const ridgeInterfaceY = ridgeUnderY + THICKNESS_B / cosA - const ridgeTopY = ridgeInterfaceY + THICKNESS_A / cosA + const faces: THREE.Vector3[][] = [] + faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom) - const wallOuterTopY = BASE_HEIGHT - WALL_THICKNESS * tanA + const h = wh + Math.max(0.001, rh) - const overhangDx = EAVE_OVERHANG * cosA + if (type === 'flat' || rh === 0) { + faces.push([e1, e2, e3, e4]) + } else if (type === 'gable') { + const r1 = v(-w / 2, h, 0) + const r2 = v(w / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else if (type === 'hip') { + if (Math.abs(w - d) < 0.01) { + const r = v(0, h, 0) + faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]) + } else if (w >= d) { + const r1 = v(-w / 2 + d / 2, h, 0) + const r2 = v(w / 2 - d / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else { + const r1 = v(0, h, d / 2 - w / 2) + const r2 = v(0, h, -d / 2 + w / 2) + faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) + } + } else if (type === 'shed') { + const t1 = v(-w / 2, h, -d / 2) + const t2 = v(w / 2, h, -d / 2) + faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]) + } else if (type === 'gambrel') { + const mz = (baseD / 2) * 0.5 + const dist = d / 2 - mz + const mh = wh + dist * (tanTheta || 0) - const eaveTopZ = width + halfWall + overhangDx - const eaveTopY = ridgeTopY - eaveTopZ * tanA + const m1 = v(-w / 2, mh, mz) + const m2 = v(w / 2, mh, mz) + const m3 = v(w / 2, mh, -mz) + const m4 = v(-w / 2, mh, -mz) + const r1 = v(-w / 2, h, 0) + const r2 = v(w / 2, h, 0) + faces.push( + [e4, e1, m1, r1, m4], + [e2, e3, m3, r2, m2], + [e1, e2, m2, m1], + [m1, m2, r2, r1], + [e3, e4, m4, m3], + [m3, m4, r1, r2], + ) + } else if (type === 'mansard') { + const i = Math.min(baseW, baseD) * 0.15 + const mh = wh + i * (tanTheta || 0) - const coverExtDx = ROOF_COVER_OVERHANG * cosA - const coverExtDy = ROOF_COVER_OVERHANG * sinA + const m1 = v(-w / 2 + i, mh, d / 2 - i) + const m2 = v(w / 2 - i, mh, d / 2 - i) + const m3 = v(w / 2 - i, mh, -d / 2 + i) + const m4 = v(-w / 2 + i, mh, -d / 2 + i) + const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2) + const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2) + const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2) + const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2) + if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) { + if (w >= d) { + const r1 = v(-w / 2 + d / 2, h, 0) + const r2 = v(w / 2 - d / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else { + const r1 = v(0, h, d / 2 - w / 2) + const r2 = v(0, h, -d / 2 + w / 2) + faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) + } + } else { + faces.push( + [t1, t2, t3, t4], + [e1, e2, m2, m1], + [e2, e3, m3, m2], + [e3, e4, m4, m3], + [e4, e1, m1, m4], + [m1, m2, t2, t1], + [m2, m3, t3, t2], + [m3, m4, t4, t3], + [m4, m1, t1, t4], + ) + } + } else if (type === 'dutch') { + const i = insets.dutchI !== undefined ? insets.dutchI : Math.min(baseW, baseD) * 0.25 + const mh = wh + i * (tanTheta || 0) - const eaveTopExtZ = eaveTopZ + coverExtDx - const eaveTopExtY = eaveTopY - coverExtDy + if (w >= d) { + const m1 = v(-w / 2 + i, mh, d / 2 - i) + const m2 = v(w / 2 - i, mh, d / 2 - i) + const m3 = v(w / 2 - i, mh, -d / 2 + i) + const m4 = v(-w / 2 + i, mh, -d / 2 + i) + const r1 = v(-w / 2 + i, h, 0) + const r2 = v(w / 2 - i, h, 0) - const eaveInterfaceExtZ = eaveTopExtZ - THICKNESS_A * sinA - const eaveInterfaceExtY = eaveTopExtY - THICKNESS_A * cosA + faces.push( + [e1, e2, m2, m1], + [e2, e3, m3, m2], + [e3, e4, m4, m3], + [e4, e1, m1, m4], + [m4, m1, r1], + [m2, m3, r2], + [m1, m2, r2, r1], + [m3, m4, r1, r2], + ) + } else { + const m1 = v(-w / 2 + i, mh, d / 2 - i) + const m2 = v(w / 2 - i, mh, d / 2 - i) + const m3 = v(w / 2 - i, mh, -d / 2 + i) + const m4 = v(-w / 2 + i, mh, -d / 2 + i) + const r1 = v(0, h, d / 2 - i) + const r2 = v(0, h, -d / 2 + i) - const eaveInterfaceZ = eaveTopZ + faces.push( + [e1, e2, m2, m1], + [e2, e3, m3, m2], + [e3, e4, m4, m3], + [e4, e1, m1, m4], + [m1, m2, r1], + [m3, m4, r2], + [m2, m3, r2, r1], + [m4, m1, r1, r2], + ) + } + } - const eaveBottomZ = eaveTopZ - const eaveBottomY = ridgeUnderY - eaveTopZ * tanA - - // Layer A (Cover) - const pointsA = [ - { x: 0, y: ridgeTopY }, - { x: dir * eaveTopExtZ, y: eaveTopExtY }, - { x: dir * eaveInterfaceExtZ, y: eaveInterfaceExtY }, - { x: 0, y: ridgeInterfaceY }, - ] - - // Layer B (Structure) - const pointsB = [ - { x: 0, y: ridgeInterfaceY }, - { x: dir * eaveInterfaceZ, y: ridgeInterfaceY - eaveTopZ * tanA }, - { x: dir * eaveBottomZ, y: eaveBottomY }, - { x: 0, y: ridgeUnderY }, - ] - - // Side Wall - const zInner = width - halfWall - const zOuter = width + halfWall - - const pointsSide = [ - { x: dir * zInner, y: 0 }, - { x: dir * zOuter, y: 0 }, - { x: dir * zOuter, y: Math.max(0, wallOuterTopY) }, - { x: dir * zInner, y: BASE_HEIGHT }, - ] - - // Gable Top (C1) - const pointsC1 = [ - { x: 0, y: BASE_HEIGHT }, - { x: dir * zInner, y: BASE_HEIGHT }, - { x: dir * zInner, y: BASE_HEIGHT }, - { x: 0, y: ridgeUnderY }, - ] - - // Gable Base (C2) - const pointsC2 = [ - { x: 0, y: 0 }, - { x: dir * zInner, y: 0 }, - { x: dir * zInner, y: BASE_HEIGHT }, - { x: 0, y: BASE_HEIGHT }, - ] - - return { pointsA, pointsB, pointsSide, pointsC1, pointsC2 } + return faces } /** - * Generates detailed gable roof geometry with layers, walls, and overhangs + * Converts an array of face polygons into a BufferGeometry. + * Each face is triangulated via fan triangulation. */ -export function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry { - const { length, height, leftWidth, rightWidth } = roofNode - - const ridgeLength = length - - // Get profiles for both sides - const leftP = getSideProfile(1, leftWidth, height) - const rightP = getSideProfile(-1, rightWidth, height) - - // Create shapes from profiles - const shapes = { - ALeft: createShape(leftP.pointsA), - ARight: createShape(rightP.pointsA), - BLeft: createShape(leftP.pointsB), - BRight: createShape(rightP.pointsB), - SideLeft: createShape(leftP.pointsSide), - SideRight: createShape(rightP.pointsSide), - C1Left: createShape(leftP.pointsC1), - C1Right: createShape(rightP.pointsC1), - C2Left: createShape(leftP.pointsC2), - C2Right: createShape(rightP.pointsC2), - } - - // Calculate extrusion lengths and offsets - const lengths = { - A: ridgeLength + 2 * RAKE_OVERHANG + 2 * ROOF_COVER_OVERHANG + WALL_THICKNESS, - B: ridgeLength + 2 * RAKE_OVERHANG + WALL_THICKNESS, - Side: ridgeLength + WALL_THICKNESS, - Gable: WALL_THICKNESS, - } - - const offsets = { - A: -RAKE_OVERHANG - ROOF_COVER_OVERHANG - WALL_THICKNESS / 2, - B: -RAKE_OVERHANG - WALL_THICKNESS / 2, - Side: -WALL_THICKNESS / 2, - GableFront: -WALL_THICKNESS / 2, - GableBack: ridgeLength - WALL_THICKNESS / 2, - } - - // Helper to create and position extruded geometry - const createPart = (shape: THREE.Shape, depth: number, xOffset: number) => { - const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false }) - // Rotate to align: extrusion goes along X axis - geo.rotateY(Math.PI / 2) - geo.translate(xOffset, 0, 0) - return geo - } - - // Create all parts - const geometries: THREE.BufferGeometry[] = [] - - // Layer A (Cover) - both sides - geometries.push(createPart(shapes.ALeft, lengths.A, offsets.A)) - geometries.push(createPart(shapes.ARight, lengths.A, offsets.A)) - - // Layer B (Structure) - both sides - geometries.push(createPart(shapes.BLeft, lengths.B, offsets.B)) - geometries.push(createPart(shapes.BRight, lengths.B, offsets.B)) - - // Side Walls - both sides - geometries.push(createPart(shapes.SideLeft, lengths.Side, offsets.Side)) - geometries.push(createPart(shapes.SideRight, lengths.Side, offsets.Side)) - - // Gable Walls (Front) - geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableFront)) - geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableFront)) - geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableFront)) - geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableFront)) - - // Gable Walls (Back) - geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableBack)) - geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableBack)) - geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableBack)) - geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableBack)) - - // Merge all geometries - const mergedGeometry = new THREE.BufferGeometry() +function createGeometryFromFaces( + faces: THREE.Vector3[][], + matRule: number | ((normal: THREE.Vector3) => number) | null = null, +): THREE.BufferGeometry { const positions: number[] = [] const normals: number[] = [] - const uvs: number[] = [] + const indices: number[] = [] + const groups: { start: number; count: number; materialIndex: number }[] = [] + let vertexCount = 0 - for (const geo of geometries) { - const posAttr = geo.getAttribute('position') - const normAttr = geo.getAttribute('normal') - const uvAttr = geo.getAttribute('uv') + for (const face of faces) { + if (face.length < 3) continue - if (posAttr) { - for (let i = 0; i < posAttr.count; i++) { - positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i)) - } - } - if (normAttr) { - for (let i = 0; i < normAttr.count; i++) { - normals.push(normAttr.getX(i), normAttr.getY(i), normAttr.getZ(i)) - } - } - if (uvAttr) { - for (let i = 0; i < uvAttr.count; i++) { - uvs.push(uvAttr.getX(i), uvAttr.getY(i)) - } + const p0 = face[0]! + const p1 = face[1]! + const p2 = face[2]! + const vA = new THREE.Vector3().subVectors(p1, p0) + const vB = new THREE.Vector3().subVectors(p2, p0) + const normal = new THREE.Vector3().crossVectors(vA, vB).normalize() + + let assignedMatIndex = 0 + if (typeof matRule === 'function') { + assignedMatIndex = matRule(normal) + } else if (matRule !== null && matRule !== undefined) { + assignedMatIndex = matRule + } else { + const isVertical = Math.abs(normal.y) < 0.01 + assignedMatIndex = isVertical ? 0 : 1 } - geo.dispose() + let faceVertexCount = 0 + const startVertexCount = vertexCount + + for (let i = 1; i < face.length - 1; i++) { + const fi = face[i]! + const fi1 = face[i + 1]! + positions.push(p0.x, p0.y, p0.z) + positions.push(fi.x, fi.y, fi.z) + positions.push(fi1.x, fi1.y, fi1.z) + + normals.push(normal.x, normal.y, normal.z) + normals.push(normal.x, normal.y, normal.z) + normals.push(normal.x, normal.y, normal.z) + + indices.push(vertexCount, vertexCount + 1, vertexCount + 2) + + faceVertexCount += 3 + vertexCount += 3 + } + + groups.push({ + start: startVertexCount, + count: faceVertexCount, + materialIndex: assignedMatIndex, + }) } - mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) - mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) - if (uvs.length > 0) { - mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setIndex(indices) + + for (const g of groups) { + geometry.addGroup(g.start, g.count, g.materialIndex) } - mergedGeometry.computeVertexNormals() + // Merge identical vertices to optimize geometry for CSG and create clean topology + const mergedGeo = mergeVertices(geometry, 1e-4) + geometry.dispose() - // Center the geometry at X=0 (translate by -ridgeLength/2) - // This matches the old geometry centering behavior - mergedGeometry.translate(-ridgeLength / 2, 0, 0) - - return mergedGeometry + return mergedGeo } diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index 5bcbe6bd..069b7684 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -95,12 +95,12 @@ function findJunctions(walls: WallNode[]): Map { if (!junctions.has(keyStart)) { junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] }) } - junctions.get(keyStart)!.connectedWalls.push({ wall, endType: 'start' }) + junctions.get(keyStart)?.connectedWalls.push({ wall, endType: 'start' }) if (!junctions.has(keyEnd)) { junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] }) } - junctions.get(keyEnd)!.connectedWalls.push({ wall, endType: 'end' }) + junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' }) } // Second pass: detect T-junctions (walls passing through junction points) diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index dea62947..cb8d327a 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -46,7 +46,7 @@ export const WallSystem = () => { if (!dirtyWallsByLevel.has(levelId)) { dirtyWallsByLevel.set(levelId, new Set()) } - dirtyWallsByLevel.get(levelId)!.add(id) + dirtyWallsByLevel.get(levelId)?.add(id) }) // Process each level that has dirty walls diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 2d0acb9a..0a4a061d 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -4,7 +4,6 @@ import { type Interactive, type ItemNode, type LightEffect, - type SliderControl, useInteractive, useRegistry, useScene, @@ -14,12 +13,13 @@ import { Clone } from '@react-three/drei/core/Clone' import { useGLTF } from '@react-three/drei/core/Gltf' import { useFrame } from '@react-three/fiber' import { Suspense, useEffect, useMemo, useRef } from 'react' -import type { AnimationAction, Group, Material, Mesh, PointLight } from 'three' +import type { AnimationAction, Group, Material, Mesh } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' +import { useItemLightPool } from '../../../store/use-item-light-pool' import { NodeRenderer } from '../node-renderer' // Shared materials to avoid creating new instances for every mesh @@ -167,7 +167,13 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { /> )} {lightEffects.map((effect, i) => ( - + ))} ) @@ -245,54 +251,22 @@ const ItemAnimation = ({ return null } -const ItemLight = ({ +const ItemLightRegistrar = ({ nodeId, effect, interactive, + index, }: { nodeId: AnyNodeId effect: LightEffect interactive: Interactive + index: number }) => { - const lightRef = useRef(null!) - // Precompute stable indices — interactive is frozen at mount - const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle') - const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider') - const sliderControl = - sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null + useEffect(() => { + const key = `${nodeId}:${index}` + useItemLightPool.getState().register(key, nodeId, effect, interactive) + return () => useItemLightPool.getState().unregister(key) + }, [nodeId, index, effect, interactive]) - useFrame((_, delta) => { - if (!lightRef.current) return - const values = useInteractive.getState().items[nodeId]?.controlValues - - const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : true - - // Normalize slider to 0-1 (default full intensity if no slider) - let t = 1 - if (sliderControl) { - const raw = (values?.[sliderIndex] as number) ?? sliderControl.min - t = (raw - sliderControl.min) / (sliderControl.max - sliderControl.min) - } - - const target = isOn - ? MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], t) - : effect.intensityRange[0] - - lightRef.current.intensity = MathUtils.lerp( - lightRef.current.intensity, - target, - Math.min(delta * 12, 1), - ) - }) - - return ( - - ) + return null } diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 87e10141..3d70ef95 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -8,6 +8,7 @@ import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' import { RoofRenderer } from './roof/roof-renderer' +import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { ScanRenderer } from './scan/scan-renderer' import { SiteRenderer } from './site/site-renderer' import { SlabRenderer } from './slab/slab-renderer' @@ -33,6 +34,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'window' && } {node.type === 'zone' && } {node.type === 'roof' && } + {node.type === 'roof-segment' && } {node.type === 'scan' && } {node.type === 'guide' && } diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx new file mode 100644 index 00000000..4d43ed19 --- /dev/null +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -0,0 +1,29 @@ +import { type RoofSegmentNode, useRegistry } from '@pascal-app/core' +import { useRef } from 'react' +import type * as THREE from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' +import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' + +export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'roof-segment', ref) + + const handlers = useNodeEvents(node, 'roof-segment') + const debugColors = useViewer((s) => s.debugColors) + + return ( + + {/* RoofSystem will replace this geometry in the next frame */} + + + ) +} diff --git a/packages/viewer/src/components/renderers/roof/roof-materials.ts b/packages/viewer/src/components/renderers/roof/roof-materials.ts new file mode 100644 index 00000000..ff5b332b --- /dev/null +++ b/packages/viewer/src/components/renderers/roof/roof-materials.ts @@ -0,0 +1,18 @@ +import * as THREE from 'three' + +// Production materials — match the rest of the scene (white walls, light-gray slabs). +// Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle +export const roofMaterials: THREE.Material[] = [ + new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 0: Wall/Trim + new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 1, side: THREE.FrontSide }), // 1: Deck + new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 2: Interior + new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle +] + +// Debug materials — vivid, distinct colours to identify each surface group. +export const roofDebugMaterials: THREE.Material[] = [ + new THREE.MeshStandardMaterial({ color: '#eaeaea', roughness: 0.8, side: THREE.DoubleSide }), // 0: Wall + new THREE.MeshStandardMaterial({ color: '#000000', roughness: 0.9, side: THREE.FrontSide }), // 1: Deck + new THREE.MeshStandardMaterial({ color: '#dddddd', roughness: 0.9, side: THREE.DoubleSide }), // 2: Interior + new THREE.MeshStandardMaterial({ color: '#4ade80', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle +] diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 43f3ef48..01cff317 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -1,28 +1,40 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' import { useRef } from 'react' -import type { Mesh } from 'three' +import type * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' +import { NodeRenderer } from '../node-renderer' +import { roofDebugMaterials, roofMaterials } from './roof-materials' export const RoofRenderer = ({ node }: { node: RoofNode }) => { - const ref = useRef(null!) + const ref = useRef(null!) useRegistry(node.id, 'roof', ref) const handlers = useNodeEvents(node, 'roof') + const debugColors = useViewer((s) => s.debugColors) return ( - - {/* RoofSystem will replace this geometry in the next frame */} - - - + + + + + {(node.children ?? []).map((childId) => ( + + ))} + + ) } diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index 0d783942..2661efe5 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -19,10 +19,10 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom // Create a simple line loop at ground level for (const [x, z] of points) { - positions.push(x!, Y_OFFSET, z!) + positions.push(x ?? 0, Y_OFFSET, z ?? 0) } // Close the loop - positions.push(points[0]![0]!, Y_OFFSET, points[0]![1]!) + positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0) geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 97e3ccde..572c1805 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -10,11 +10,12 @@ import { WindowSystem, } from '@pascal-app/core' import { Bvh } from '@react-three/drei' -import { Canvas, extend, type ThreeToJSXElements, useFrame } from '@react-three/fiber' -import { useMemo, useRef } from 'react' +import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' import useViewer from '../../store/use-viewer' import { GuideSystem } from '../../systems/guide/guide-system' +import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { LevelSystem } from '../../systems/level/level-system' import { ScanSystem } from '../../systems/scan/scan-system' import { WallCutout } from '../../systems/wall/wall-cutout' @@ -22,6 +23,7 @@ import { ZoneSystem } from '../../systems/zone/zone-system' import { SceneRenderer } from '../renderers/scene-renderer' import { GroundOccluder } from './ground-occluder' import { Lights } from './lights' +import { PerfMonitor } from './perf-monitor' import PostProcessing from './post-processing' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' @@ -59,12 +61,44 @@ declare module '@react-three/fiber' { extend(THREE as any) +/** + * Monitors the WebGPU device for loss events and logs them. + * WebGPU device loss can happen when: + * - Tab is backgrounded and OS reclaims GPU + * - Driver crash or GPU reset + * - Browser security policy kills the context + */ +function GPUDeviceWatcher() { + const gl = useThree((s) => s.gl) + + useEffect(() => { + const backend = (gl as any).backend + const device: GPUDevice | undefined = backend?.device + + if (!device) return + + device.lost.then((info) => { + console.error( + `[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` + + 'The page must be reloaded to recover the GPU context.', + ) + }) + }, [gl]) + + return null +} + interface ViewerProps { children?: React.ReactNode selectionManager?: 'default' | 'custom' + perf?: boolean } -const Viewer: React.FC = ({ children, selectionManager = 'default' }) => { +const Viewer: React.FC = ({ + children, + selectionManager = 'default', + perf = false, +}) => { const theme = useViewer((state) => state.theme) return ( @@ -72,11 +106,10 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' camera={{ position: [50, 50, 50], fov: 50 }} className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`} dpr={[1, 1.5]} - gl={async (props) => { + gl={(props) => { const renderer = new THREE.WebGPURenderer(props as any) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 0.9 - await renderer.init() return renderer }} shadows={{ @@ -110,11 +143,22 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' + {/* */} + + {selectionManager === 'default' && } + {perf && } {children} ) } +const DebugRenderer = () => { + useFrame(({ gl, scene, camera }) => { + gl.render(scene, camera) + }) + return null +} + export default Viewer diff --git a/packages/viewer/src/components/viewer/perf-monitor.tsx b/packages/viewer/src/components/viewer/perf-monitor.tsx new file mode 100644 index 00000000..d94d098b --- /dev/null +++ b/packages/viewer/src/components/viewer/perf-monitor.tsx @@ -0,0 +1,60 @@ +import { useScene } from '@pascal-app/core' +import { Html } from '@react-three/drei' +import { useFrame } from '@react-three/fiber' +import { useRef, useState } from 'react' + +const SAMPLE_INTERVAL = 0.5 // seconds between display updates + +export const PerfMonitor = () => { + const [stats, setStats] = useState({ fps: 0, frameMs: 0, drawCalls: 0, triangles: 0, dirty: 0 }) + const frameCount = useRef(0) + const elapsed = useRef(0) + const lastMs = useRef(0) + + useFrame(({ gl, clock }) => { + frameCount.current++ + const now = clock.elapsedTime + const dt = now - elapsed.current + + if (dt >= SAMPLE_INTERVAL) { + const fps = Math.round(frameCount.current / dt) + const frameMs = lastMs.current + const info = gl.info + const drawCalls = info.render?.calls ?? 0 + const triangles = info.render?.triangles ?? 0 + const dirty = useScene.getState().dirtyNodes.size + + setStats({ fps, frameMs, drawCalls, triangles, dirty }) + frameCount.current = 0 + elapsed.current = now + } + + lastMs.current = Math.round(clock.getDelta() * 1000 * 10) / 10 + }) + + return ( + +
+ {`FPS ${stats.fps} +DRAW ${stats.drawCalls} +TRI ${(stats.triangles / 1000).toFixed(1)}k +DIRTY ${stats.dirty}`} +
+ + ) +} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 4e758d5a..4f6fdc4f 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,9 +1,10 @@ import { useFrame, useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Color, Layers, UnsignedByteType } from 'three' import { outline } from 'three/addons/tsl/display/OutlineNode.js' import { ssgi } from 'three/addons/tsl/display/SSGINode.js' import { traa } from 'three/addons/tsl/display/TRAANode.js' +import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { add, colorToDirection, @@ -22,7 +23,6 @@ import { vec4, velocity, } from 'three/tsl' - import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' @@ -30,19 +30,22 @@ import useViewer from '../../store/use-viewer' // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 2, - stepCount: 8, + sliceCount: 1, + stepCount: 4, radius: 1, expFactor: 1.5, thickness: 0.5, backfaceLighting: 0.5, aoIntensity: 1.5, - giIntensity: 0.5, + giIntensity: 0, useLinearThickness: false, useScreenSpaceSampling: true, - useTemporalFiltering: true, + useTemporalFiltering: false, } +const MAX_PIPELINE_RETRIES = 3 +const RETRY_DELAY_MS = 500 + const DARK_BG = '#1f2433' const LIGHT_BG = '#ffffff' @@ -50,6 +53,7 @@ const PostProcessingPasses = () => { const { gl: renderer, scene, camera } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) + const retryCountRef = useRef(0) const [isInitialized, setIsInitialized] = useState(false) // Background color uniform — updated every frame via lerp, read by the TSL pipeline. @@ -66,6 +70,18 @@ const PostProcessingPasses = () => { return l }, []) + // Subscribe to projectId so the pipeline rebuilds on project switch + const projectId = useViewer((s) => s.projectId) + + // Bump this to force a pipeline rebuild (used by retry logic) + const [pipelineVersion, setPipelineVersion] = useState(0) + + const requestPipelineRebuild = useCallback(() => { + setPipelineVersion((v) => v + 1) + }, []) + + // Renderer initialization + useEffect(() => { let mounted = true @@ -93,6 +109,12 @@ const PostProcessingPasses = () => { } }, [renderer]) + // Reset retry count when project changes + useEffect(() => { + retryCountRef.current = 0 + }, []) + + // Build / rebuild the post-processing pipeline useEffect(() => { if (!(renderer && scene && camera && isInitialized)) { return @@ -100,6 +122,12 @@ const PostProcessingPasses = () => { hasPipelineErrorRef.current = false + // Clear outliner arrays synchronously to prevent stale Object3D refs + // from the previous project leaking into the new pipeline's outline passes. + const outliner = useViewer.getState().outliner + outliner.selectedObjects.length = 0 + outliner.hoveredObjects.length = 0 + try { // Scene pass with MRT for SSGI const scenePass = pass(scene, camera) @@ -148,9 +176,20 @@ const PostProcessingPasses = () => { giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering - // Extract GI and AO from SSGI pass + const giTexture = (giPass as any).getTextureNode() + + // DenoiseNode only denoises RGB — alpha is passed through unchanged. + // SSGI packs AO into alpha, so we remap it into RGB before denoising. + // convertToTexture() inside denoise() will call rtt() on this vec4 node automatically. + const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1)) + const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera) + denoisePass.index.value = 0 + denoisePass.radius.value = 4 + const gi = giPass.rgb - const ao = giPass.a + const ao = (denoisePass as any).r + // const gi = giPass.rgb; + // const ao = giPass.a; // Background detection via alpha: renderer clears with alpha=0 (setClearAlpha(0) in useFrame), // so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1. @@ -272,12 +311,24 @@ const PostProcessingPasses = () => { renderPipelineRef.current.render() } catch (error) { hasPipelineErrorRef.current = true - console.error( - '[viewer] Post-processing render pass failed. Disabling post FX for this session.', - error, - ) - renderPipelineRef.current.dispose() + console.error('[viewer] Post-processing render pass failed.', error) + if (renderPipelineRef.current) { + renderPipelineRef.current.dispose() + } renderPipelineRef.current = null + + if (retryCountRef.current < MAX_PIPELINE_RETRIES) { + // Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit + retryCountRef.current++ + console.warn( + `[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`, + ) + setTimeout(requestPipelineRebuild, RETRY_DELAY_MS) + } else { + console.error( + '[viewer] Post-processing retries exhausted. Rendering without post FX for this session.', + ) + } } }, 1) diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 49ceba0c..9174d02d 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -2,6 +2,7 @@ import { type AnyNode, + type AnyNodeId, type BuildingNode, emitter, type ItemNode, @@ -34,6 +35,7 @@ type SelectableNodeType = | 'slab' | 'ceiling' | 'roof' + | 'roof-segment' // Expand polygon outward by a small amount to include items on edges const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => { @@ -150,7 +152,7 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = return false } - if (node.type === 'roof') { + if (node.type === 'roof' || node.type === 'roof-segment') { // Roofs on the same level are valid when zone is selected return true } @@ -219,12 +221,20 @@ const getStrategy = (): SelectionStrategy | null => { // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors) return { - types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'], + types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'], handleClick: (node, nativeEvent) => { + let nodeToSelect = node + if (node.type === 'roof-segment' && node.parentId) { + const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] + if (parentNode && parentNode.type === 'roof') { + nodeToSelect = parentNode + } + } + const { selectedIds } = useViewer.getState().selection useViewer .getState() - .setSelection({ selectedIds: computeNextIds(node, selectedIds, nativeEvent) }) + .setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) }) }, handleDeselect: () => { const { selectedIds } = useViewer.getState().selection @@ -236,7 +246,16 @@ const getStrategy = (): SelectionStrategy | null => { } }, isValid: (node) => { - const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'] + const validTypes = [ + 'wall', + 'item', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'window', + 'door', + ] if (!validTypes.includes(node.type)) return false return isNodeInZone(node, levelId, zoneId) }, @@ -288,6 +307,7 @@ export const SelectionManager = () => { 'slab', 'ceiling', 'roof', + 'roof-segment', 'window', 'door', ] diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index f619707b..0a1aa298 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -13,6 +13,8 @@ import { type LevelNode, type RoofEvent, type RoofNode, + type RoofSegmentEvent, + type RoofSegmentNode, type SiteEvent, type SiteNode, type SlabEvent, @@ -37,6 +39,7 @@ type NodeConfig = { slab: { node: SlabNode; event: SlabEvent } ceiling: { node: CeilingNode; event: CeilingEvent } roof: { node: RoofNode; event: RoofEvent } + 'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent } window: { node: WindowNode; event: WindowEvent } door: { node: DoorNode; event: DoorEvent } } diff --git a/packages/viewer/src/r3f.d.ts b/packages/viewer/src/r3f.d.ts new file mode 100644 index 00000000..c162d161 --- /dev/null +++ b/packages/viewer/src/r3f.d.ts @@ -0,0 +1,8 @@ +// Augment @react-three/fiber's ThreeElements to include all Three.js JSX intrinsic elements. +// This must be a project-wide declaration so all files can use , etc. +import type { ThreeToJSXElements } from '@react-three/fiber' +import * as THREE from 'three/webgpu' + +declare module '@react-three/fiber' { + interface ThreeElements extends ThreeToJSXElements {} +} diff --git a/packages/viewer/src/store/use-item-light-pool.ts b/packages/viewer/src/store/use-item-light-pool.ts new file mode 100644 index 00000000..3b6e8e41 --- /dev/null +++ b/packages/viewer/src/store/use-item-light-pool.ts @@ -0,0 +1,53 @@ +import type { AnyNodeId, Interactive, LightEffect, SliderControl } from '@pascal-app/core' +import { create } from 'zustand' + +export type LightRegistration = { + nodeId: AnyNodeId + effect: LightEffect + toggleIndex: number + sliderIndex: number + sliderMin: number + sliderMax: number + hasSlider: boolean +} + +type ItemLightPoolStore = { + registrations: Map + register: (key: string, nodeId: AnyNodeId, effect: LightEffect, interactive: Interactive) => void + unregister: (key: string) => void +} + +export const useItemLightPool = create((set) => ({ + registrations: new Map(), + + register: (key, nodeId, effect, interactive) => { + const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle') + const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider') + const sliderControl = + sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null + + const registration: LightRegistration = { + nodeId, + effect, + toggleIndex, + sliderIndex, + hasSlider: sliderControl !== null, + sliderMin: sliderControl?.min ?? 0, + sliderMax: sliderControl?.max ?? 1, + } + + set((s) => { + const next = new Map(s.registrations) + next.set(key, registration) + return { registrations: next } + }) + }, + + unregister: (key) => { + set((s) => { + const next = new Map(s.registrations) + next.delete(key) + return { registrations: next } + }) + }, +})) diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index e649b77c..a0f2b65d 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -61,6 +61,9 @@ type ViewerState = { exportScene: (() => Promise) | null setExportScene: (fn: (() => Promise) | null) => void + debugColors: boolean + setDebugColors: (enabled: boolean) => void + cameraDragging: boolean setCameraDragging: (dragging: boolean) => void } @@ -173,6 +176,9 @@ const useViewer = create()( exportScene: null, setExportScene: (fn) => set({ exportScene: fn }), + debugColors: false, + setDebugColors: (enabled) => set({ debugColors: enabled }), + cameraDragging: false, setCameraDragging: (dragging) => set({ cameraDragging: dragging }), }), diff --git a/packages/viewer/src/systems/item-light/item-light-system.tsx b/packages/viewer/src/systems/item-light/item-light-system.tsx new file mode 100644 index 00000000..684ec64f --- /dev/null +++ b/packages/viewer/src/systems/item-light/item-light-system.tsx @@ -0,0 +1,296 @@ +import type { AnyNodeId, LevelNode } from '@pascal-app/core' +import { sceneRegistry, useInteractive, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useRef } from 'react' +import { MathUtils, type PointLight, Vector3 } from 'three' +import { useItemLightPool } from '../../store/use-item-light-pool' +import useViewer from '../../store/use-viewer' + +const POOL_SIZE = 12 +// How often (in seconds) to re-evaluate which items have lights assigned (fallback timer) +const REASSIGN_INTERVAL = 0.2 + +// Hysteresis: a currently-assigned slot keeps its key unless an unassigned +// candidate beats it by at least this much (prevents flickering at the boundary) +const HYSTERESIS = 0.15 + +// Camera movement thresholds that trigger an early re-evaluation +const CAM_MOVE_DIST = 0.5 // units +const CAM_ROT_DOT = 0.995 // cos(~5.7°) + +type SlotRuntime = { + // The key currently driving this slot (null = idle) + key: string | null + // A pending reassignment waiting for the fade-out to finish + pendingKey: string | null + isFadingOut: boolean +} + +// Module-level temp vectors reused every frame (avoids GC pressure) +const _dir = new Vector3() +const _camPos = new Vector3() +const _camFwd = new Vector3() +const _itemPos = new Vector3() + +type SceneNodes = ReturnType['nodes'] +type InteractiveState = ReturnType + +function scoreRegistration( + reg: import('../../store/use-item-light-pool').LightRegistration, + nodes: SceneNodes, + selectedLevelId: string | null, + levelMode: string, + interactiveState: InteractiveState, +): number { + // Skip lights that are toggled off — they contribute no illumination + if (reg.toggleIndex >= 0) { + const values = interactiveState.items[reg.nodeId]?.controlValues + const isOn = Boolean(values?.[reg.toggleIndex]) + if (!isOn) return Number.POSITIVE_INFINITY + } + + const { nodeId, effect } = reg + const obj = sceneRegistry.nodes.get(nodeId) + if (!obj) return Number.POSITIVE_INFINITY + + obj.getWorldPosition(_itemPos) + _itemPos.x += effect.offset[0] + _itemPos.y += effect.offset[1] + _itemPos.z += effect.offset[2] + + _dir.copy(_itemPos).sub(_camPos).normalize() + const dot = _camFwd.dot(_dir) // 1 = ahead, -1 = behind + + // Angular component (0 = dead ahead, 2 = directly behind) + const angular = 1 - dot + // Normalised distance component (assumes scenes < 200 units) + const dist = _camPos.distanceTo(_itemPos) / 200 + + // ── Level factor ────────────────────────────────────────────────────────── + const node = nodes[nodeId] + const itemLevelId = node?.parentId ?? null + + let levelPenalty = 0 + if (selectedLevelId) { + if (itemLevelId !== selectedLevelId) { + // In solo mode items on other levels are invisible — deprioritize strongly + levelPenalty = levelMode === 'solo' ? 100 : 0.8 + } + } else if (itemLevelId) { + // No level selected — lightly prefer items on level index 0 + const levelNode = nodes[itemLevelId as AnyNodeId] as LevelNode | undefined + const levelIndex = levelNode?.level ?? 0 + if (levelIndex !== 0) levelPenalty = 0.3 + } + + return angular * 0.7 + dist * 0.3 + levelPenalty +} + +export function ItemLightSystem() { + const lightRefs = useRef>(Array.from({ length: POOL_SIZE }, () => null)) + const slots = useRef( + Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })), + ) + const reassignTimer = useRef(0) + + // Track camera state at last reassignment to detect meaningful movement + const prevReassignCamPos = useRef(new Vector3()) + const prevReassignCamFwd = useRef(new Vector3(0, 0, -1)) + + useFrame(({ camera }, delta) => { + const dt = Math.min(delta, 0.1) + const { registrations } = useItemLightPool.getState() + const interactiveState = useInteractive.getState() + + // ── 1. Throttled priority reassignment ────────────────────────────────── + camera.getWorldPosition(_camPos) + camera.getWorldDirection(_camFwd) + + const camMoved = + _camPos.distanceTo(prevReassignCamPos.current) > CAM_MOVE_DIST || + _camFwd.dot(prevReassignCamFwd.current) < CAM_ROT_DOT + + reassignTimer.current -= delta + const shouldReassign = reassignTimer.current <= 0 || camMoved + + if (shouldReassign) { + reassignTimer.current = REASSIGN_INTERVAL + prevReassignCamPos.current.copy(_camPos) + prevReassignCamFwd.current.copy(_camFwd) + + // Read level/scene state once for the whole tick + const nodes = useScene.getState().nodes + const viewerState = useViewer.getState() + const selectedLevelId = viewerState.selection.levelId + const levelMode = viewerState.levelMode + + // Score every registration + const scored: Array<{ key: string; score: number }> = [] + for (const [key, reg] of registrations) { + scored.push({ + key, + score: scoreRegistration(reg, nodes, selectedLevelId, levelMode, interactiveState), + }) + } + scored.sort((a, b) => a.score - b.score) + + // Build the desired assignment (top POOL_SIZE keys) + const desired = scored.slice(0, POOL_SIZE).map((s) => s.key) + + // Build a map of currently-assigned keys → slot index for hysteresis + const currentlyAssigned = new Map() + for (let i = 0; i < POOL_SIZE; i++) { + const s = slots.current[i] + if (!s) continue + const k = s.key ?? s.pendingKey + if (k) currentlyAssigned.set(k, i) + } + + // Assign desired keys to slots — prefer keeping existing assignments + const usedSlots = new Set() + const assignedKeys = new Set() + + // Pass 1: keep existing slots where the key is still in desired + for (const key of desired) { + const existingSlot = currentlyAssigned.get(key) + if (existingSlot !== undefined && !usedSlots.has(existingSlot)) { + usedSlots.add(existingSlot) + assignedKeys.add(key) + } + } + + // Pass 2: assign remaining desired keys to free slots + let freeSlot = 0 + for (const key of desired) { + if (assignedKeys.has(key)) continue + while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++ + if (freeSlot >= POOL_SIZE) break + + // Hysteresis: only evict the current occupant if the new key scores + // meaningfully better than it + const freeSlotData = slots.current[freeSlot] + const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null + if (currentKey && !desired.includes(currentKey)) { + const currentScore = + scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY + const newScore = scored.find((s) => s.key === key)?.score ?? 0 + if (currentScore - newScore < HYSTERESIS) { + freeSlot++ + continue + } + } + + usedSlots.add(freeSlot) + assignedKeys.add(key) + + const slot = slots.current[freeSlot] + if (slot && slot.key !== key) { + slot.pendingKey = key + slot.isFadingOut = slot.key !== null + if (!slot.isFadingOut) { + // Slot was idle — skip fade-out, assign immediately + slot.key = key + slot.pendingKey = null + const light = lightRefs.current[freeSlot] + const reg = registrations.get(key) + if (light && reg) { + light.color.set(reg.effect.color) + light.distance = reg.effect.distance ?? 0 + } + } + } + freeSlot++ + } + + // Clear slots whose key is no longer in desired and not pending + for (let i = 0; i < POOL_SIZE; i++) { + if (!usedSlots.has(i)) { + const slot = slots.current[i] + if (slot?.key && !desired.includes(slot.key)) { + slot.pendingKey = null + slot.isFadingOut = true + } + } + } + } + + // ── 2. Per-frame light updates ─────────────────────────────────────────── + for (let i = 0; i < POOL_SIZE; i++) { + const light = lightRefs.current[i] + if (!light) continue + + const slot = slots.current[i] + if (!slot) continue + + // Fade-out phase: lerp intensity → 0, then complete the transition + if (slot.isFadingOut) { + light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) + if (light.intensity < 0.01) { + light.intensity = 0 + slot.isFadingOut = false + slot.key = slot.pendingKey + slot.pendingKey = null + + if (slot.key) { + const reg = registrations.get(slot.key) + if (reg) { + light.color.set(reg.effect.color) + light.distance = reg.effect.distance ?? 0 + } + } + } + continue + } + + if (!slot.key) { + // Idle slot — keep dark + light.intensity = 0 + continue + } + + const reg = registrations.get(slot.key) + if (!reg) { + slot.key = null + light.intensity = 0 + continue + } + + // Snap world position each frame + const obj = sceneRegistry.nodes.get(reg.nodeId) + if (obj) { + obj.getWorldPosition(_itemPos) + const [ox, oy, oz] = reg.effect.offset + light.position.set(_itemPos.x + ox, _itemPos.y + oy, _itemPos.z + oz) + } + + // Compute target intensity + const values = interactiveState.items[reg.nodeId]?.controlValues + const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true + let t = 1 + if (reg.hasSlider) { + const raw = (values?.[reg.sliderIndex] as number) ?? reg.sliderMin + t = (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin) + } + const targetIntensity = isOn + ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t) + : reg.effect.intensityRange[0] + + light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) + } + }) + + return ( + <> + {Array.from({ length: POOL_SIZE }, (_, i) => ( + { + lightRefs.current[i] = el + }} + /> + ))} + + ) +} diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 165038b2..22e69480 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -98,11 +98,9 @@ export const WallCutout = () => { if (wallNode.frontSide === 'exterior' && wallNode.backSide !== 'exterior') { hideWall = true } - } else { + } else if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') { // Back side - if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') { - hideWall = true - } + hideWall = true } } ;(wallMesh as Mesh).material = hideWall ? invsibleWallMaterial : wallMaterial