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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bafc5973a3
commit
1d28e4208f
@@ -7,6 +7,7 @@ import type {
|
|||||||
ItemNode,
|
ItemNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
|
RoofSegmentNode,
|
||||||
SiteNode,
|
SiteNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
@@ -39,6 +40,7 @@ export type ZoneEvent = NodeEvent<ZoneNode>
|
|||||||
export type SlabEvent = NodeEvent<SlabNode>
|
export type SlabEvent = NodeEvent<SlabNode>
|
||||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||||
export type RoofEvent = NodeEvent<RoofNode>
|
export type RoofEvent = NodeEvent<RoofNode>
|
||||||
|
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
||||||
export type WindowEvent = NodeEvent<WindowNode>
|
export type WindowEvent = NodeEvent<WindowNode>
|
||||||
export type DoorEvent = NodeEvent<DoorNode>
|
export type DoorEvent = NodeEvent<DoorNode>
|
||||||
|
|
||||||
@@ -100,6 +102,7 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'slab', SlabEvent> &
|
NodeEvents<'slab', SlabEvent> &
|
||||||
NodeEvents<'ceiling', CeilingEvent> &
|
NodeEvents<'ceiling', CeilingEvent> &
|
||||||
NodeEvents<'roof', RoofEvent> &
|
NodeEvents<'roof', RoofEvent> &
|
||||||
|
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
||||||
NodeEvents<'window', WindowEvent> &
|
NodeEvents<'window', WindowEvent> &
|
||||||
NodeEvents<'door', DoorEvent> &
|
NodeEvents<'door', DoorEvent> &
|
||||||
CameraControlEvents &
|
CameraControlEvents &
|
||||||
|
|||||||
@@ -17,11 +17,20 @@ export const sceneRegistry = {
|
|||||||
slab: new Set<string>(),
|
slab: new Set<string>(),
|
||||||
zone: new Set<string>(),
|
zone: new Set<string>(),
|
||||||
roof: new Set<string>(),
|
roof: new Set<string>(),
|
||||||
|
'roof-segment': new Set<string>(),
|
||||||
scan: new Set<string>(),
|
scan: new Set<string>(),
|
||||||
guide: new Set<string>(),
|
guide: new Set<string>(),
|
||||||
window: new Set<string>(),
|
window: new Set<string>(),
|
||||||
door: new Set<string>(),
|
door: new Set<string>(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 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(
|
export function useRegistry(
|
||||||
|
|||||||
@@ -273,15 +273,19 @@ export function wallOverlapsPolygon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SpatialGridManager {
|
export class SpatialGridManager {
|
||||||
private floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
|
private readonly floorGrids = new Map<string, SpatialGrid>() // levelId -> grid
|
||||||
private wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
|
private readonly wallGrids = new Map<string, WallSpatialGrid>() // levelId -> wall grid
|
||||||
private walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)
|
private readonly walls = new Map<string, WallNode>() // wallId -> wall data (for length calculations)
|
||||||
private slabsByLevel = new Map<string, Map<string, SlabNode>>() // levelId -> (slabId -> slab)
|
private readonly slabsByLevel = new Map<string, Map<string, SlabNode>>() // levelId -> (slabId -> slab)
|
||||||
private ceilingGrids = new Map<string, SpatialGrid>() // ceilingId -> grid
|
private readonly ceilingGrids = new Map<string, SpatialGrid>() // ceilingId -> grid
|
||||||
private ceilings = new Map<string, CeilingNode>() // ceilingId -> ceiling data
|
private readonly ceilings = new Map<string, CeilingNode>() // ceilingId -> ceiling data
|
||||||
private itemCeilingMap = new Map<string, string>() // itemId -> ceilingId (reverse lookup)
|
private readonly itemCeilingMap = new Map<string, string>() // 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 {
|
private getFloorGrid(levelId: string): SpatialGrid {
|
||||||
if (!this.floorGrids.has(levelId)) {
|
if (!this.floorGrids.has(levelId)) {
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ interface SpatialGridConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SpatialGrid {
|
export class SpatialGrid {
|
||||||
private cells = new Map<CellKey, GridCell>()
|
private readonly cells = new Map<CellKey, GridCell>()
|
||||||
private itemCells = new Map<string, Set<CellKey>>() // reverse lookup
|
private readonly itemCells = new Map<string, Set<CellKey>>() // reverse lookup
|
||||||
|
|
||||||
constructor(private config: SpatialGridConfig) {}
|
private readonly config: SpatialGridConfig
|
||||||
|
|
||||||
|
constructor(config: SpatialGridConfig) {
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
private posToCell(x: number, z: number): [number, number] {
|
private posToCell(x: number, z: number): [number, number] {
|
||||||
return [Math.floor(x / this.config.cellSize), Math.floor(z / this.config.cellSize)]
|
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)) {
|
if (!this.cells.has(key)) {
|
||||||
this.cells.set(key, { itemIds: new Set() })
|
this.cells.set(key, { itemIds: new Set() })
|
||||||
}
|
}
|
||||||
this.cells.get(key)!.itemIds.add(itemId)
|
this.cells.get(key)?.itemIds.add(itemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ function autoAdjustYPosition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class WallSpatialGrid {
|
export class WallSpatialGrid {
|
||||||
private wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
|
private readonly wallItems = new Map<string, WallItemPlacement[]>() // wallId -> placements
|
||||||
private itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
|
private readonly itemToWall = new Map<string, string>() // itemId -> wallId (reverse lookup)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an item can be placed on a wall with auto-adjustment for vertical position
|
* 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)) {
|
if (!this.wallItems.has(wallId)) {
|
||||||
this.wallItems.set(wallId, [])
|
this.wallItems.set(wallId, [])
|
||||||
}
|
}
|
||||||
this.wallItems.get(wallId)!.push(placement)
|
this.wallItems.get(wallId)?.push(placement)
|
||||||
this.itemToWall.set(itemId, wallId)
|
this.itemToWall.set(itemId, wallId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type {
|
|||||||
LevelEvent,
|
LevelEvent,
|
||||||
NodeEvent,
|
NodeEvent,
|
||||||
RoofEvent,
|
RoofEvent,
|
||||||
|
RoofSegmentEvent,
|
||||||
SiteEvent,
|
SiteEvent,
|
||||||
SlabEvent,
|
SlabEvent,
|
||||||
WallEvent,
|
WallEvent,
|
||||||
@@ -46,7 +47,7 @@ export {
|
|||||||
type ItemInteractiveState,
|
type ItemInteractiveState,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
} from './store/use-interactive'
|
} from './store/use-interactive'
|
||||||
export { default as useScene } from './store/use-scene'
|
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||||
// Systems
|
// Systems
|
||||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||||
export { DoorSystem } from './systems/door/door-system'
|
export { DoorSystem } from './systems/door/door-system'
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function initSpaceDetectionSync(
|
|||||||
if (!currentWallsByLevel.has(levelId)) {
|
if (!currentWallsByLevel.has(levelId)) {
|
||||||
currentWallsByLevel.set(levelId, new Set())
|
currentWallsByLevel.set(levelId, new Set())
|
||||||
}
|
}
|
||||||
currentWallsByLevel.get(levelId)!.add((node as any).id)
|
currentWallsByLevel.get(levelId)?.add((node as any).id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type {
|
|||||||
export { getScaledDimensions, ItemNode } from './nodes/item'
|
export { getScaledDimensions, ItemNode } from './nodes/item'
|
||||||
export { LevelNode } from './nodes/level'
|
export { LevelNode } from './nodes/level'
|
||||||
export { RoofNode } from './nodes/roof'
|
export { RoofNode } from './nodes/roof'
|
||||||
|
export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
|
||||||
export { ScanNode } from './nodes/scan'
|
export { ScanNode } from './nodes/scan'
|
||||||
// Nodes
|
// Nodes
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
|
|||||||
@@ -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<typeof RoofType>
|
||||||
|
|
||||||
|
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<typeof RoofSegmentNode>
|
||||||
@@ -1,32 +1,25 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { RoofSegmentNode } from './roof-segment'
|
||||||
|
|
||||||
export const RoofNode = BaseNode.extend({
|
export const RoofNode = BaseNode.extend({
|
||||||
id: objectId('roof'),
|
id: objectId('roof'),
|
||||||
type: nodeType('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]),
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
// Rotation around Y axis in radians
|
// Rotation around Y axis in radians
|
||||||
rotation: z.number().default(0),
|
rotation: z.number().default(0),
|
||||||
// Length of the roof along the ridge direction (in meters)
|
// Child roof segment IDs
|
||||||
length: z.number().default(4),
|
children: z.array(RoofSegmentNode.shape.id).default([]),
|
||||||
// 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),
|
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Roof node - used to represent a gable roof in the building
|
Roof node - a container for roof segments.
|
||||||
- position: center position of the roof (Y typically 0)
|
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
|
- rotation: rotation around Y axis
|
||||||
- length: length of the roof along the ridge
|
- children: array of RoofSegmentNode IDs
|
||||||
- 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
|
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { GuideNode } from './nodes/guide'
|
|||||||
import { ItemNode } from './nodes/item'
|
import { ItemNode } from './nodes/item'
|
||||||
import { LevelNode } from './nodes/level'
|
import { LevelNode } from './nodes/level'
|
||||||
import { RoofNode } from './nodes/roof'
|
import { RoofNode } from './nodes/roof'
|
||||||
|
import { RoofSegmentNode } from './nodes/roof-segment'
|
||||||
import { ScanNode } from './nodes/scan'
|
import { ScanNode } from './nodes/scan'
|
||||||
import { SiteNode } from './nodes/site'
|
import { SiteNode } from './nodes/site'
|
||||||
import { SlabNode } from './nodes/slab'
|
import { SlabNode } from './nodes/slab'
|
||||||
@@ -23,6 +24,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
SlabNode,
|
SlabNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
|
RoofSegmentNode,
|
||||||
ScanNode,
|
ScanNode,
|
||||||
GuideNode,
|
GuideNode,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
|
|||||||
@@ -164,9 +164,15 @@ export const deleteNodesAction = (
|
|||||||
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
|
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)
|
// Mark affected nodes dirty: parents of deleted nodes and their remaining children
|
||||||
const currentNodes = get().nodes
|
// (e.g. deleting a slab affects sibling walls via level elevation changes)
|
||||||
Object.values(currentNodes).forEach((node) => {
|
parentsToMarkDirty.forEach((parentId) => {
|
||||||
get().markDirty(node.id)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,57 @@
|
|||||||
import type { TemporalState } from 'zundo'
|
import type { TemporalState } from 'zundo'
|
||||||
import { temporal } from 'zundo'
|
import { temporal } from 'zundo'
|
||||||
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
|
||||||
import { BuildingNode } from '../schema'
|
import { BuildingNode } from '../schema'
|
||||||
import type { Collection, CollectionId } from '../schema/collections'
|
import type { Collection, CollectionId } from '../schema/collections'
|
||||||
import { generateCollectionId } from '../schema/collections'
|
import { generateCollectionId } from '../schema/collections'
|
||||||
import { LevelNode } from '../schema/nodes/level'
|
import { LevelNode } from '../schema/nodes/level'
|
||||||
import { SiteNode } from '../schema/nodes/site'
|
import { SiteNode } from '../schema/nodes/site'
|
||||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
import { isObject } from '../utils/types'
|
|
||||||
import * as nodeActions from './actions/node-actions'
|
import * as nodeActions from './actions/node-actions'
|
||||||
|
|
||||||
|
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||||
|
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<string, AnyNode>
|
||||||
|
}
|
||||||
|
|
||||||
export type SceneState = {
|
export type SceneState = {
|
||||||
// 1. The Data: A flat dictionary of all nodes
|
// 1. The Data: A flat dictionary of all nodes
|
||||||
nodes: Record<AnyNodeId, AnyNode>
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
@@ -29,6 +70,7 @@ export type SceneState = {
|
|||||||
// Actions
|
// Actions
|
||||||
loadScene: () => void
|
loadScene: () => void
|
||||||
clearScene: () => void
|
clearScene: () => void
|
||||||
|
unloadScene: () => void
|
||||||
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
|
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
|
||||||
|
|
||||||
markDirty: (id: AnyNodeId) => void
|
markDirty: (id: AnyNodeId) => void
|
||||||
@@ -58,7 +100,6 @@ type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useScene: UseSceneStore = create<SceneState>()(
|
const useScene: UseSceneStore = create<SceneState>()(
|
||||||
persist(
|
|
||||||
temporal(
|
temporal(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
// 1. Flat dictionary of all nodes
|
// 1. Flat dictionary of all nodes
|
||||||
@@ -73,25 +114,24 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
// 4. Collections
|
// 4. Collections
|
||||||
collections: {} as Record<CollectionId, Collection>,
|
collections: {} as Record<CollectionId, Collection>,
|
||||||
|
|
||||||
clearScene: () => {
|
unloadScene: () => {
|
||||||
set({
|
set({
|
||||||
nodes: {},
|
nodes: {},
|
||||||
rootNodeIds: [],
|
rootNodeIds: [],
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
dirtyNodes: new Set<AnyNodeId>(),
|
||||||
collections: {},
|
collections: {},
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearScene: () => {
|
||||||
|
get().unloadScene()
|
||||||
get().loadScene() // Default scene
|
get().loadScene() // Default scene
|
||||||
},
|
},
|
||||||
|
|
||||||
setScene: (nodes, rootNodeIds) => {
|
setScene: (nodes, rootNodeIds) => {
|
||||||
// Backward compat: add default scale to item nodes loaded from external sources
|
// Apply backward compatibility migrations
|
||||||
// (pascal_local_projects, Supabase) saved before scale was added to ItemNode
|
const patchedNodes = migrateNodes(nodes)
|
||||||
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({
|
set({
|
||||||
nodes: patchedNodes,
|
nodes: patchedNodes,
|
||||||
rootNodeIds,
|
rootNodeIds,
|
||||||
@@ -148,8 +188,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
||||||
createNode: (node, parentId) =>
|
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
||||||
nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
|
||||||
|
|
||||||
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
||||||
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
||||||
@@ -257,91 +296,21 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
limit: 50, // Limit to last 50 actions
|
limit: 50, // Limit to last 50 actions
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
{
|
|
||||||
name: 'editor-storage',
|
|
||||||
version: 1,
|
|
||||||
// Keep existing local scenes when the persist version changes.
|
|
||||||
migrate: (persistedState) =>
|
|
||||||
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>,
|
|
||||||
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<SceneState>
|
|
||||||
// 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')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export default useScene
|
export default useScene
|
||||||
|
|
||||||
// Track previous temporal state lengths
|
// Track previous temporal state lengths and node snapshot for diffing
|
||||||
let prevPastLength = 0
|
let prevPastLength = 0
|
||||||
let prevFutureLength = 0
|
let prevFutureLength = 0
|
||||||
|
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||||
|
|
||||||
|
export function clearSceneHistory() {
|
||||||
|
useScene.temporal.getState().clear()
|
||||||
|
prevPastLength = 0
|
||||||
|
prevFutureLength = 0
|
||||||
|
prevNodesSnapshot = null
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to the temporal store (Undo/Redo events)
|
// Subscribe to the temporal store (Undo/Redo events)
|
||||||
useScene.temporal.subscribe((state) => {
|
useScene.temporal.subscribe((state) => {
|
||||||
@@ -354,18 +323,40 @@ useScene.temporal.subscribe((state) => {
|
|||||||
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
|
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
|
||||||
|
|
||||||
if (didUndo || didRedo) {
|
if (didUndo || didRedo) {
|
||||||
|
// Capture the previous snapshot before RAF fires
|
||||||
|
const snapshotBefore = prevNodesSnapshot
|
||||||
|
|
||||||
// Use RAF to ensure all middleware and store updates are complete
|
// Use RAF to ensure all middleware and store updates are complete
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const currentNodes = useScene.getState().nodes
|
const currentNodes = useScene.getState().nodes
|
||||||
|
const { markDirty } = useScene.getState()
|
||||||
|
|
||||||
// Trigger a full scene re-validation after undo/redo
|
if (snapshotBefore) {
|
||||||
Object.values(currentNodes).forEach((node) => {
|
// Diff: only mark nodes that actually changed
|
||||||
useScene.getState().markDirty(node.id)
|
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
|
prevPastLength = currentPastLength
|
||||||
prevFutureLength = currentFutureLength
|
prevFutureLength = currentFutureLength
|
||||||
|
prevNodesSnapshot = useScene.getState().nodes
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,12 +95,12 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
|
|||||||
if (!junctions.has(keyStart)) {
|
if (!junctions.has(keyStart)) {
|
||||||
junctions.set(keyStart, { meetingPoint: startPt, connectedWalls: [] })
|
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)) {
|
if (!junctions.has(keyEnd)) {
|
||||||
junctions.set(keyEnd, { meetingPoint: endPt, connectedWalls: [] })
|
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)
|
// Second pass: detect T-junctions (walls passing through junction points)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const WallSystem = () => {
|
|||||||
if (!dirtyWallsByLevel.has(levelId)) {
|
if (!dirtyWallsByLevel.has(levelId)) {
|
||||||
dirtyWallsByLevel.set(levelId, new Set())
|
dirtyWallsByLevel.set(levelId, new Set())
|
||||||
}
|
}
|
||||||
dirtyWallsByLevel.get(levelId)!.add(id)
|
dirtyWallsByLevel.get(levelId)?.add(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process each level that has dirty walls
|
// Process each level that has dirty walls
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type Interactive,
|
type Interactive,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LightEffect,
|
type LightEffect,
|
||||||
type SliderControl,
|
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -14,12 +13,13 @@ import { Clone } from '@react-three/drei/core/Clone'
|
|||||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
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 { MathUtils } from 'three'
|
||||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||||
|
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
// Shared materials to avoid creating new instances for every mesh
|
// Shared materials to avoid creating new instances for every mesh
|
||||||
@@ -167,7 +167,13 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{lightEffects.map((effect, i) => (
|
{lightEffects.map((effect, i) => (
|
||||||
<ItemLight effect={effect} interactive={interactive!} key={i} nodeId={node.id} />
|
<ItemLightRegistrar
|
||||||
|
effect={effect}
|
||||||
|
index={i}
|
||||||
|
interactive={interactive!}
|
||||||
|
key={i}
|
||||||
|
nodeId={node.id}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -245,54 +251,22 @@ const ItemAnimation = ({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ItemLight = ({
|
const ItemLightRegistrar = ({
|
||||||
nodeId,
|
nodeId,
|
||||||
effect,
|
effect,
|
||||||
interactive,
|
interactive,
|
||||||
|
index,
|
||||||
}: {
|
}: {
|
||||||
nodeId: AnyNodeId
|
nodeId: AnyNodeId
|
||||||
effect: LightEffect
|
effect: LightEffect
|
||||||
interactive: Interactive
|
interactive: Interactive
|
||||||
|
index: number
|
||||||
}) => {
|
}) => {
|
||||||
const lightRef = useRef<PointLight>(null!)
|
useEffect(() => {
|
||||||
// Precompute stable indices — interactive is frozen at mount
|
const key = `${nodeId}:${index}`
|
||||||
const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')
|
useItemLightPool.getState().register(key, nodeId, effect, interactive)
|
||||||
const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider')
|
return () => useItemLightPool.getState().unregister(key)
|
||||||
const sliderControl =
|
}, [nodeId, index, effect, interactive])
|
||||||
sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
return null
|
||||||
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 (
|
|
||||||
<pointLight
|
|
||||||
castShadow={false}
|
|
||||||
color={effect.color}
|
|
||||||
distance={effect.distance ?? 0}
|
|
||||||
intensity={effect.intensityRange[0]}
|
|
||||||
position={effect.offset}
|
|
||||||
ref={lightRef}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { GuideRenderer } from './guide/guide-renderer'
|
|||||||
import { ItemRenderer } from './item/item-renderer'
|
import { ItemRenderer } from './item/item-renderer'
|
||||||
import { LevelRenderer } from './level/level-renderer'
|
import { LevelRenderer } from './level/level-renderer'
|
||||||
import { RoofRenderer } from './roof/roof-renderer'
|
import { RoofRenderer } from './roof/roof-renderer'
|
||||||
|
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
|
||||||
import { ScanRenderer } from './scan/scan-renderer'
|
import { ScanRenderer } from './scan/scan-renderer'
|
||||||
import { SiteRenderer } from './site/site-renderer'
|
import { SiteRenderer } from './site/site-renderer'
|
||||||
import { SlabRenderer } from './slab/slab-renderer'
|
import { SlabRenderer } from './slab/slab-renderer'
|
||||||
@@ -33,6 +34,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'window' && <WindowRenderer node={node} />}
|
{node.type === 'window' && <WindowRenderer node={node} />}
|
||||||
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
||||||
{node.type === 'roof' && <RoofRenderer node={node} />}
|
{node.type === 'roof' && <RoofRenderer node={node} />}
|
||||||
|
{node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />}
|
||||||
{node.type === 'scan' && <ScanRenderer node={node} />}
|
{node.type === 'scan' && <ScanRenderer node={node} />}
|
||||||
{node.type === 'guide' && <GuideRenderer node={node} />}
|
{node.type === 'guide' && <GuideRenderer node={node} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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<THREE.Mesh>(null!)
|
||||||
|
|
||||||
|
useRegistry(node.id, 'roof-segment', ref)
|
||||||
|
|
||||||
|
const handlers = useNodeEvents(node, 'roof-segment')
|
||||||
|
const debugColors = useViewer((s) => s.debugColors)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
material={debugColors ? roofDebugMaterials : roofMaterials}
|
||||||
|
position={node.position}
|
||||||
|
ref={ref}
|
||||||
|
rotation-y={node.rotation}
|
||||||
|
visible={node.visible}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
|
{/* RoofSystem will replace this geometry in the next frame */}
|
||||||
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -1,28 +1,40 @@
|
|||||||
import { type RoofNode, useRegistry } from '@pascal-app/core'
|
import { type RoofNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import type { Mesh } from 'three'
|
import type * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
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 }) => {
|
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<THREE.Group>(null!)
|
||||||
|
|
||||||
useRegistry(node.id, 'roof', ref)
|
useRegistry(node.id, 'roof', ref)
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'roof')
|
const handlers = useNodeEvents(node, 'roof')
|
||||||
|
const debugColors = useViewer((s) => s.debugColors)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<group
|
||||||
castShadow
|
|
||||||
position={node.position}
|
position={node.position}
|
||||||
receiveShadow
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
rotation-y={node.rotation}
|
rotation-y={node.rotation}
|
||||||
visible={node.visible}
|
visible={node.visible}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
{/* RoofSystem will replace this geometry in the next frame */}
|
<mesh
|
||||||
|
castShadow
|
||||||
|
material={debugColors ? roofDebugMaterials : roofMaterials}
|
||||||
|
name="merged-roof"
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
<meshStandardMaterial color="white" />
|
|
||||||
</mesh>
|
</mesh>
|
||||||
|
<group name="segments-wrapper" visible={false}>
|
||||||
|
{(node.children ?? []).map((childId) => (
|
||||||
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
|
|||||||
|
|
||||||
// Create a simple line loop at ground level
|
// Create a simple line loop at ground level
|
||||||
for (const [x, z] of points) {
|
for (const [x, z] of points) {
|
||||||
positions.push(x!, Y_OFFSET, z!)
|
positions.push(x ?? 0, Y_OFFSET, z ?? 0)
|
||||||
}
|
}
|
||||||
// Close the loop
|
// 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))
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
WindowSystem,
|
WindowSystem,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Bvh } from '@react-three/drei'
|
import { Bvh } from '@react-three/drei'
|
||||||
import { Canvas, extend, type ThreeToJSXElements, useFrame } from '@react-three/fiber'
|
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three/webgpu'
|
import * as THREE from 'three/webgpu'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
|
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
||||||
import { LevelSystem } from '../../systems/level/level-system'
|
import { LevelSystem } from '../../systems/level/level-system'
|
||||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
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 { SceneRenderer } from '../renderers/scene-renderer'
|
||||||
import { GroundOccluder } from './ground-occluder'
|
import { GroundOccluder } from './ground-occluder'
|
||||||
import { Lights } from './lights'
|
import { Lights } from './lights'
|
||||||
|
import { PerfMonitor } from './perf-monitor'
|
||||||
import PostProcessing from './post-processing'
|
import PostProcessing from './post-processing'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { ViewerCamera } from './viewer-camera'
|
import { ViewerCamera } from './viewer-camera'
|
||||||
@@ -59,12 +61,44 @@ declare module '@react-three/fiber' {
|
|||||||
|
|
||||||
extend(THREE as any)
|
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 {
|
interface ViewerProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
selectionManager?: 'default' | 'custom'
|
selectionManager?: 'default' | 'custom'
|
||||||
|
perf?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => {
|
const Viewer: React.FC<ViewerProps> = ({
|
||||||
|
children,
|
||||||
|
selectionManager = 'default',
|
||||||
|
perf = false,
|
||||||
|
}) => {
|
||||||
const theme = useViewer((state) => state.theme)
|
const theme = useViewer((state) => state.theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,11 +106,10 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
|
|||||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||||
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
gl={async (props) => {
|
gl={(props) => {
|
||||||
const renderer = new THREE.WebGPURenderer(props as any)
|
const renderer = new THREE.WebGPURenderer(props as any)
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
renderer.toneMappingExposure = 0.9
|
renderer.toneMappingExposure = 0.9
|
||||||
await renderer.init()
|
|
||||||
return renderer
|
return renderer
|
||||||
}}
|
}}
|
||||||
shadows={{
|
shadows={{
|
||||||
@@ -110,11 +143,22 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
|
|||||||
<WindowSystem />
|
<WindowSystem />
|
||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
<PostProcessing />
|
<PostProcessing />
|
||||||
|
{/* <DebugRenderer /> */}
|
||||||
|
<GPUDeviceWatcher />
|
||||||
|
|
||||||
|
<ItemLightSystem />
|
||||||
{selectionManager === 'default' && <SelectionManager />}
|
{selectionManager === 'default' && <SelectionManager />}
|
||||||
|
{perf && <PerfMonitor />}
|
||||||
{children}
|
{children}
|
||||||
</Canvas>
|
</Canvas>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DebugRenderer = () => {
|
||||||
|
useFrame(({ gl, scene, camera }) => {
|
||||||
|
gl.render(scene, camera)
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export default Viewer
|
export default Viewer
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Html
|
||||||
|
position={[0, 0, 0]}
|
||||||
|
style={{ position: 'fixed', top: 8, left: 8, pointerEvents: 'none' }}
|
||||||
|
zIndexRange={[100, 100]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: stats.fps < 30 ? '#f87171' : stats.fps < 55 ? '#fbbf24' : '#4ade80',
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 10px',
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`FPS ${stats.fps}
|
||||||
|
DRAW ${stats.drawCalls}
|
||||||
|
TRI ${(stats.triangles / 1000).toFixed(1)}k
|
||||||
|
DIRTY ${stats.dirty}`}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useFrame, useThree } from '@react-three/fiber'
|
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 { Color, Layers, UnsignedByteType } from 'three'
|
||||||
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
|
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
|
||||||
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
||||||
import { traa } from 'three/addons/tsl/display/TRAANode.js'
|
import { traa } from 'three/addons/tsl/display/TRAANode.js'
|
||||||
|
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
colorToDirection,
|
colorToDirection,
|
||||||
@@ -22,7 +23,6 @@ import {
|
|||||||
vec4,
|
vec4,
|
||||||
velocity,
|
velocity,
|
||||||
} from 'three/tsl'
|
} from 'three/tsl'
|
||||||
|
|
||||||
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
|
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
|
||||||
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
|
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
|
||||||
import useViewer from '../../store/use-viewer'
|
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
|
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
|
||||||
export const SSGI_PARAMS = {
|
export const SSGI_PARAMS = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sliceCount: 2,
|
sliceCount: 1,
|
||||||
stepCount: 8,
|
stepCount: 4,
|
||||||
radius: 1,
|
radius: 1,
|
||||||
expFactor: 1.5,
|
expFactor: 1.5,
|
||||||
thickness: 0.5,
|
thickness: 0.5,
|
||||||
backfaceLighting: 0.5,
|
backfaceLighting: 0.5,
|
||||||
aoIntensity: 1.5,
|
aoIntensity: 1.5,
|
||||||
giIntensity: 0.5,
|
giIntensity: 0,
|
||||||
useLinearThickness: false,
|
useLinearThickness: false,
|
||||||
useScreenSpaceSampling: true,
|
useScreenSpaceSampling: true,
|
||||||
useTemporalFiltering: true,
|
useTemporalFiltering: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_PIPELINE_RETRIES = 3
|
||||||
|
const RETRY_DELAY_MS = 500
|
||||||
|
|
||||||
const DARK_BG = '#1f2433'
|
const DARK_BG = '#1f2433'
|
||||||
const LIGHT_BG = '#ffffff'
|
const LIGHT_BG = '#ffffff'
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ const PostProcessingPasses = () => {
|
|||||||
const { gl: renderer, scene, camera } = useThree()
|
const { gl: renderer, scene, camera } = useThree()
|
||||||
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
||||||
const hasPipelineErrorRef = useRef(false)
|
const hasPipelineErrorRef = useRef(false)
|
||||||
|
const retryCountRef = useRef(0)
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
|
||||||
// Background color uniform — updated every frame via lerp, read by the TSL pipeline.
|
// Background color uniform — updated every frame via lerp, read by the TSL pipeline.
|
||||||
@@ -66,6 +70,18 @@ const PostProcessingPasses = () => {
|
|||||||
return l
|
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(() => {
|
useEffect(() => {
|
||||||
let mounted = true
|
let mounted = true
|
||||||
|
|
||||||
@@ -93,6 +109,12 @@ const PostProcessingPasses = () => {
|
|||||||
}
|
}
|
||||||
}, [renderer])
|
}, [renderer])
|
||||||
|
|
||||||
|
// Reset retry count when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
retryCountRef.current = 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Build / rebuild the post-processing pipeline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(renderer && scene && camera && isInitialized)) {
|
if (!(renderer && scene && camera && isInitialized)) {
|
||||||
return
|
return
|
||||||
@@ -100,6 +122,12 @@ const PostProcessingPasses = () => {
|
|||||||
|
|
||||||
hasPipelineErrorRef.current = false
|
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 {
|
try {
|
||||||
// Scene pass with MRT for SSGI
|
// Scene pass with MRT for SSGI
|
||||||
const scenePass = pass(scene, camera)
|
const scenePass = pass(scene, camera)
|
||||||
@@ -148,9 +176,20 @@ const PostProcessingPasses = () => {
|
|||||||
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
||||||
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
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 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),
|
// 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.
|
// so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1.
|
||||||
@@ -272,12 +311,24 @@ const PostProcessingPasses = () => {
|
|||||||
renderPipelineRef.current.render()
|
renderPipelineRef.current.render()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasPipelineErrorRef.current = true
|
hasPipelineErrorRef.current = true
|
||||||
console.error(
|
console.error('[viewer] Post-processing render pass failed.', error)
|
||||||
'[viewer] Post-processing render pass failed. Disabling post FX for this session.',
|
if (renderPipelineRef.current) {
|
||||||
error,
|
|
||||||
)
|
|
||||||
renderPipelineRef.current.dispose()
|
renderPipelineRef.current.dispose()
|
||||||
|
}
|
||||||
renderPipelineRef.current = null
|
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)
|
}, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
emitter,
|
emitter,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
@@ -34,6 +35,7 @@ type SelectableNodeType =
|
|||||||
| 'slab'
|
| 'slab'
|
||||||
| 'ceiling'
|
| 'ceiling'
|
||||||
| 'roof'
|
| 'roof'
|
||||||
|
| 'roof-segment'
|
||||||
|
|
||||||
// Expand polygon outward by a small amount to include items on edges
|
// Expand polygon outward by a small amount to include items on edges
|
||||||
const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {
|
const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {
|
||||||
@@ -150,7 +152,7 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
|
|||||||
return false
|
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
|
// Roofs on the same level are valid when zone is selected
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -219,12 +221,20 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
|
|
||||||
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
|
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
|
||||||
return {
|
return {
|
||||||
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'],
|
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
|
||||||
handleClick: (node, nativeEvent) => {
|
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
|
const { selectedIds } = useViewer.getState().selection
|
||||||
useViewer
|
useViewer
|
||||||
.getState()
|
.getState()
|
||||||
.setSelection({ selectedIds: computeNextIds(node, selectedIds, nativeEvent) })
|
.setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) })
|
||||||
},
|
},
|
||||||
handleDeselect: () => {
|
handleDeselect: () => {
|
||||||
const { selectedIds } = useViewer.getState().selection
|
const { selectedIds } = useViewer.getState().selection
|
||||||
@@ -236,7 +246,16 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isValid: (node) => {
|
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
|
if (!validTypes.includes(node.type)) return false
|
||||||
return isNodeInZone(node, levelId, zoneId)
|
return isNodeInZone(node, levelId, zoneId)
|
||||||
},
|
},
|
||||||
@@ -288,6 +307,7 @@ export const SelectionManager = () => {
|
|||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
type LevelNode,
|
type LevelNode,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
|
type RoofSegmentEvent,
|
||||||
|
type RoofSegmentNode,
|
||||||
type SiteEvent,
|
type SiteEvent,
|
||||||
type SiteNode,
|
type SiteNode,
|
||||||
type SlabEvent,
|
type SlabEvent,
|
||||||
@@ -37,6 +39,7 @@ type NodeConfig = {
|
|||||||
slab: { node: SlabNode; event: SlabEvent }
|
slab: { node: SlabNode; event: SlabEvent }
|
||||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||||
roof: { node: RoofNode; event: RoofEvent }
|
roof: { node: RoofNode; event: RoofEvent }
|
||||||
|
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
|
||||||
window: { node: WindowNode; event: WindowEvent }
|
window: { node: WindowNode; event: WindowEvent }
|
||||||
door: { node: DoorNode; event: DoorEvent }
|
door: { node: DoorNode; event: DoorEvent }
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+8
@@ -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 <directionalLight />, etc.
|
||||||
|
import type { ThreeToJSXElements } from '@react-three/fiber'
|
||||||
|
import * as THREE from 'three/webgpu'
|
||||||
|
|
||||||
|
declare module '@react-three/fiber' {
|
||||||
|
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||||
|
}
|
||||||
@@ -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<string, LightRegistration>
|
||||||
|
register: (key: string, nodeId: AnyNodeId, effect: LightEffect, interactive: Interactive) => void
|
||||||
|
unregister: (key: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useItemLightPool = create<ItemLightPoolStore>((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 }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -61,6 +61,9 @@ type ViewerState = {
|
|||||||
exportScene: (() => Promise<void>) | null
|
exportScene: (() => Promise<void>) | null
|
||||||
setExportScene: (fn: (() => Promise<void>) | null) => void
|
setExportScene: (fn: (() => Promise<void>) | null) => void
|
||||||
|
|
||||||
|
debugColors: boolean
|
||||||
|
setDebugColors: (enabled: boolean) => void
|
||||||
|
|
||||||
cameraDragging: boolean
|
cameraDragging: boolean
|
||||||
setCameraDragging: (dragging: boolean) => void
|
setCameraDragging: (dragging: boolean) => void
|
||||||
}
|
}
|
||||||
@@ -173,6 +176,9 @@ const useViewer = create<ViewerState>()(
|
|||||||
exportScene: null,
|
exportScene: null,
|
||||||
setExportScene: (fn) => set({ exportScene: fn }),
|
setExportScene: (fn) => set({ exportScene: fn }),
|
||||||
|
|
||||||
|
debugColors: false,
|
||||||
|
setDebugColors: (enabled) => set({ debugColors: enabled }),
|
||||||
|
|
||||||
cameraDragging: false,
|
cameraDragging: false,
|
||||||
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
|
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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<typeof useScene.getState>['nodes']
|
||||||
|
type InteractiveState = ReturnType<typeof useInteractive.getState>
|
||||||
|
|
||||||
|
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<PointLight | null>>(Array.from({ length: POOL_SIZE }, () => null))
|
||||||
|
const slots = useRef<SlotRuntime[]>(
|
||||||
|
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<string, number>()
|
||||||
|
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<number>()
|
||||||
|
const assignedKeys = new Set<string>()
|
||||||
|
|
||||||
|
// 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) => (
|
||||||
|
<pointLight
|
||||||
|
castShadow={false}
|
||||||
|
intensity={0}
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
lightRefs.current[i] = el
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -98,13 +98,11 @@ export const WallCutout = () => {
|
|||||||
if (wallNode.frontSide === 'exterior' && wallNode.backSide !== 'exterior') {
|
if (wallNode.frontSide === 'exterior' && wallNode.backSide !== 'exterior') {
|
||||||
hideWall = true
|
hideWall = true
|
||||||
}
|
}
|
||||||
} else {
|
} else if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') {
|
||||||
// Back side
|
// Back side
|
||||||
if (wallNode.backSide === 'exterior' && wallNode.frontSide !== 'exterior') {
|
|
||||||
hideWall = true
|
hideWall = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
;(wallMesh as Mesh).material = hideWall ? invsibleWallMaterial : wallMaterial
|
;(wallMesh as Mesh).material = hideWall ? invsibleWallMaterial : wallMaterial
|
||||||
})
|
})
|
||||||
lastWallMode.current = wallMode
|
lastWallMode.current = wallMode
|
||||||
|
|||||||
Reference in New Issue
Block a user