sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)
Squash merge of 3 commits: 1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes 2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels) 3. Missing files fix (materials.ts, merged-outline-node.ts, type fix) 75 files changed, ~6K additions.
This commit is contained in:
@@ -10,6 +10,11 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./clone-scene-graph": {
|
||||||
|
"types": "./dist/utils/clone-scene-graph.d.ts",
|
||||||
|
"import": "./dist/utils/clone-scene-graph.js",
|
||||||
|
"default": "./dist/utils/clone-scene-graph.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { useLayoutEffect } from 'react'
|
import { useLayoutEffect } from 'react'
|
||||||
import type * as THREE from 'three'
|
import type * as THREE from 'three'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Store
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
BuildingEvent,
|
BuildingEvent,
|
||||||
CameraControlEvent,
|
CameraControlEvent,
|
||||||
@@ -20,9 +18,7 @@ export type {
|
|||||||
WindowEvent,
|
WindowEvent,
|
||||||
ZoneEvent,
|
ZoneEvent,
|
||||||
} from './events/bus'
|
} from './events/bus'
|
||||||
// Events
|
|
||||||
export { emitter, eventSuffixes } from './events/bus'
|
export { emitter, eventSuffixes } from './events/bus'
|
||||||
// Hooks
|
|
||||||
export {
|
export {
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
@@ -33,24 +29,22 @@ export {
|
|||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||||
// Asset storage
|
|
||||||
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
|
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
|
||||||
// Space detection
|
|
||||||
export {
|
export {
|
||||||
detectSpacesForLevel,
|
detectSpacesForLevel,
|
||||||
initSpaceDetectionSync,
|
initSpaceDetectionSync,
|
||||||
type Space,
|
type Space,
|
||||||
wallTouchesOthers,
|
wallTouchesOthers,
|
||||||
} from './lib/space-detection'
|
} from './lib/space-detection'
|
||||||
// Schema
|
export { baseMaterial, glassMaterial } from './materials'
|
||||||
export * from './schema'
|
export * from './schema'
|
||||||
export {
|
export {
|
||||||
type ControlValue,
|
type ControlValue,
|
||||||
type ItemInteractiveState,
|
type ItemInteractiveState,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
} from './store/use-interactive'
|
} from './store/use-interactive'
|
||||||
|
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||||
export { clearSceneHistory, 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 { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||||
export { DoorSystem } from './systems/door/door-system'
|
export { DoorSystem } from './systems/door/door-system'
|
||||||
export { ItemSystem } from './systems/item/item-system'
|
export { ItemSystem } from './systems/item/item-system'
|
||||||
@@ -71,5 +65,6 @@ export {
|
|||||||
} from './systems/wall/wall-mitering'
|
} from './systems/wall/wall-mitering'
|
||||||
export { WallSystem } from './systems/wall/wall-system'
|
export { WallSystem } from './systems/wall/wall-system'
|
||||||
export { WindowSystem } from './systems/window/window-system'
|
export { WindowSystem } from './systems/window/window-system'
|
||||||
|
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||||
export { isObject } from './utils/types'
|
export { isObject } from './utils/types'
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared base material for structural elements: walls, frames, slabs, roof.
|
||||||
|
*/
|
||||||
|
export const baseMaterial = new MeshStandardNodeMaterial({
|
||||||
|
color: '#f2f0ed',
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared glass material for windows, glazed door panels, and glass items.
|
||||||
|
*/
|
||||||
|
export const glassMaterial = new MeshStandardNodeMaterial({
|
||||||
|
name: 'glass',
|
||||||
|
color: 'lightblue',
|
||||||
|
roughness: 0.05,
|
||||||
|
metalness: 0.1,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.35,
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Base
|
// Base
|
||||||
export { BaseNode, generateId, nodeType, objectId } from './base'
|
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||||
// Camera
|
// Camera
|
||||||
export { CameraSchema } from './camera'
|
export { CameraSchema } from './camera'
|
||||||
// Collections
|
// Collections
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export const updateNodesAction = (
|
|||||||
) => {
|
) => {
|
||||||
if (get().readOnly) return
|
if (get().readOnly) return
|
||||||
const parentsToUpdate = new Set<AnyNodeId>()
|
const parentsToUpdate = new Set<AnyNodeId>()
|
||||||
const idsToMarkDirty = new Set<AnyNodeId>()
|
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextNodes = { ...state.nodes }
|
const nextNodes = { ...state.nodes }
|
||||||
@@ -105,26 +104,19 @@ export const updateNodesAction = (
|
|||||||
return { nodes: nextNodes }
|
return { nodes: nextNodes }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Collect all IDs that need to be marked dirty
|
// Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates
|
||||||
for (const u of updates) {
|
for (const u of updates) {
|
||||||
idsToMarkDirty.add(u.id)
|
pendingUpdates.add(u.id)
|
||||||
}
|
}
|
||||||
for (const pId of parentsToUpdate) {
|
for (const pId of parentsToUpdate) {
|
||||||
idsToMarkDirty.add(pId)
|
pendingUpdates.add(pId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to pending updates set
|
|
||||||
for (const id of idsToMarkDirty) {
|
|
||||||
pendingUpdates.add(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel any pending RAF and schedule a new one
|
|
||||||
if (pendingRafId !== null) {
|
if (pendingRafId !== null) {
|
||||||
cancelAnimationFrame(pendingRafId)
|
cancelAnimationFrame(pendingRafId)
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingRafId = requestAnimationFrame(() => {
|
pendingRafId = requestAnimationFrame(() => {
|
||||||
// Mark all pending updates as dirty
|
|
||||||
pendingUpdates.forEach((id) => {
|
pendingUpdates.forEach((id) => {
|
||||||
get().markDirty(id)
|
get().markDirty(id)
|
||||||
})
|
})
|
||||||
@@ -146,32 +138,26 @@ export const deleteNodesAction = (
|
|||||||
const nextCollections = { ...state.collections }
|
const nextCollections = { ...state.collections }
|
||||||
let nextRootIds = [...state.rootNodeIds]
|
let nextRootIds = [...state.rootNodeIds]
|
||||||
|
|
||||||
// Collect all IDs to delete (including descendants) in a first pass
|
// Collect all ids to delete (the requested ids + all their descendants) before
|
||||||
// This avoids issues with recursive calls during state mutation
|
// mutating anything, so the recursive walk reads consistent state.
|
||||||
const allIdsToDelete = new Set<AnyNodeId>()
|
const allIds = new Set<AnyNodeId>()
|
||||||
const collectDescendants = (id: AnyNodeId) => {
|
const collect = (id: AnyNodeId) => {
|
||||||
|
if (allIds.has(id)) return
|
||||||
|
allIds.add(id)
|
||||||
const node = nextNodes[id]
|
const node = nextNodes[id]
|
||||||
if (!node) return
|
if (node && 'children' in node) {
|
||||||
allIdsToDelete.add(id)
|
for (const cid of node.children as AnyNodeId[]) collect(cid)
|
||||||
if ('children' in node && node.children) {
|
|
||||||
for (const childId of node.children as AnyNodeId[]) {
|
|
||||||
collectDescendants(childId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const id of ids) collect(id)
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of allIds) {
|
||||||
collectDescendants(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now process all nodes for deletion
|
|
||||||
for (const id of allIdsToDelete) {
|
|
||||||
const node = nextNodes[id]
|
const node = nextNodes[id]
|
||||||
if (!node) continue
|
if (!node) continue
|
||||||
|
|
||||||
// 1. Remove reference from Parent
|
// 1. Remove reference from parent — only if the parent itself is NOT also being deleted
|
||||||
const parentId = node.parentId as AnyNodeId | null
|
const parentId = node.parentId as AnyNodeId | null
|
||||||
if (parentId && nextNodes[parentId]) {
|
if (parentId && nextNodes[parentId] && !allIds.has(parentId)) {
|
||||||
const parent = nextNodes[parentId] as AnyContainerNode
|
const parent = nextNodes[parentId] as AnyContainerNode
|
||||||
if (parent.children) {
|
if (parent.children) {
|
||||||
nextNodes[parent.id] = {
|
nextNodes[parent.id] = {
|
||||||
@@ -182,7 +168,7 @@ export const deleteNodesAction = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Remove from Root list
|
// 2. Remove from root list
|
||||||
nextRootIds = nextRootIds.filter((rid) => rid !== id)
|
nextRootIds = nextRootIds.filter((rid) => rid !== id)
|
||||||
|
|
||||||
// 3. Remove from any collections it belongs to
|
// 3. Remove from any collections it belongs to
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Ephemeral live transform state for nodes being actively dragged/moved.
|
||||||
|
// This decouples 2D (floorplan) and 3D (viewer) so neither needs to peek
|
||||||
|
// into the other's scene graph during drag operations.
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type LiveTransform = {
|
||||||
|
position: [number, number, number]
|
||||||
|
rotation: number // Y-axis rotation (plan-view rotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveTransformState = {
|
||||||
|
transforms: Map<string, LiveTransform>
|
||||||
|
set(nodeId: string, transform: LiveTransform): void
|
||||||
|
get(nodeId: string): LiveTransform | undefined
|
||||||
|
clear(nodeId: string): void
|
||||||
|
clearAll(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useLiveTransforms = create<LiveTransformState>((set, get) => ({
|
||||||
|
transforms: new Map(),
|
||||||
|
set: (nodeId, transform) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.transforms)
|
||||||
|
next.set(nodeId, transform)
|
||||||
|
return { transforms: next }
|
||||||
|
}),
|
||||||
|
get: (nodeId) => get().transforms.get(nodeId),
|
||||||
|
clear: (nodeId) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Map(state.transforms)
|
||||||
|
next.delete(nodeId)
|
||||||
|
return { transforms: next }
|
||||||
|
}),
|
||||||
|
clearAll: () => set({ transforms: new Map() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useLiveTransforms
|
||||||
@@ -123,11 +123,6 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
setReadOnly: (readOnly: boolean) => set({ readOnly }),
|
setReadOnly: (readOnly: boolean) => set({ readOnly }),
|
||||||
|
|
||||||
unloadScene: () => {
|
unloadScene: () => {
|
||||||
// Clear temporal tracking to prevent memory leaks from stale node references
|
|
||||||
prevPastLength = 0
|
|
||||||
prevFutureLength = 0
|
|
||||||
prevNodesSnapshot = null
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
nodes: {},
|
nodes: {},
|
||||||
rootNodeIds: [],
|
rootNodeIds: [],
|
||||||
@@ -145,14 +140,29 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
// Apply backward compatibility migrations
|
// Apply backward compatibility migrations
|
||||||
const patchedNodes = migrateNodes(nodes)
|
const patchedNodes = migrateNodes(nodes)
|
||||||
|
|
||||||
|
// Remove orphans: nodes whose parentId points to a non-existent node
|
||||||
|
const cleanedNodes = { ...patchedNodes }
|
||||||
|
for (const node of Object.values(cleanedNodes)) {
|
||||||
|
if (node.parentId && !cleanedNodes[node.parentId]) {
|
||||||
|
console.warn(
|
||||||
|
'[Scene] Removing orphan node',
|
||||||
|
node.id,
|
||||||
|
'(parentId',
|
||||||
|
node.parentId,
|
||||||
|
'not found)',
|
||||||
|
)
|
||||||
|
delete cleanedNodes[node.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
nodes: patchedNodes,
|
nodes: cleanedNodes,
|
||||||
rootNodeIds,
|
rootNodeIds,
|
||||||
dirtyNodes: new Set<AnyNodeId>(),
|
dirtyNodes: new Set<AnyNodeId>(),
|
||||||
collections: {},
|
collections: {},
|
||||||
})
|
})
|
||||||
// Mark all nodes as dirty to trigger re-validation
|
// Mark all nodes as dirty to trigger re-validation
|
||||||
Object.values(patchedNodes).forEach((node) => {
|
Object.values(cleanedNodes).forEach((node) => {
|
||||||
get().markDirty(node.id)
|
get().markDirty(node.id)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -292,7 +302,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
if (!col) return state
|
if (!col) return state
|
||||||
const nextCollections = {
|
const nextCollections = {
|
||||||
...state.collections,
|
...state.collections,
|
||||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) },
|
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||||
}
|
}
|
||||||
const node = state.nodes[nodeId]
|
const node = state.nodes[nodeId]
|
||||||
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
|
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
|
||||||
@@ -324,21 +334,13 @@ let prevPastLength = 0
|
|||||||
let prevFutureLength = 0
|
let prevFutureLength = 0
|
||||||
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||||
|
|
||||||
/**
|
export function clearSceneHistory() {
|
||||||
* Clears temporal history tracking variables to prevent memory leaks.
|
useScene.temporal.getState().clear()
|
||||||
* Should be called when unloading a scene to release node references.
|
|
||||||
*/
|
|
||||||
export function clearTemporalTracking() {
|
|
||||||
prevPastLength = 0
|
prevPastLength = 0
|
||||||
prevFutureLength = 0
|
prevFutureLength = 0
|
||||||
prevNodesSnapshot = null
|
prevNodesSnapshot = null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSceneHistory() {
|
|
||||||
useScene.temporal.getState().clear()
|
|
||||||
clearTemporalTracking()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to the temporal store (Undo/Redo events)
|
// Subscribe to the temporal store (Undo/Redo events)
|
||||||
useScene.temporal.subscribe((state) => {
|
useScene.temporal.subscribe((state) => {
|
||||||
const currentPastLength = state.pastStates.length
|
const currentPastLength = state.pastStates.length
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
|
||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
|
import { baseMaterial, glassMaterial } from '../../materials'
|
||||||
import type { AnyNodeId, DoorNode } from '../../schema'
|
import type { AnyNodeId, DoorNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
const baseMaterial = new MeshStandardNodeMaterial({
|
|
||||||
name: 'door-base',
|
|
||||||
color: '#f2f0ed',
|
|
||||||
roughness: 0.5,
|
|
||||||
metalness: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const glassMaterial = new MeshStandardNodeMaterial({
|
|
||||||
name: 'door-glass',
|
|
||||||
color: 'lightblue',
|
|
||||||
roughness: 0.05,
|
|
||||||
metalness: 0.1,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.35,
|
|
||||||
side: DoubleSide,
|
|
||||||
depthWrite: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Invisible material for root mesh — used as selection hitbox only
|
// Invisible material for root mesh — used as selection hitbox only
|
||||||
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ import useScene from '../../store/use-scene'
|
|||||||
|
|
||||||
const csgEvaluator = new Evaluator()
|
const csgEvaluator = new Evaluator()
|
||||||
csgEvaluator.useGroups = true
|
csgEvaluator.useGroups = true
|
||||||
|
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
|
||||||
csgEvaluator.attributes = ['position', 'normal']
|
csgEvaluator.attributes = ['position', 'normal']
|
||||||
|
|
||||||
|
function prepareBrushForCSG(brush: Brush) {
|
||||||
|
brush.geometry.computeBoundsTree = computeBoundsTree
|
||||||
|
brush.geometry.computeBoundsTree({ maxLeafSize: 10 })
|
||||||
|
brush.updateMatrixWorld()
|
||||||
|
}
|
||||||
|
|
||||||
// Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry
|
// Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry
|
||||||
const _matrix = new THREE.Matrix4()
|
const _matrix = new THREE.Matrix4()
|
||||||
const _position = new THREE.Vector3()
|
const _position = new THREE.Vector3()
|
||||||
@@ -78,6 +85,8 @@ export const RoofSystem = () => {
|
|||||||
mesh.rotation.y = node.rotation
|
mesh.rotation.y = node.rotation
|
||||||
}
|
}
|
||||||
clearDirty(id as AnyNodeId)
|
clearDirty(id as AnyNodeId)
|
||||||
|
} else {
|
||||||
|
clearDirty(id as AnyNodeId)
|
||||||
}
|
}
|
||||||
// Queue the parent roof for a merged geometry update
|
// Queue the parent roof for a merged geometry update
|
||||||
if (node.parentId) {
|
if (node.parentId) {
|
||||||
@@ -179,6 +188,7 @@ function updateMergedRoofGeometry(
|
|||||||
const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush
|
const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush
|
||||||
totalShinSlab.geometry.dispose()
|
totalShinSlab.geometry.dispose()
|
||||||
brushes.shinSlab.geometry.dispose()
|
brushes.shinSlab.geometry.dispose()
|
||||||
|
prepareBrushForCSG(next)
|
||||||
totalShinSlab = next
|
totalShinSlab = next
|
||||||
} else {
|
} else {
|
||||||
totalShinSlab = brushes.shinSlab
|
totalShinSlab = brushes.shinSlab
|
||||||
@@ -188,6 +198,7 @@ function updateMergedRoofGeometry(
|
|||||||
const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush
|
const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush
|
||||||
totalDeckSlab.geometry.dispose()
|
totalDeckSlab.geometry.dispose()
|
||||||
brushes.deckSlab.geometry.dispose()
|
brushes.deckSlab.geometry.dispose()
|
||||||
|
prepareBrushForCSG(next)
|
||||||
totalDeckSlab = next
|
totalDeckSlab = next
|
||||||
} else {
|
} else {
|
||||||
totalDeckSlab = brushes.deckSlab
|
totalDeckSlab = brushes.deckSlab
|
||||||
@@ -197,6 +208,7 @@ function updateMergedRoofGeometry(
|
|||||||
const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush
|
const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush
|
||||||
totalWall.geometry.dispose()
|
totalWall.geometry.dispose()
|
||||||
brushes.wallBrush.geometry.dispose()
|
brushes.wallBrush.geometry.dispose()
|
||||||
|
prepareBrushForCSG(next)
|
||||||
totalWall = next
|
totalWall = next
|
||||||
} else {
|
} else {
|
||||||
totalWall = brushes.wallBrush
|
totalWall = brushes.wallBrush
|
||||||
@@ -206,6 +218,7 @@ function updateMergedRoofGeometry(
|
|||||||
const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush
|
const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush
|
||||||
totalInner.geometry.dispose()
|
totalInner.geometry.dispose()
|
||||||
brushes.innerBrush.geometry.dispose()
|
brushes.innerBrush.geometry.dispose()
|
||||||
|
prepareBrushForCSG(next)
|
||||||
totalInner = next
|
totalInner = next
|
||||||
} else {
|
} else {
|
||||||
totalInner = brushes.innerBrush
|
totalInner = brushes.innerBrush
|
||||||
@@ -505,6 +518,10 @@ export function getRoofSegmentBrushes(
|
|||||||
const toBrush = (geo: THREE.BufferGeometry): Brush | null => {
|
const toBrush = (geo: THREE.BufferGeometry): Brush | null => {
|
||||||
if (!geo?.attributes.position || geo.attributes.position.count === 0) return null
|
if (!geo?.attributes.position || geo.attributes.position.count === 0) return null
|
||||||
if (!geo.index) return null
|
if (!geo.index) return null
|
||||||
|
// Strip zero-count groups — three-bvh-csg crashes with groupIndices[i] undefined
|
||||||
|
// when a group exists but covers no triangles (can happen after mergeVertices)
|
||||||
|
geo.groups = geo.groups.filter((g) => g.count > 0)
|
||||||
|
if (geo.groups.length === 0) return null
|
||||||
geo.computeBoundsTree = computeBoundsTree
|
geo.computeBoundsTree = computeBoundsTree
|
||||||
geo.computeBoundsTree({ maxLeafSize: 10 })
|
geo.computeBoundsTree({ maxLeafSize: 10 })
|
||||||
const brush = new Brush(geo, dummyMats)
|
const brush = new Brush(geo, dummyMats)
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
|
|||||||
|
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
mesh.geometry = newGeo
|
mesh.geometry = newGeo
|
||||||
|
|
||||||
|
// For negative elevation, shift the mesh down so the top face sits at Y=elevation
|
||||||
|
// rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
|
||||||
|
const elevation = node.elevation ?? 0.05
|
||||||
|
mesh.position.y = elevation < 0 ? elevation : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Half of default wall thickness — used to extend slab geometry under walls */
|
/** Half of default wall thickness — used to extend slab geometry under walls */
|
||||||
@@ -102,54 +107,94 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<
|
|||||||
* Generates extruded slab geometry from polygon
|
* Generates extruded slab geometry from polygon
|
||||||
*/
|
*/
|
||||||
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||||
|
const elevation = slabNode.elevation ?? 0.05
|
||||||
|
return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
|
||||||
|
*/
|
||||||
|
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||||
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
|
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
|
||||||
const elevation = slabNode.elevation ?? 0.05
|
const elevation = slabNode.elevation ?? 0.05
|
||||||
|
|
||||||
if (polygon.length < 3) {
|
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||||
return new THREE.BufferGeometry()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create shape from polygon
|
|
||||||
// Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion
|
|
||||||
const shape = new THREE.Shape()
|
const shape = new THREE.Shape()
|
||||||
const firstPt = polygon[0]!
|
shape.moveTo(polygon[0]![0], -polygon[0]![1])
|
||||||
|
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
|
||||||
// Negate Y (which becomes Z) to get correct orientation after rotation
|
|
||||||
shape.moveTo(firstPt[0], -firstPt[1])
|
|
||||||
|
|
||||||
for (let i = 1; i < polygon.length; i++) {
|
|
||||||
const pt = polygon[i]!
|
|
||||||
shape.lineTo(pt[0], -pt[1])
|
|
||||||
}
|
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
// Add holes to the shape
|
for (const holePolygon of slabNode.holes ?? []) {
|
||||||
const holes = slabNode.holes || []
|
|
||||||
for (const holePolygon of holes) {
|
|
||||||
if (holePolygon.length < 3) continue
|
if (holePolygon.length < 3) continue
|
||||||
|
|
||||||
const holePath = new THREE.Path()
|
const holePath = new THREE.Path()
|
||||||
const holeFirstPt = holePolygon[0]!
|
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
|
||||||
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])
|
for (let i = 1; i < holePolygon.length; i++)
|
||||||
|
holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1])
|
||||||
for (let i = 1; i < holePolygon.length; i++) {
|
|
||||||
const pt = holePolygon[i]!
|
|
||||||
holePath.lineTo(pt[0], -pt[1])
|
|
||||||
}
|
|
||||||
holePath.closePath()
|
holePath.closePath()
|
||||||
|
|
||||||
shape.holes.push(holePath)
|
shape.holes.push(holePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extrude the shape by elevation
|
const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false })
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
|
||||||
depth: elevation,
|
|
||||||
bevelEnabled: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
|
||||||
geometry.rotateX(-Math.PI / 2)
|
geometry.rotateX(-Math.PI / 2)
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
|
|
||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|.
|
||||||
|
* No top cap — the opening at ground level is handled by the ground occluder hole.
|
||||||
|
* mesh.position.y must be set to elevation so the floor sits at the correct world Y.
|
||||||
|
*
|
||||||
|
* Geometry is built directly in 3D (Y-up) to avoid rotation confusion:
|
||||||
|
* - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
|
||||||
|
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
|
||||||
|
*/
|
||||||
|
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
||||||
|
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
|
||||||
|
const depth = Math.abs(slabNode.elevation ?? 0.05)
|
||||||
|
|
||||||
|
if (polygon.length < 3) return new THREE.BufferGeometry()
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const indices: number[] = []
|
||||||
|
const n = polygon.length
|
||||||
|
|
||||||
|
// --- Floor at Y=0 ---
|
||||||
|
for (const [x, z] of polygon) positions.push(x!, 0, z!)
|
||||||
|
|
||||||
|
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
|
||||||
|
const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
|
||||||
|
for (const hole of slabNode.holes ?? []) {
|
||||||
|
for (const [x, z] of hole) positions.push(x!, 0, z!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
|
||||||
|
for (const tri of floorTris) {
|
||||||
|
// Reversed winding → normals point +Y (upward) in XZ plane
|
||||||
|
indices.push(tri[0]!, tri[2]!, tri[1]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inner walls (no top cap at Y=depth) ---
|
||||||
|
// Standard winding on a CCW polygon in XZ gives inward-facing normals.
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (i + 1) % n
|
||||||
|
const [x0, z0] = polygon[i]!
|
||||||
|
const [x1, z1] = polygon[j]!
|
||||||
|
const vBase = positions.length / 3
|
||||||
|
|
||||||
|
positions.push(x0!, 0, z0!) // v0 — floor level
|
||||||
|
positions.push(x1!, 0, z1!) // v1 — floor level
|
||||||
|
positions.push(x1!, depth, z1!) // v2 — ground level
|
||||||
|
positions.push(x0!, depth, z0!) // v3 — ground level
|
||||||
|
|
||||||
|
indices.push(vBase, vBase + 1, vBase + 2)
|
||||||
|
indices.push(vBase, vBase + 2, vBase + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry()
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
|
geo.setIndex(indices)
|
||||||
|
geo.computeVertexNormals()
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const csgEvaluator = new Evaluator()
|
|||||||
// WALL SYSTEM
|
// WALL SYSTEM
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
let useFrameNb = 0
|
||||||
export const WallSystem = () => {
|
export const WallSystem = () => {
|
||||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
const clearDirty = useScene((state) => state.clearDirty)
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
@@ -34,6 +35,7 @@ export const WallSystem = () => {
|
|||||||
// Collect dirty walls and their levels
|
// Collect dirty walls and their levels
|
||||||
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
useFrameNb += 1
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
|
|||||||
@@ -1,28 +1,10 @@
|
|||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
|
||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
|
import { baseMaterial, glassMaterial } from '../../materials'
|
||||||
import type { AnyNodeId, WindowNode } from '../../schema'
|
import type { AnyNodeId, WindowNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
const glassMaterial = new MeshStandardNodeMaterial({
|
|
||||||
name: 'glass',
|
|
||||||
color: 'lightblue',
|
|
||||||
roughness: 0.05,
|
|
||||||
metalness: 0.1,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.3,
|
|
||||||
side: DoubleSide,
|
|
||||||
depthWrite: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const frameMaterial = new MeshStandardNodeMaterial({
|
|
||||||
name: 'window-frame',
|
|
||||||
color: '#e8e8e8',
|
|
||||||
roughness: 0.6,
|
|
||||||
metalness: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Invisible material for root mesh — used as selection hitbox only
|
// Invisible material for root mesh — used as selection hitbox only
|
||||||
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||||
|
|
||||||
@@ -108,7 +90,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
// Top / bottom — full width
|
// Top / bottom — full width
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
frameMaterial,
|
baseMaterial,
|
||||||
width,
|
width,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
@@ -118,7 +100,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
)
|
)
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
frameMaterial,
|
baseMaterial,
|
||||||
width,
|
width,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
@@ -129,7 +111,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
// Left / right — inner height to avoid corner overlap
|
// Left / right — inner height to avoid corner overlap
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
frameMaterial,
|
baseMaterial,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
innerH,
|
innerH,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
@@ -139,7 +121,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
)
|
)
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
frameMaterial,
|
baseMaterial,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
innerH,
|
innerH,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
@@ -184,7 +166,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
cx += colWidths[c]!
|
cx += colWidths[c]!
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
frameMaterial,
|
baseMaterial,
|
||||||
columnDividerThickness,
|
columnDividerThickness,
|
||||||
innerH,
|
innerH,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
@@ -203,7 +185,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
for (let c = 0; c < numCols; c++) {
|
for (let c = 0; c < numCols; c++) {
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
frameMaterial,
|
baseMaterial,
|
||||||
colWidths[c]!,
|
colWidths[c]!,
|
||||||
rowDividerThickness,
|
rowDividerThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
@@ -239,7 +221,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
const sillZ = frameDepth / 2 + sillDepth / 2
|
const sillZ = frameDepth / 2 + sillDepth / 2
|
||||||
addBox(
|
addBox(
|
||||||
mesh,
|
mesh,
|
||||||
frameMaterial,
|
baseMaterial,
|
||||||
sillW,
|
sillW,
|
||||||
sillThickness,
|
sillThickness,
|
||||||
sillDepth,
|
sillDepth,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ function extractIdPrefix(id: string): string {
|
|||||||
* parent-child relationships and other internal references.
|
* parent-child relationships and other internal references.
|
||||||
*
|
*
|
||||||
* This is useful for:
|
* This is useful for:
|
||||||
|
* - Duplicating a project (host app creates a new project record, then loads the cloned scene)
|
||||||
* - Copying nodes between different projects
|
* - Copying nodes between different projects
|
||||||
* - Duplicating a subset of a scene within the same project
|
|
||||||
* - Multi-scene in-memory scenarios
|
* - Multi-scene in-memory scenarios
|
||||||
*/
|
*/
|
||||||
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||||
@@ -42,7 +42,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
|
|
||||||
for (const [oldId, node] of Object.entries(nodes)) {
|
for (const [oldId, node] of Object.entries(nodes)) {
|
||||||
const newId = idMap.get(oldId)! as AnyNodeId
|
const newId = idMap.get(oldId)! as AnyNodeId
|
||||||
// structuredClone to avoid shared references between original and clone
|
|
||||||
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
||||||
|
|
||||||
// Remap parentId
|
// Remap parentId
|
||||||
@@ -50,10 +49,23 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null
|
clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remap children array (walls, levels, buildings, sites, items can have children)
|
// Remap children array (buildings, levels, walls, items, etc.)
|
||||||
|
// Children can be either string IDs or embedded node objects (with an `id` property).
|
||||||
|
// Normalize both forms to remapped string IDs.
|
||||||
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
||||||
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as string[])
|
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[])
|
||||||
.map((childId) => idMap.get(childId))
|
.map((child) => {
|
||||||
|
if (typeof child === 'string') return idMap.get(child)
|
||||||
|
if (
|
||||||
|
child &&
|
||||||
|
typeof child === 'object' &&
|
||||||
|
'id' in child &&
|
||||||
|
typeof (child as any).id === 'string'
|
||||||
|
) {
|
||||||
|
return idMap.get((child as any).id)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
.filter((id): id is string => id !== undefined)
|
.filter((id): id is string => id !== undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +90,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
clonedCollections = {} as Record<CollectionId, Collection>
|
clonedCollections = {} as Record<CollectionId, Collection>
|
||||||
const collectionIdMap = new Map<string, CollectionId>()
|
const collectionIdMap = new Map<string, CollectionId>()
|
||||||
|
|
||||||
// Generate new collection IDs
|
|
||||||
for (const collectionId of Object.keys(collections)) {
|
for (const collectionId of Object.keys(collections)) {
|
||||||
collectionIdMap.set(collectionId, generateId('collection'))
|
collectionIdMap.set(collectionId, generateId('collection'))
|
||||||
}
|
}
|
||||||
@@ -166,6 +177,9 @@ export function cloneLevelSubtree(
|
|||||||
const newLevelId = idMap.get(levelId)! as AnyNodeId
|
const newLevelId = idMap.get(levelId)! as AnyNodeId
|
||||||
|
|
||||||
// Clone each node with remapped references.
|
// Clone each node with remapped references.
|
||||||
|
// Use JSON roundtrip instead of structuredClone because live runtime nodes may
|
||||||
|
// carry non-serializable properties (Three.js Object3D refs, functions, etc.)
|
||||||
|
// that structuredClone would throw on.
|
||||||
const clonedNodes: AnyNode[] = []
|
const clonedNodes: AnyNode[] = []
|
||||||
for (const oldId of subtreeIds) {
|
for (const oldId of subtreeIds) {
|
||||||
const node = nodes[oldId]
|
const node = nodes[oldId]
|
||||||
@@ -178,6 +192,7 @@ export function cloneLevelSubtree(
|
|||||||
;(cloned as Record<string, unknown>).id = newId
|
;(cloned as Record<string, unknown>).id = newId
|
||||||
|
|
||||||
// Remap parentId — but only for descendants, not the level node itself
|
// Remap parentId — but only for descendants, not the level node itself
|
||||||
|
// (the level's parentId points to the building, which is outside the subtree)
|
||||||
if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
|
if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
|
||||||
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
|
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
|
||||||
}
|
}
|
||||||
@@ -219,6 +234,7 @@ export function cloneLevelSubtree(
|
|||||||
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||||
|
|
||||||
|
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
|
||||||
const excludedNodeIds = new Set<string>()
|
const excludedNodeIds = new Set<string>()
|
||||||
for (const [nodeId, node] of Object.entries(nodes)) {
|
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||||
if (node.type === 'scan' || node.type === 'guide') {
|
if (node.type === 'scan' || node.type === 'guide') {
|
||||||
@@ -226,12 +242,15 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a filtered scene graph without scan nodes
|
||||||
const filteredNodes = {} as Record<AnyNodeId, AnyNode>
|
const filteredNodes = {} as Record<AnyNodeId, AnyNode>
|
||||||
for (const [nodeId, node] of Object.entries(nodes)) {
|
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||||
if (excludedNodeIds.has(nodeId)) continue
|
if (excludedNodeIds.has(nodeId)) continue
|
||||||
|
|
||||||
const clonedNode = structuredClone(node) as AnyNode
|
const clonedNode = structuredClone(node) as AnyNode
|
||||||
|
|
||||||
|
// Remove scan children from any parent that references them.
|
||||||
|
// Children can be string IDs or embedded node objects.
|
||||||
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
||||||
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
|
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
|
||||||
(child) => {
|
(child) => {
|
||||||
@@ -251,6 +270,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
|
|
||||||
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
|
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
|
||||||
|
|
||||||
|
// Filter collections to remove references to scan nodes
|
||||||
let filteredCollections: Record<CollectionId, Collection> | undefined
|
let filteredCollections: Record<CollectionId, Collection> | undefined
|
||||||
if (collections) {
|
if (collections) {
|
||||||
filteredCollections = {} as Record<CollectionId, Collection>
|
filteredCollections = {} as Record<CollectionId, Collection>
|
||||||
@@ -269,6 +289,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now clone the filtered graph with new IDs
|
||||||
return cloneSceneGraph({
|
return cloneSceneGraph({
|
||||||
nodes: filteredNodes,
|
nodes: filteredNodes,
|
||||||
rootNodeIds: filteredRootNodeIds,
|
rootNodeIds: filteredRootNodeIds,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"description": "Pascal building editor component",
|
"description": "Pascal building editor component",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.tsx"
|
".": "./src/index.tsx",
|
||||||
|
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
|
||||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
@@ -22,7 +22,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
|||||||
export const CustomCameraControls = () => {
|
export const CustomCameraControls = () => {
|
||||||
const controls = useRef<CameraControlsImpl>(null!)
|
const controls = useRef<CameraControlsImpl>(null!)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
const walkthroughMode = useViewer((s) => s.walkthroughMode)
|
||||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const currentLevelId = selection.levelId
|
const currentLevelId = selection.levelId
|
||||||
@@ -39,7 +39,7 @@ export const CustomCameraControls = () => {
|
|||||||
}, [camera, raycaster])
|
}, [camera, raycaster])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPreviewMode || isFirstPersonMode) return
|
if (isPreviewMode) return // Preview mode uses auto-navigate instead
|
||||||
let targetY = 0
|
let targetY = 0
|
||||||
if (currentLevelId) {
|
if (currentLevelId) {
|
||||||
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
||||||
@@ -47,21 +47,17 @@ export const CustomCameraControls = () => {
|
|||||||
targetY = levelMesh.position.y
|
targetY = levelMesh.position.y
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!controls.current) return
|
||||||
if (firstLoad.current) {
|
if (firstLoad.current) {
|
||||||
firstLoad.current = false
|
firstLoad.current = false
|
||||||
;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true)
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||||
}
|
}
|
||||||
;(controls.current as CameraControlsImpl).getTarget(currentTarget)
|
controls.current.getTarget(currentTarget)
|
||||||
;(controls.current as CameraControlsImpl).moveTo(
|
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
||||||
currentTarget.x,
|
}, [currentLevelId, isPreviewMode])
|
||||||
targetY,
|
|
||||||
currentTarget.z,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}, [currentLevelId, isPreviewMode, isFirstPersonMode])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!controls.current || isFirstPersonMode) return
|
if (!controls.current) return
|
||||||
|
|
||||||
controls.current.maxPolarAngle = maxPolarAngle
|
controls.current.maxPolarAngle = maxPolarAngle
|
||||||
controls.current.minPolarAngle = 0
|
controls.current.minPolarAngle = 0
|
||||||
@@ -69,7 +65,7 @@ export const CustomCameraControls = () => {
|
|||||||
if (controls.current.polarAngle > maxPolarAngle) {
|
if (controls.current.polarAngle > maxPolarAngle) {
|
||||||
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
|
||||||
}
|
}
|
||||||
}, [maxPolarAngle, isFirstPersonMode])
|
}, [maxPolarAngle])
|
||||||
|
|
||||||
const focusNode = useCallback(
|
const focusNode = useCallback(
|
||||||
(nodeId: string) => {
|
(nodeId: string) => {
|
||||||
@@ -117,8 +113,6 @@ export const CustomCameraControls = () => {
|
|||||||
}, [cameraMode, isPreviewMode])
|
}, [cameraMode, isPreviewMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstPersonMode) return
|
|
||||||
|
|
||||||
const keyState = {
|
const keyState = {
|
||||||
shiftRight: false,
|
shiftRight: false,
|
||||||
shiftLeft: false,
|
shiftLeft: false,
|
||||||
@@ -199,7 +193,7 @@ export const CustomCameraControls = () => {
|
|||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('keyup', onKeyUp)
|
document.removeEventListener('keyup', onKeyUp)
|
||||||
}
|
}
|
||||||
}, [cameraMode, isPreviewMode, isFirstPersonMode])
|
}, [cameraMode, isPreviewMode])
|
||||||
|
|
||||||
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
||||||
const previewTargetNodeId = isPreviewMode
|
const previewTargetNodeId = isPreviewMode
|
||||||
@@ -221,6 +215,14 @@ export const CustomCameraControls = () => {
|
|||||||
// Check if node has a saved camera
|
// Check if node has a saved camera
|
||||||
if (node.camera) {
|
if (node.camera) {
|
||||||
const { position, target } = node.camera
|
const { position, target } = node.camera
|
||||||
|
if (
|
||||||
|
position &&
|
||||||
|
target &&
|
||||||
|
position.length >= 3 &&
|
||||||
|
target.length >= 3 &&
|
||||||
|
position.every((v) => v !== null && v !== undefined) &&
|
||||||
|
target.every((v) => v !== null && v !== undefined)
|
||||||
|
) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!controls.current) return
|
if (!controls.current) return
|
||||||
controls.current.setLookAt(
|
controls.current.setLookAt(
|
||||||
@@ -233,6 +235,7 @@ export const CustomCameraControls = () => {
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +264,6 @@ export const CustomCameraControls = () => {
|
|||||||
}, [isPreviewMode, previewTargetNodeId])
|
}, [isPreviewMode, previewTargetNodeId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstPersonMode) return
|
|
||||||
|
|
||||||
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
|
||||||
if (!controls.current) return
|
if (!controls.current) return
|
||||||
|
|
||||||
@@ -354,7 +355,7 @@ export const CustomCameraControls = () => {
|
|||||||
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
emitter.off('camera-controls:orbit-cw', handleOrbitCW)
|
||||||
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
|
||||||
}
|
}
|
||||||
}, [focusNode, isFirstPersonMode])
|
}, [focusNode])
|
||||||
|
|
||||||
const onTransitionStart = useCallback(() => {
|
const onTransitionStart = useCallback(() => {
|
||||||
useViewer.getState().setCameraDragging(true)
|
useViewer.getState().setCameraDragging(true)
|
||||||
@@ -364,9 +365,8 @@ export const CustomCameraControls = () => {
|
|||||||
useViewer.getState().setCameraDragging(false)
|
useViewer.getState().setCameraDragging(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// In first-person mode, don't render orbit controls — FirstPersonControls takes over
|
if (walkthroughMode) {
|
||||||
if (isFirstPersonMode) {
|
return <WalkthroughControls />
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Regular → Executable
+13
-2
@@ -14,9 +14,11 @@ const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
|||||||
function LeftColumn({
|
function LeftColumn({
|
||||||
tabs,
|
tabs,
|
||||||
renderTabContent,
|
renderTabContent,
|
||||||
|
sidebarOverlay,
|
||||||
}: {
|
}: {
|
||||||
tabs: SidebarTab[]
|
tabs: SidebarTab[]
|
||||||
renderTabContent: (tabId: string) => ReactNode
|
renderTabContent: (tabId: string) => ReactNode
|
||||||
|
sidebarOverlay?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const width = useSidebarStore((s) => s.width)
|
const width = useSidebarStore((s) => s.width)
|
||||||
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
|
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||||
@@ -108,7 +110,10 @@ function LeftColumn({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
|
<TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">{renderTabContent(activePanel)}</div>
|
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||||
|
{renderTabContent(activePanel)}
|
||||||
|
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Resize handle + hit area */}
|
{/* Resize handle + hit area */}
|
||||||
<div
|
<div
|
||||||
@@ -171,6 +176,7 @@ export interface EditorLayoutV2Props {
|
|||||||
navbarSlot?: ReactNode
|
navbarSlot?: ReactNode
|
||||||
sidebarTabs?: SidebarTab[]
|
sidebarTabs?: SidebarTab[]
|
||||||
renderTabContent: (tabId: string) => ReactNode
|
renderTabContent: (tabId: string) => ReactNode
|
||||||
|
sidebarOverlay?: ReactNode
|
||||||
viewerToolbarLeft?: ReactNode
|
viewerToolbarLeft?: ReactNode
|
||||||
viewerToolbarRight?: ReactNode
|
viewerToolbarRight?: ReactNode
|
||||||
viewerContent: ReactNode
|
viewerContent: ReactNode
|
||||||
@@ -181,6 +187,7 @@ export function EditorLayoutV2({
|
|||||||
navbarSlot,
|
navbarSlot,
|
||||||
sidebarTabs = [],
|
sidebarTabs = [],
|
||||||
renderTabContent,
|
renderTabContent,
|
||||||
|
sidebarOverlay,
|
||||||
viewerToolbarLeft,
|
viewerToolbarLeft,
|
||||||
viewerToolbarRight,
|
viewerToolbarRight,
|
||||||
viewerContent,
|
viewerContent,
|
||||||
@@ -194,7 +201,11 @@ export function EditorLayoutV2({
|
|||||||
{/* Main content: left column + right column */}
|
{/* Main content: left column + right column */}
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
{sidebarTabs.length > 0 && (
|
{sidebarTabs.length > 0 && (
|
||||||
<LeftColumn renderTabContent={renderTabContent} tabs={sidebarTabs} />
|
<LeftColumn
|
||||||
|
renderTabContent={renderTabContent}
|
||||||
|
sidebarOverlay={sidebarOverlay}
|
||||||
|
tabs={sidebarTabs}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<RightColumn
|
<RightColumn
|
||||||
overlays={overlays}
|
overlays={overlays}
|
||||||
|
|||||||
Regular → Executable
+48
-4
@@ -7,6 +7,8 @@ import {
|
|||||||
ItemNode,
|
ItemNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
|
StairNode,
|
||||||
|
StairSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
@@ -20,7 +22,17 @@ import { sfxEmitter } from '../../lib/sfx-bus'
|
|||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
import { NodeActionMenu } from './node-action-menu'
|
||||||
|
|
||||||
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment', 'wall', 'slab']
|
const ALLOWED_TYPES = [
|
||||||
|
'item',
|
||||||
|
'door',
|
||||||
|
'window',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
|
'wall',
|
||||||
|
'slab',
|
||||||
|
]
|
||||||
const DELETE_ONLY_TYPES = ['wall', 'slab']
|
const DELETE_ONLY_TYPES = ['wall', 'slab']
|
||||||
|
|
||||||
export function FloatingActionMenu() {
|
export function FloatingActionMenu() {
|
||||||
@@ -66,7 +78,9 @@ export function FloatingActionMenu() {
|
|||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'door' ||
|
node.type === 'door' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
node.type === 'roof-segment'
|
node.type === 'roof-segment' ||
|
||||||
|
node.type === 'stair' ||
|
||||||
|
node.type === 'stair-segment'
|
||||||
) {
|
) {
|
||||||
setMovingNode(node as any)
|
setMovingNode(node as any)
|
||||||
}
|
}
|
||||||
@@ -98,6 +112,10 @@ export function FloatingActionMenu() {
|
|||||||
duplicate = RoofNode.parse(duplicateInfo)
|
duplicate = RoofNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'roof-segment') {
|
} else if (node.type === 'roof-segment') {
|
||||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
||||||
|
} else if (node.type === 'stair') {
|
||||||
|
duplicate = StairNode.parse(duplicateInfo)
|
||||||
|
} else if (node.type === 'stair-segment') {
|
||||||
|
duplicate = StairSegmentNode.parse(duplicateInfo)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse duplicate', error)
|
console.error('Failed to parse duplicate', error)
|
||||||
@@ -107,7 +125,12 @@ export function FloatingActionMenu() {
|
|||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (duplicate.type === 'roof' || duplicate.type === 'roof-segment') {
|
} else if (
|
||||||
|
duplicate.type === 'roof' ||
|
||||||
|
duplicate.type === 'roof-segment' ||
|
||||||
|
duplicate.type === 'stair' ||
|
||||||
|
duplicate.type === 'stair-segment'
|
||||||
|
) {
|
||||||
// Add small offset to make it visible
|
// Add small offset to make it visible
|
||||||
if ('position' in duplicate) {
|
if ('position' in duplicate) {
|
||||||
duplicate.position = [
|
duplicate.position = [
|
||||||
@@ -136,13 +159,34 @@ export function FloatingActionMenu() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Duplicate children for stair nodes
|
||||||
|
if (node.type === 'stair' && node.children) {
|
||||||
|
const nodesState = useScene.getState().nodes
|
||||||
|
for (const childId of node.children) {
|
||||||
|
const childNode = nodesState[childId]
|
||||||
|
if (childNode && childNode.type === 'stair-segment') {
|
||||||
|
let childDuplicateInfo = structuredClone(childNode) as any
|
||||||
|
delete childDuplicateInfo.id
|
||||||
|
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
||||||
|
try {
|
||||||
|
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
|
||||||
|
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to duplicate stair segment', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
duplicate.type === 'item' ||
|
duplicate.type === 'item' ||
|
||||||
duplicate.type === 'window' ||
|
duplicate.type === 'window' ||
|
||||||
duplicate.type === 'door' ||
|
duplicate.type === 'door' ||
|
||||||
duplicate.type === 'roof' ||
|
duplicate.type === 'roof' ||
|
||||||
duplicate.type === 'roof-segment'
|
duplicate.type === 'roof-segment' ||
|
||||||
|
duplicate.type === 'stair' ||
|
||||||
|
duplicate.type === 'stair-segment'
|
||||||
) {
|
) {
|
||||||
setMovingNode(duplicate as any)
|
setMovingNode(duplicate as any)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,14 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||||
@@ -47,7 +54,6 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
|||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
import { EditorLayoutV2 } from './editor-layout-v2'
|
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
|
||||||
import { FloatingActionMenu } from './floating-action-menu'
|
import { FloatingActionMenu } from './floating-action-menu'
|
||||||
import { FloorplanPanel } from './floorplan-panel'
|
import { FloorplanPanel } from './floorplan-panel'
|
||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
@@ -56,17 +62,19 @@ import { SelectionManager } from './selection-manager'
|
|||||||
import { SiteEdgeLabels } from './site-edge-labels'
|
import { SiteEdgeLabels } from './site-edge-labels'
|
||||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||||
import { WallMeasurementLabel } from './wall-measurement-label'
|
import { WallMeasurementLabel } from './wall-measurement-label'
|
||||||
|
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||||
|
|
||||||
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
||||||
|
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||||
|
const DELETE_CURSOR_BADGE_OFFSET_X = 14
|
||||||
|
const DELETE_CURSOR_BADGE_OFFSET_Y = 14
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire up module-level singletons (spatial grid, space detection, SFX) for
|
* Wire up module-level singletons (spatial grid, space detection, SFX) for
|
||||||
* an Editor mount. Returns a teardown function that detaches the scene-store
|
* an Editor mount. Returns a teardown function that detaches the scene-store
|
||||||
* subscriptions and resets the shared singletons so a subsequent remount —
|
* subscriptions and resets the shared singletons so a subsequent remount —
|
||||||
* including hot navigation back to the editor in the same tab — starts from
|
* including hot navigation back to the editor in the same tab — starts from
|
||||||
* a clean slate. Without this, the spatial-grid manager and viewer outliner
|
* a clean slate.
|
||||||
* accumulate stale references from the previous Editor instance and can
|
|
||||||
* freeze the app on re-entry.
|
|
||||||
*/
|
*/
|
||||||
function initializeEditorRuntime(): () => void {
|
function initializeEditorRuntime(): () => void {
|
||||||
const unsubscribeSpatialGrid = initSpatialGridSync()
|
const unsubscribeSpatialGrid = initSpatialGridSync()
|
||||||
@@ -77,15 +85,8 @@ function initializeEditorRuntime(): () => void {
|
|||||||
unsubscribeSpatialGrid()
|
unsubscribeSpatialGrid()
|
||||||
unsubscribeSpaceDetection?.()
|
unsubscribeSpaceDetection?.()
|
||||||
|
|
||||||
// Drop all entries the spatial-grid singleton accumulated for the
|
|
||||||
// previous scene so the next mount re-syncs from current state instead
|
|
||||||
// of layering on top of stale data.
|
|
||||||
spatialGridManager.clear()
|
spatialGridManager.clear()
|
||||||
|
|
||||||
// The viewer outliner holds direct Object3D references used by the
|
|
||||||
// post-processing selection pass. Clearing the underlying arrays (we
|
|
||||||
// intentionally mutate in place — there is no setter by design) releases
|
|
||||||
// those refs so the disposed Three.js scene graph can be GC'd.
|
|
||||||
const outliner = useViewer.getState().outliner
|
const outliner = useViewer.getState().outliner
|
||||||
outliner.selectedObjects.length = 0
|
outliner.selectedObjects.length = 0
|
||||||
outliner.hoveredObjects.length = 0
|
outliner.hoveredObjects.length = 0
|
||||||
@@ -123,6 +124,10 @@ export interface EditorProps {
|
|||||||
// Thumbnail
|
// Thumbnail
|
||||||
onThumbnailCapture?: (blob: Blob) => void
|
onThumbnailCapture?: (blob: Blob) => void
|
||||||
|
|
||||||
|
// Version preview overlays (rendered by host app)
|
||||||
|
sidebarOverlay?: ReactNode
|
||||||
|
viewerBanner?: ReactNode
|
||||||
|
|
||||||
// Panel config (passed through to sidebar panels — v1 only)
|
// Panel config (passed through to sidebar panels — v1 only)
|
||||||
settingsPanelProps?: SettingsPanelProps
|
settingsPanelProps?: SettingsPanelProps
|
||||||
sitePanelProps?: SitePanelProps
|
sitePanelProps?: SitePanelProps
|
||||||
@@ -472,6 +477,35 @@ function ViewerCanvasControlsHint({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute z-40"
|
||||||
|
style={{
|
||||||
|
left: position.x + DELETE_CURSOR_BADGE_OFFSET_X,
|
||||||
|
top: position.y + DELETE_CURSOR_BADGE_OFFSET_Y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
||||||
|
style={{
|
||||||
|
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${DELETE_CURSOR_BADGE_COLOR}22`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
||||||
|
color={DELETE_CURSOR_BADGE_COLOR}
|
||||||
|
height={18}
|
||||||
|
icon="mdi:trash-can-outline"
|
||||||
|
width={18}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
layoutVersion = 'v1',
|
layoutVersion = 'v1',
|
||||||
appMenuButton,
|
appMenuButton,
|
||||||
@@ -489,13 +523,15 @@ export default function Editor({
|
|||||||
isVersionPreviewMode = false,
|
isVersionPreviewMode = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onThumbnailCapture,
|
onThumbnailCapture,
|
||||||
|
sidebarOverlay,
|
||||||
|
viewerBanner,
|
||||||
settingsPanelProps,
|
settingsPanelProps,
|
||||||
sitePanelProps,
|
sitePanelProps,
|
||||||
extraSidebarPanels,
|
extraSidebarPanels,
|
||||||
presetsAdapter,
|
presetsAdapter,
|
||||||
commandPaletteEmptyAction,
|
commandPaletteEmptyAction,
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
useKeyboard()
|
useKeyboard({ isVersionPreviewMode })
|
||||||
|
|
||||||
const { isLoadingSceneRef } = useAutoSave({
|
const { isLoadingSceneRef } = useAutoSave({
|
||||||
onSave,
|
onSave,
|
||||||
@@ -510,10 +546,14 @@ export default function Editor({
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
||||||
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
|
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
|
||||||
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
|
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
|
||||||
|
const [viewerCursorPosition, setViewerCursorPosition] = useState<{ x: number; y: number } | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
const sidebarWidth = useSidebarStore((s) => s.width)
|
const sidebarWidth = useSidebarStore((s) => s.width)
|
||||||
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
|
||||||
@@ -602,6 +642,17 @@ export default function Editor({
|
|||||||
}
|
}
|
||||||
}, [isVersionPreviewMode, previewScene])
|
}, [isVersionPreviewMode, previewScene])
|
||||||
|
|
||||||
|
// Lock scene graph and reset to select mode when entering version preview
|
||||||
|
useEffect(() => {
|
||||||
|
useScene.getState().setReadOnly(isVersionPreviewMode)
|
||||||
|
if (isVersionPreviewMode) {
|
||||||
|
useEditor.getState().setMode('select')
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
useScene.getState().setReadOnly(false)
|
||||||
|
}
|
||||||
|
}, [isVersionPreviewMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.add('dark')
|
document.body.classList.add('dark')
|
||||||
return () => {
|
return () => {
|
||||||
@@ -623,8 +674,8 @@ export default function Editor({
|
|||||||
const viewerSceneContent = (
|
const viewerSceneContent = (
|
||||||
<>
|
<>
|
||||||
{!isFirstPersonMode && <SelectionManager />}
|
{!isFirstPersonMode && <SelectionManager />}
|
||||||
{!isFirstPersonMode && <BoxSelectTool />}
|
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
|
||||||
{!isFirstPersonMode && <FloatingActionMenu />}
|
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
||||||
{!isFirstPersonMode && <WallMeasurementLabel />}
|
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||||
<ExportManager />
|
<ExportManager />
|
||||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||||
@@ -632,9 +683,9 @@ export default function Editor({
|
|||||||
<RoofEditSystem />
|
<RoofEditSystem />
|
||||||
<StairEditSystem />
|
<StairEditSystem />
|
||||||
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||||
{!isLoading && !isFirstPersonMode && <ToolManager />}
|
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
|
||||||
<CustomCameraControls />
|
|
||||||
{isFirstPersonMode && <FirstPersonControls />}
|
{isFirstPersonMode && <FirstPersonControls />}
|
||||||
|
<CustomCameraControls />
|
||||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
<PresetThumbnailGenerator />
|
<PresetThumbnailGenerator />
|
||||||
{!isFirstPersonMode && <SiteEdgeLabels />}
|
{!isFirstPersonMode && <SiteEdgeLabels />}
|
||||||
@@ -661,6 +712,33 @@ export default function Editor({
|
|||||||
|
|
||||||
const show2d = viewMode === '2d' || viewMode === 'split'
|
const show2d = viewMode === '2d' || viewMode === 'split'
|
||||||
const show3d = viewMode === '3d' || viewMode === 'split'
|
const show3d = viewMode === '3d' || viewMode === 'split'
|
||||||
|
const showDeleteCursorBadge = mode === 'delete' && !isVersionPreviewMode
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(showDeleteCursorBadge && show3d)) {
|
||||||
|
setViewerCursorPosition(null)
|
||||||
|
}
|
||||||
|
}, [show3d, showDeleteCursorBadge])
|
||||||
|
|
||||||
|
const handleViewerPointerMove = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!showDeleteCursorBadge) {
|
||||||
|
setViewerCursorPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
setViewerCursorPosition({
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[showDeleteCursorBadge],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleViewerPointerLeave = useCallback(() => {
|
||||||
|
setViewerCursorPosition(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const viewerCanvas = (
|
const viewerCanvas = (
|
||||||
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
<ErrorBoundary fallback={<EditorSceneCrashFallback />}>
|
||||||
@@ -689,8 +767,14 @@ export default function Editor({
|
|||||||
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
|
||||||
<div
|
<div
|
||||||
className="relative min-w-0 flex-1 overflow-hidden"
|
className="relative min-w-0 flex-1 overflow-hidden"
|
||||||
|
onPointerEnter={handleViewerPointerMove}
|
||||||
|
onPointerLeave={handleViewerPointerLeave}
|
||||||
|
onPointerMove={handleViewerPointerMove}
|
||||||
style={{ display: show3d ? undefined : 'none' }}
|
style={{ display: show3d ? undefined : 'none' }}
|
||||||
>
|
>
|
||||||
|
{showDeleteCursorBadge && viewerCursorPosition ? (
|
||||||
|
<DeleteCursorBadge position={viewerCursorPosition} />
|
||||||
|
) : null}
|
||||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||||
<ViewerCanvasControlsHint
|
<ViewerCanvasControlsHint
|
||||||
isPreviewMode={isPreviewMode}
|
isPreviewMode={isPreviewMode}
|
||||||
@@ -701,7 +785,7 @@ export default function Editor({
|
|||||||
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
|
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isLoading && <ZoneLabelEditorSystem />}
|
{!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -741,6 +825,34 @@ export default function Editor({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<EditorLayoutV2
|
||||||
|
navbarSlot={navbarSlot}
|
||||||
|
overlays={
|
||||||
|
<>
|
||||||
|
<FloatingLevelSelector />
|
||||||
|
{!isVersionPreviewMode && (
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<ActionMenu />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isVersionPreviewMode && (
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<PanelManager />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<HelperManager />
|
||||||
|
</div>
|
||||||
|
{viewerBanner}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
renderTabContent={renderTabContent}
|
||||||
|
sidebarOverlay={sidebarOverlay}
|
||||||
|
sidebarTabs={tabBarTabs}
|
||||||
|
viewerContent={viewerCanvas}
|
||||||
|
viewerToolbarLeft={viewerToolbarLeft}
|
||||||
|
viewerToolbarRight={viewerToolbarRight}
|
||||||
|
/>
|
||||||
{/* First-person overlay — rendered on top of normal layout */}
|
{/* First-person overlay — rendered on top of normal layout */}
|
||||||
{isFirstPersonMode && (
|
{isFirstPersonMode && (
|
||||||
<div className="fixed inset-0 z-50 pointer-events-none">
|
<div className="fixed inset-0 z-50 pointer-events-none">
|
||||||
@@ -749,28 +861,6 @@ export default function Editor({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<EditorLayoutV2
|
|
||||||
navbarSlot={navbarSlot}
|
|
||||||
overlays={
|
|
||||||
<>
|
|
||||||
<FloatingLevelSelector />
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<ActionMenu />
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<PanelManager />
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-auto">
|
|
||||||
<HelperManager />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
renderTabContent={renderTabContent}
|
|
||||||
sidebarTabs={tabBarTabs}
|
|
||||||
viewerContent={viewerCanvas}
|
|
||||||
viewerToolbarLeft={viewerToolbarLeft}
|
|
||||||
viewerToolbarRight={viewerToolbarRight}
|
|
||||||
/>
|
|
||||||
<EditorCommands />
|
<EditorCommands />
|
||||||
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
Regular → Executable
+265
-10
@@ -11,7 +11,8 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { Color, type Material, type Mesh, type Object3D } from 'three'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
||||||
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
||||||
@@ -66,6 +67,88 @@ export const resolveBuildingId = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HIGHLIGHT_PROFILES = {
|
||||||
|
delete: {
|
||||||
|
color: new Color('#dc2626'),
|
||||||
|
blend: 0.76,
|
||||||
|
emissiveBlend: 0.92,
|
||||||
|
emissiveIntensity: 0.46,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
color: new Color('#818cf8'),
|
||||||
|
blend: 0.32,
|
||||||
|
emissiveBlend: 0.7,
|
||||||
|
emissiveIntensity: 0.42,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
|
||||||
|
|
||||||
|
type HighlightableMaterial = Material & {
|
||||||
|
color?: Color
|
||||||
|
emissive?: Color
|
||||||
|
emissiveIntensity?: number
|
||||||
|
opacity?: number
|
||||||
|
transparent?: boolean
|
||||||
|
needsUpdate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighlightableMesh(object: Object3D): object is Mesh {
|
||||||
|
return Boolean(
|
||||||
|
(object as Mesh).isMesh &&
|
||||||
|
(object as Mesh).material &&
|
||||||
|
object.visible &&
|
||||||
|
object.name !== 'collision-mesh',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHighlightedMaterial(material: Material, kind: HighlightKind): Material {
|
||||||
|
const highlightedMaterial = material.clone() as HighlightableMaterial
|
||||||
|
const profile = HIGHLIGHT_PROFILES[kind]
|
||||||
|
|
||||||
|
if (highlightedMaterial.color instanceof Color) {
|
||||||
|
highlightedMaterial.color = highlightedMaterial.color.clone().lerp(profile.color, profile.blend)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highlightedMaterial.emissive instanceof Color) {
|
||||||
|
highlightedMaterial.emissive = highlightedMaterial.emissive
|
||||||
|
.clone()
|
||||||
|
.lerp(profile.color, profile.emissiveBlend)
|
||||||
|
highlightedMaterial.emissiveIntensity = Math.max(
|
||||||
|
highlightedMaterial.emissiveIntensity ?? 0,
|
||||||
|
profile.emissiveIntensity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof highlightedMaterial.opacity === 'number' && highlightedMaterial.opacity < 1) {
|
||||||
|
highlightedMaterial.transparent = true
|
||||||
|
highlightedMaterial.opacity = Math.min(1, highlightedMaterial.opacity + 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightedMaterial.needsUpdate = true
|
||||||
|
return highlightedMaterial
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHighlightedMaterials(
|
||||||
|
material: Material | Material[],
|
||||||
|
kind: HighlightKind,
|
||||||
|
): Material | Material[] {
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
return material.map((entry) => createHighlightedMaterial(entry, kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
return createHighlightedMaterial(material, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeHighlightedMaterials(material: Material | Material[]) {
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
material.forEach((entry) => entry.dispose())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
material.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
const computeNextIds = (
|
const computeNextIds = (
|
||||||
node: AnyNode,
|
node: AnyNode,
|
||||||
selectedIds: string[],
|
selectedIds: string[],
|
||||||
@@ -99,7 +182,19 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
structure: {
|
structure: {
|
||||||
types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
|
types: [
|
||||||
|
'wall',
|
||||||
|
'item',
|
||||||
|
'zone',
|
||||||
|
'slab',
|
||||||
|
'ceiling',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
|
'window',
|
||||||
|
'door',
|
||||||
|
],
|
||||||
handleSelect: (node, nativeEvent, modifierKeys) => {
|
handleSelect: (node, nativeEvent, modifierKeys) => {
|
||||||
const { selection, setSelection } = useViewer.getState()
|
const { selection, setSelection } = useViewer.getState()
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
@@ -144,7 +239,9 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
node.type === 'roof-segment'
|
node.type === 'roof-segment' ||
|
||||||
|
node.type === 'stair' ||
|
||||||
|
node.type === 'stair-segment'
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
if (node.type === 'item') {
|
if (node.type === 'item') {
|
||||||
@@ -204,6 +301,8 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
node.type === 'roof-segment' ||
|
node.type === 'roof-segment' ||
|
||||||
|
node.type === 'stair' ||
|
||||||
|
node.type === 'stair-segment' ||
|
||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'door'
|
node.type === 'door'
|
||||||
) {
|
) {
|
||||||
@@ -233,6 +332,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|||||||
export const SelectionManager = () => {
|
export const SelectionManager = () => {
|
||||||
const phase = useEditor((s) => s.phase)
|
const phase = useEditor((s) => s.phase)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
||||||
const modifierKeysRef = useRef<ModifierKeys>({
|
const modifierKeysRef = useRef<ModifierKeys>({
|
||||||
meta: false,
|
meta: false,
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
@@ -241,6 +341,14 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const movingNode = useEditor((s) => s.movingNode)
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setHoverHighlightMode('default')
|
||||||
|
}
|
||||||
|
}, [mode, setHoverHighlightMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
||||||
@@ -314,6 +422,12 @@ export const SelectionManager = () => {
|
|||||||
nodeToSelect = parentNode
|
nodeToSelect = parentNode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (node.type === 'stair-segment' && node.parentId) {
|
||||||
|
const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||||
|
if (parentNode && parentNode.type === 'stair') {
|
||||||
|
nodeToSelect = parentNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
|
||||||
|
|
||||||
@@ -333,6 +447,8 @@ export const SelectionManager = () => {
|
|||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
'roof-segment',
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
]
|
]
|
||||||
@@ -343,8 +459,15 @@ export const SelectionManager = () => {
|
|||||||
const onGridClick = () => {
|
const onGridClick = () => {
|
||||||
if (clickHandledRef.current) return
|
if (clickHandledRef.current) return
|
||||||
if (boxSelectHandled) return
|
if (boxSelectHandled) return
|
||||||
const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase]
|
const { phase, structureLayer } = useEditor.getState()
|
||||||
|
const activeStrategy = SELECTION_STRATEGIES[phase]
|
||||||
if (activeStrategy) activeStrategy.handleDeselect()
|
if (activeStrategy) activeStrategy.handleDeselect()
|
||||||
|
|
||||||
|
// When deselecting from zone mode, return to structure select
|
||||||
|
if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
|
useEditor.getState().setStructureLayer('elements')
|
||||||
|
useEditor.getState().setMode('select')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
|
|
||||||
@@ -415,6 +538,8 @@ export const SelectionManager = () => {
|
|||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
node.type === 'roof-segment' ||
|
node.type === 'roof-segment' ||
|
||||||
|
node.type === 'stair' ||
|
||||||
|
node.type === 'stair-segment' ||
|
||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'door'
|
node.type === 'door'
|
||||||
) {
|
) {
|
||||||
@@ -422,6 +547,9 @@ export const SelectionManager = () => {
|
|||||||
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
if (node.type === 'roof-segment' && currentPhase === 'structure') {
|
||||||
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase
|
||||||
}
|
}
|
||||||
|
if (node.type === 'stair-segment' && currentPhase === 'structure') {
|
||||||
|
forceSelect = true // allow double click to dive into stair-segment even if already in structure phase
|
||||||
|
}
|
||||||
} else if (node.type === 'item') {
|
} else if (node.type === 'item') {
|
||||||
const item = node as ItemNode
|
const item = node as ItemNode
|
||||||
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
if (item.asset.category === 'door' || item.asset.category === 'window') {
|
||||||
@@ -461,6 +589,8 @@ export const SelectionManager = () => {
|
|||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
'roof-segment',
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
'zone',
|
'zone',
|
||||||
@@ -529,6 +659,8 @@ export const SelectionManager = () => {
|
|||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
'roof-segment',
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
'window',
|
'window',
|
||||||
'door',
|
'door',
|
||||||
'zone',
|
'zone',
|
||||||
@@ -553,6 +685,7 @@ export const SelectionManager = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SelectionStateSync />
|
<SelectionStateSync />
|
||||||
|
<SelectionMaterialSync />
|
||||||
<EditorOutlinerSync />
|
<EditorOutlinerSync />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -590,9 +723,127 @@ const SelectionStateSync = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SelectionMaterialSync = () => {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||||
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
|
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
||||||
|
const activeHighlightKindsRef = useRef(new Map<string, HighlightKind>())
|
||||||
|
const highlightedMaterialsRef = useRef(
|
||||||
|
new Map<
|
||||||
|
Mesh,
|
||||||
|
{
|
||||||
|
originalMaterial: Material | Material[]
|
||||||
|
highlightedMaterial: Material | Material[]
|
||||||
|
kind: HighlightKind
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const syncSelectionMaterials = useCallback(() => {
|
||||||
|
const activeMeshes = new Set<Mesh>()
|
||||||
|
|
||||||
|
for (const [id, kind] of activeHighlightKindsRef.current.entries()) {
|
||||||
|
const node = useScene.getState().nodes[id as AnyNodeId]
|
||||||
|
if (node?.type === 'wall') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootObject = sceneRegistry.nodes.get(id)
|
||||||
|
if (!rootObject) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rootObject.traverse((child) => {
|
||||||
|
if (!isHighlightableMesh(child)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeMeshes.add(child)
|
||||||
|
const existingEntry = highlightedMaterialsRef.current.get(child)
|
||||||
|
if (existingEntry) {
|
||||||
|
const materialWasOverwritten = child.material !== existingEntry.highlightedMaterial
|
||||||
|
if (materialWasOverwritten || existingEntry.kind !== kind) {
|
||||||
|
disposeHighlightedMaterials(existingEntry.highlightedMaterial)
|
||||||
|
const originalMaterial = materialWasOverwritten
|
||||||
|
? child.material
|
||||||
|
: existingEntry.originalMaterial
|
||||||
|
const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind)
|
||||||
|
child.material = highlightedMaterial
|
||||||
|
highlightedMaterialsRef.current.set(child, {
|
||||||
|
originalMaterial,
|
||||||
|
highlightedMaterial,
|
||||||
|
kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalMaterial = child.material
|
||||||
|
const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind)
|
||||||
|
child.material = highlightedMaterial
|
||||||
|
highlightedMaterialsRef.current.set(child, {
|
||||||
|
originalMaterial,
|
||||||
|
highlightedMaterial,
|
||||||
|
kind,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|
||||||
|
if (activeMeshes.has(mesh)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mesh.material === entry.highlightedMaterial) {
|
||||||
|
mesh.material = entry.originalMaterial
|
||||||
|
}
|
||||||
|
disposeHighlightedMaterials(entry.highlightedMaterial)
|
||||||
|
highlightedMaterialsRef.current.delete(mesh)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextHighlightKinds = new Map<string, HighlightKind>()
|
||||||
|
|
||||||
|
for (const id of new Set([...selectedIds, ...previewSelectedIds])) {
|
||||||
|
nextHighlightKinds.set(id, 'selection')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverHighlightMode === 'delete' && hoveredId) {
|
||||||
|
nextHighlightKinds.set(hoveredId, 'delete')
|
||||||
|
}
|
||||||
|
|
||||||
|
activeHighlightKindsRef.current = nextHighlightKinds
|
||||||
|
syncSelectionMaterials()
|
||||||
|
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return useScene.subscribe(() => {
|
||||||
|
syncSelectionMaterials()
|
||||||
|
})
|
||||||
|
}, [syncSelectionMaterials])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) {
|
||||||
|
if (mesh.material === entry.highlightedMaterial) {
|
||||||
|
mesh.material = entry.originalMaterial
|
||||||
|
}
|
||||||
|
disposeHighlightedMaterials(entry.highlightedMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightedMaterialsRef.current.clear()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const EditorOutlinerSync = () => {
|
const EditorOutlinerSync = () => {
|
||||||
const phase = useEditor((s) => s.phase)
|
const phase = useEditor((s) => s.phase)
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||||
const hoveredId = useViewer((s) => s.hoveredId)
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
const outliner = useViewer((s) => s.outliner)
|
const outliner = useViewer((s) => s.outliner)
|
||||||
|
|
||||||
@@ -609,19 +860,23 @@ const EditorOutlinerSync = () => {
|
|||||||
case 'structure':
|
case 'structure':
|
||||||
// Highlight selected items (walls/slabs)
|
// Highlight selected items (walls/slabs)
|
||||||
// We IGNORE buildingId even if it's set in the store
|
// We IGNORE buildingId even if it's set in the store
|
||||||
idsToHighlight = selection.selectedIds
|
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'furnish':
|
case 'furnish':
|
||||||
// Highlight selected furniture/items
|
// Highlight selected furniture/items
|
||||||
idsToHighlight = selection.selectedIds
|
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Pure Viewer mode: Highlight based on the "deepest" selection
|
// Pure Viewer mode: Highlight based on the "deepest" selection
|
||||||
if (selection.selectedIds.length > 0) idsToHighlight = selection.selectedIds
|
if (selection.selectedIds.length > 0 || previewSelectedIds.length > 0) {
|
||||||
else if (selection.levelId) idsToHighlight = [selection.levelId]
|
idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
|
||||||
else if (selection.buildingId) idsToHighlight = [selection.buildingId]
|
} else if (selection.levelId) {
|
||||||
|
idsToHighlight = [selection.levelId]
|
||||||
|
} else if (selection.buildingId) {
|
||||||
|
idsToHighlight = [selection.buildingId]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||||
@@ -636,7 +891,7 @@ const EditorOutlinerSync = () => {
|
|||||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||||
if (obj) outliner.hoveredObjects.push(obj)
|
if (obj) outliner.hoveredObjects.push(obj)
|
||||||
}
|
}
|
||||||
}, [phase, selection, hoveredId, outliner])
|
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
-1
@@ -58,7 +58,6 @@ export function WallMeasurementLabel() {
|
|||||||
|
|
||||||
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
|
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset cached object when selection changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWallObject(null)
|
setWallObject(null)
|
||||||
}, [selectedId])
|
}, [selectedId])
|
||||||
|
|||||||
Regular → Executable
+142
-16
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useScene, type ZoneNode } from '@pascal-app/core'
|
import { type AnyNodeId, emitter, useScene, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Check, Pencil } from 'lucide-react'
|
import { Check, Pencil } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
// ─── Per-zone label editor ────────────────────────────────────────────────────
|
// ─── Per-zone label editor ────────────────────────────────────────────────────
|
||||||
@@ -13,7 +14,13 @@ import useEditor from '../../../store/use-editor'
|
|||||||
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||||
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
|
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const selectedZoneId = useViewer((s) => s.selection.zoneId)
|
||||||
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const isSelected = selectedZoneId === zoneId
|
||||||
|
const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -27,15 +34,26 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
|||||||
|
|
||||||
// Setup: find the label element, enable pointer events, and hide the
|
// Setup: find the label element, enable pointer events, and hide the
|
||||||
// zone-renderer's own text node (children[0]) — we replace it via portal.
|
// zone-renderer's own text node (children[0]) — we replace it via portal.
|
||||||
|
// Retries via rAF because the <Html> element from drei may not exist yet at mount time.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = document.getElementById(`${zoneId}-label`)
|
let cancelled = false
|
||||||
if (!el) return
|
let textEl: HTMLElement | undefined
|
||||||
setLabelEl(el)
|
|
||||||
|
|
||||||
const textEl = el.children[0] as HTMLElement | undefined
|
const tryFind = () => {
|
||||||
|
const el = document.getElementById(`${zoneId}-label`)
|
||||||
|
if (!el) {
|
||||||
|
if (!cancelled) requestAnimationFrame(tryFind)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLabelEl(el)
|
||||||
|
textEl = el.children[0] as HTMLElement | undefined
|
||||||
if (textEl) textEl.style.display = 'none'
|
if (textEl) textEl.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
tryFind()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true
|
||||||
if (textEl) textEl.style.display = ''
|
if (textEl) textEl.style.display = ''
|
||||||
}
|
}
|
||||||
}, [zoneId])
|
}, [zoneId])
|
||||||
@@ -48,6 +66,29 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
|||||||
}
|
}
|
||||||
}, [editing])
|
}, [editing])
|
||||||
|
|
||||||
|
// Tint the label pin red when delete-hovered
|
||||||
|
useEffect(() => {
|
||||||
|
if (!labelEl) return
|
||||||
|
const pin = labelEl.querySelector('.label-pin') as HTMLElement | null
|
||||||
|
if (!pin) return
|
||||||
|
const line = pin.children[0] as HTMLElement | undefined
|
||||||
|
const circle = pin.children[1] as HTMLElement | undefined
|
||||||
|
const color = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1')
|
||||||
|
if (line) line.style.backgroundColor = color
|
||||||
|
if (circle) {
|
||||||
|
circle.style.backgroundColor = color
|
||||||
|
}
|
||||||
|
if (isDeleteHovered) {
|
||||||
|
pin.style.opacity = '1'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// Restore zone color
|
||||||
|
const originalColor = zone?.color ?? '#6366f1'
|
||||||
|
if (line) line.style.backgroundColor = originalColor
|
||||||
|
if (circle) circle.style.backgroundColor = originalColor
|
||||||
|
}
|
||||||
|
}, [isDeleteHovered, labelEl, zone?.color])
|
||||||
|
|
||||||
const save = useCallback(() => {
|
const save = useCallback(() => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (trimmed !== (zone?.name ?? '')) {
|
if (trimmed !== (zone?.name ?? '')) {
|
||||||
@@ -61,9 +102,38 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
|||||||
setEditing(false)
|
setEditing(false)
|
||||||
}, [zone?.name])
|
}, [zone?.name])
|
||||||
|
|
||||||
|
// Select zone + switch to zone mode from any mode
|
||||||
|
const selectZone = useCallback(() => {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
useEditor.getState().setStructureLayer('zones')
|
||||||
|
useEditor.getState().setMode('select')
|
||||||
|
setSelection({ zoneId })
|
||||||
|
}, [zoneId, setSelection])
|
||||||
|
|
||||||
|
// Enter text editing
|
||||||
|
const enterTextEditing = useCallback(() => {
|
||||||
|
selectZone()
|
||||||
|
setValue(zoneNameRef.current)
|
||||||
|
setEditing(true)
|
||||||
|
}, [selectZone])
|
||||||
|
|
||||||
|
// Listen for edit-label events from the 2D floorplan (double-click on zone label)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (event: { zoneId: string }) => {
|
||||||
|
if (event.zoneId === zoneId) {
|
||||||
|
setValue(zoneNameRef.current)
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitter.on('zone:edit-label' as any, handler as any)
|
||||||
|
return () => {
|
||||||
|
emitter.off('zone:edit-label' as any, handler as any)
|
||||||
|
}
|
||||||
|
}, [zoneId])
|
||||||
|
|
||||||
if (!labelEl) return null
|
if (!labelEl) return null
|
||||||
|
|
||||||
const shadowColor = zone?.color ?? '#6366f1'
|
const shadowColor = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1')
|
||||||
const textShadow = [
|
const textShadow = [
|
||||||
`-1px -1px 0 ${shadowColor}`,
|
`-1px -1px 0 ${shadowColor}`,
|
||||||
` 1px -1px 0 ${shadowColor}`,
|
` 1px -1px 0 ${shadowColor}`,
|
||||||
@@ -151,18 +221,78 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setSelection({ zoneId })
|
if (mode === 'delete') {
|
||||||
setValue(zoneNameRef.current)
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
setEditing(true)
|
deleteNode(zoneId as AnyNodeId)
|
||||||
|
setSelection({ zoneId: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSelected) {
|
||||||
|
// Already selected → enter text editing
|
||||||
|
enterTextEditing()
|
||||||
|
} else {
|
||||||
|
// Not selected → select zone + switch to zone mode
|
||||||
|
selectZone()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
|
onPointerEnter={(e) => {
|
||||||
|
if (mode === 'delete') {
|
||||||
|
useViewer.setState({ hoveredId: zoneId })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
if (mode === 'delete' && useViewer.getState().hoveredId === zoneId) {
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerMove={
|
||||||
|
mode === 'delete'
|
||||||
|
? (e) => {
|
||||||
|
// Re-dispatch pointermove to the viewer container so DeleteCursorBadge tracks the cursor.
|
||||||
|
const viewerDiv = (e.currentTarget as HTMLElement).closest(
|
||||||
|
'.relative.overflow-hidden',
|
||||||
|
)
|
||||||
|
if (viewerDiv) {
|
||||||
|
viewerDiv.dispatchEvent(
|
||||||
|
new PointerEvent('pointermove', {
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY,
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
...sharedStyle,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{zone?.name}</span>
|
<span>{zone?.name}</span>
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
|
{isSelected && (
|
||||||
<Pencil size={10} />
|
<span
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
enterTextEditing()
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'text',
|
||||||
|
filter: `drop-shadow(0 0 2px ${shadowColor})`,
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
labelEl,
|
labelEl,
|
||||||
@@ -179,10 +309,6 @@ export function ZoneLabelEditorSystem() {
|
|||||||
.map((n) => n.id as ZoneNode['id']),
|
.map((n) => n.id as ZoneNode['id']),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const structureLayer = useEditor((s) => s.structureLayer)
|
|
||||||
const mode = useEditor((s) => s.mode)
|
|
||||||
|
|
||||||
if (structureLayer !== 'zones' || mode !== 'select') return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Regular → Executable
+61
-15
@@ -1,17 +1,26 @@
|
|||||||
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import { type Group, MathUtils, type Mesh } from 'three'
|
||||||
|
import type { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
export const ZoneSystem = () => {
|
// Disable raycasting on zone geometry so clicks pass through to items underneath.
|
||||||
useFrame(() => {
|
// Zone selection in the editor is handled exclusively via the HTML label overlay.
|
||||||
const structureLayer = useEditor.getState().structureLayer
|
const noopRaycast = () => {}
|
||||||
const levelMode = useViewer.getState().levelMode
|
|
||||||
const selectedLevelId = useViewer.getState().selection.levelId
|
|
||||||
|
|
||||||
const visible = structureLayer === 'zones'
|
export const ZoneSystem = () => {
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const structureLayer = useEditor.getState().structureLayer
|
||||||
|
const editorMode = useEditor.getState().mode
|
||||||
|
const selectedLevelId = useViewer.getState().selection.levelId
|
||||||
|
const selectedZoneId = useViewer.getState().selection.zoneId
|
||||||
|
const hoveredId = useViewer.getState().hoveredId
|
||||||
|
|
||||||
|
const zoneGeometryVisible = structureLayer === 'zones'
|
||||||
const zones = sceneRegistry.byType.zone || new Set()
|
const zones = sceneRegistry.byType.zone || new Set()
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
|
const lerpSpeed = 10 * delta
|
||||||
|
|
||||||
zones.forEach((zoneId) => {
|
zones.forEach((zoneId) => {
|
||||||
const obj = sceneRegistry.nodes.get(zoneId)
|
const obj = sceneRegistry.nodes.get(zoneId)
|
||||||
@@ -19,20 +28,57 @@ export const ZoneSystem = () => {
|
|||||||
|
|
||||||
const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined
|
const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined
|
||||||
|
|
||||||
// In solo mode, hide labels for zones not on the current level
|
|
||||||
const isOnSelectedLevel = zone?.parentId === selectedLevelId
|
const isOnSelectedLevel = zone?.parentId === selectedLevelId
|
||||||
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel
|
const isSelected = zoneId === selectedZoneId
|
||||||
|
const isDeleteHovered = editorMode === 'delete' && hoveredId === zoneId
|
||||||
|
|
||||||
if (obj.visible !== visible) {
|
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
|
||||||
obj.visible = visible
|
// Show meshes when: in zone mode, selected, or delete-hovered.
|
||||||
|
if (!obj.visible) obj.visible = true
|
||||||
|
const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered
|
||||||
|
const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0
|
||||||
|
|
||||||
|
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
|
||||||
|
if (walls) {
|
||||||
|
walls.visible = meshVisible
|
||||||
|
const material = walls.material as MeshBasicNodeMaterial
|
||||||
|
if (material?.userData?.uOpacity) {
|
||||||
|
material.userData.uOpacity.value = MathUtils.lerp(
|
||||||
|
material.userData.uOpacity.value,
|
||||||
|
targetOpacity,
|
||||||
|
lerpSpeed,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide label if zone layer is off OR if in solo mode on a different level
|
const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
|
||||||
const showLabel = visible && !hideInSoloMode
|
if (floor) {
|
||||||
const targetOpacity = showLabel ? '1' : '0'
|
floor.visible = meshVisible
|
||||||
|
const material = floor.material as MeshBasicNodeMaterial
|
||||||
|
if (material?.userData?.uOpacity) {
|
||||||
|
material.userData.uOpacity.value = MathUtils.lerp(
|
||||||
|
material.userData.uOpacity.value,
|
||||||
|
targetOpacity,
|
||||||
|
lerpSpeed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable raycasting once per zone object so geometry never intercepts clicks
|
||||||
|
if (!obj.userData.__raycastDisabled) {
|
||||||
|
obj.raycast = noopRaycast
|
||||||
|
obj.traverse((child) => {
|
||||||
|
child.raycast = noopRaycast
|
||||||
|
})
|
||||||
|
obj.userData.__raycastDisabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels: always visible on the current level (regardless of mode)
|
||||||
|
const showLabel = !!selectedLevelId && isOnSelectedLevel
|
||||||
|
const labelOpacity = showLabel ? '1' : '0'
|
||||||
const labelEl = document.getElementById(`${zoneId}-label`)
|
const labelEl = document.getElementById(`${zoneId}-label`)
|
||||||
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
if (labelEl && labelEl.style.opacity !== labelOpacity) {
|
||||||
labelEl.style.opacity = targetOpacity
|
labelEl.style.opacity = labelOpacity
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { DoorNode, ItemNode, RoofNode, RoofSegmentNode, WindowNode } from '@pascal-app/core'
|
import type {
|
||||||
|
DoorNode,
|
||||||
|
ItemNode,
|
||||||
|
RoofNode,
|
||||||
|
RoofSegmentNode,
|
||||||
|
StairNode,
|
||||||
|
StairSegmentNode,
|
||||||
|
WindowNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
@@ -76,5 +84,7 @@ export const MoveTool: React.FC = () => {
|
|||||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||||
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||||
|
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
||||||
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
useSpatialQuery,
|
useSpatialQuery,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
@@ -219,6 +220,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) draft.position = result.gridPosition
|
if (draft) draft.position = result.gridPosition
|
||||||
|
|
||||||
|
// Publish live transform for 2D floorplan
|
||||||
|
if (draft) {
|
||||||
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
|
position: result.gridPosition,
|
||||||
|
rotation: cursorGroupRef.current.rotation.y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
revalidate()
|
revalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +238,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// Preserve cursor rotation for the next draft
|
// Preserve cursor rotation for the next draft
|
||||||
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
|
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
|
||||||
|
|
||||||
|
// Clear live transform before commit
|
||||||
|
if (draftNode.current) {
|
||||||
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||||
|
}
|
||||||
|
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
if (configRef.current.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
draftNode.create(gridPosition.current, asset, currentRotation)
|
draftNode.create(gridPosition.current, asset, currentRotation)
|
||||||
@@ -353,6 +367,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (result.dirtyNodeId && posChanged) {
|
if (result.dirtyNodeId && posChanged) {
|
||||||
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish live transform for 2D floorplan
|
||||||
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
|
position: result.cursorPosition,
|
||||||
|
rotation: result.cursorRotationY,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +381,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
// Clear live transform before commit
|
||||||
|
if (draftNode.current) {
|
||||||
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||||
|
}
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
if (result.dirtyNodeId) {
|
if (result.dirtyNodeId) {
|
||||||
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
useScene.getState().dirtyNodes.add(result.dirtyNodeId)
|
||||||
@@ -470,6 +494,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draft.position = result.gridPosition
|
draft.position = result.gridPosition
|
||||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
if (mesh) mesh.position.set(...result.gridPosition)
|
if (mesh) mesh.position.set(...result.gridPosition)
|
||||||
|
|
||||||
|
// Publish live transform for 2D floorplan
|
||||||
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
|
position: result.cursorPosition,
|
||||||
|
rotation: result.cursorRotationY,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidate()
|
revalidate()
|
||||||
@@ -508,6 +538,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
// Clear live transform before commit
|
||||||
|
if (draftNode.current) {
|
||||||
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||||
|
}
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
|
|
||||||
if (configRef.current.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
@@ -578,6 +612,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draft.position = result.gridPosition
|
draft.position = result.gridPosition
|
||||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
if (mesh) mesh.position.copy(gridPosition.current)
|
if (mesh) mesh.position.copy(gridPosition.current)
|
||||||
|
|
||||||
|
// Publish live transform for 2D floorplan
|
||||||
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
|
position: result.cursorPosition,
|
||||||
|
rotation: cursorGroupRef.current.rotation.y,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,6 +626,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
// Clear live transform before commit
|
||||||
|
if (draftNode.current) {
|
||||||
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||||
|
}
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
|
|
||||||
if (configRef.current.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
@@ -657,6 +701,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
cursorGroupRef.current.rotation.y = newRotationY
|
cursorGroupRef.current.rotation.y = newRotationY
|
||||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
if (mesh) mesh.rotation.y = newRotationY
|
if (mesh) mesh.rotation.y = newRotationY
|
||||||
|
|
||||||
|
// Update live transform rotation for 2D floorplan
|
||||||
|
const currentLive = useLiveTransforms.getState().get(draft.id)
|
||||||
|
if (currentLive) {
|
||||||
|
useLiveTransforms.getState().set(draft.id, {
|
||||||
|
...currentLive,
|
||||||
|
rotation: newRotationY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
revalidate()
|
revalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -693,7 +747,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
boxGeometry.translate(0, dims[1] / 2, 0)
|
const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
||||||
|
boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
||||||
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
||||||
edgesRef.current.geometry = edgesGeometry
|
edgesRef.current.geometry = edgesGeometry
|
||||||
|
|
||||||
@@ -715,6 +770,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
emitter.on('ceiling:leave', onCeilingLeave)
|
emitter.on('ceiling:leave', onCeilingLeave)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
// Clear live transform for any remaining draft
|
||||||
|
if (draftNode.current) {
|
||||||
|
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||||
|
}
|
||||||
draftNode.destroy()
|
draftNode.destroy()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
@@ -793,16 +852,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
? getScaledDimensions(initialDraft)
|
? getScaledDimensions(initialDraft)
|
||||||
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||||
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
initialBoxGeometry.translate(0, dims[1] / 2, 0)
|
const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
||||||
|
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
||||||
|
|
||||||
// Base plane geometry (colored rectangle on the ground)
|
// Base plane geometry (colored rectangle on the ground)
|
||||||
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
||||||
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
||||||
basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting
|
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={cursorGroupRef}>
|
<group ref={cursorGroupRef}>
|
||||||
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef}>
|
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
|
||||||
<edgesGeometry args={[initialBoxGeometry]} />
|
<edgesGeometry args={[initialBoxGeometry]} />
|
||||||
</lineSegments>
|
</lineSegments>
|
||||||
<mesh
|
<mesh
|
||||||
@@ -810,6 +870,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
layers={EDITOR_LAYER}
|
layers={EDITOR_LAYER}
|
||||||
material={basePlaneMaterial}
|
material={basePlaneMaterial}
|
||||||
ref={basePlaneRef}
|
ref={basePlaneRef}
|
||||||
|
renderOrder={999}
|
||||||
/>
|
/>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
type GridEvent,
|
type GridEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
|
type StairNode,
|
||||||
|
type StairSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
@@ -14,9 +17,9 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
export const MoveRoofTool: React.FC<{
|
||||||
node: movingNode,
|
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
|
||||||
}) => {
|
}> = ({ node: movingNode }) => {
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -31,7 +34,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
|||||||
return [pos.x, pos.y, pos.z]
|
return [pos.x, pos.y, pos.z]
|
||||||
}
|
}
|
||||||
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
|
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
|
||||||
if (movingNode.type === 'roof-segment' && movingNode.parentId) {
|
if (
|
||||||
|
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
||||||
|
movingNode.parentId
|
||||||
|
) {
|
||||||
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
||||||
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
||||||
const parentAngle = parentNode.rotation as number
|
const parentAngle = parentNode.rotation as number
|
||||||
@@ -95,13 +101,14 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
|||||||
// user sees the individual segment tracking the cursor.
|
// user sees the individual segment tracking the cursor.
|
||||||
let segmentWrapperGroup: THREE.Object3D | null = null
|
let segmentWrapperGroup: THREE.Object3D | null = null
|
||||||
let mergedRoofMesh: THREE.Object3D | null = null
|
let mergedRoofMesh: THREE.Object3D | null = null
|
||||||
if (movingNode.type === 'roof-segment') {
|
if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
|
||||||
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
|
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
|
||||||
if (segmentMesh?.parent) {
|
if (segmentMesh?.parent) {
|
||||||
// segmentMesh.parent = <group visible={isSelected}> wrapper in RoofRenderer
|
// segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
|
||||||
// segmentMesh.parent.parent = the registered roof group
|
// segmentMesh.parent.parent = the registered roof/stair group
|
||||||
segmentWrapperGroup = segmentMesh.parent
|
segmentWrapperGroup = segmentMesh.parent
|
||||||
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName('merged-roof') ?? null
|
const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
|
||||||
|
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
|
||||||
segmentWrapperGroup.visible = true
|
segmentWrapperGroup.visible = true
|
||||||
if (mergedRoofMesh) mergedRoofMesh.visible = false
|
if (mergedRoofMesh) mergedRoofMesh.visible = false
|
||||||
}
|
}
|
||||||
@@ -111,7 +118,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
|||||||
let localX = gridX
|
let localX = gridX
|
||||||
let localZ = gridZ
|
let localZ = gridZ
|
||||||
|
|
||||||
if (movingNode.type === 'roof-segment' && movingNode.parentId) {
|
if (
|
||||||
|
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
||||||
|
movingNode.parentId
|
||||||
|
) {
|
||||||
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
||||||
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
|
||||||
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
|
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
|
||||||
@@ -156,6 +166,12 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
|||||||
mesh.position.x = localX
|
mesh.position.x = localX
|
||||||
mesh.position.z = localZ
|
mesh.position.z = localZ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish world-space position so the 2D floorplan can track the drag
|
||||||
|
useLiveTransforms.getState().set(movingNode.id, {
|
||||||
|
position: [gridX, y, gridZ],
|
||||||
|
rotation: pendingRotation,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
@@ -181,11 +197,13 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
|||||||
|
|
||||||
sfxEmitter.emit('sfx:item-place')
|
sfxEmitter.emit('sfx:item-place')
|
||||||
useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
|
useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
|
||||||
|
useLiveTransforms.getState().clear(movingNode.id)
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
useLiveTransforms.getState().clear(movingNode.id)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingNode.id)
|
useScene.getState().deleteNode(movingNode.id)
|
||||||
} else {
|
} else {
|
||||||
@@ -218,6 +236,15 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
|||||||
// Directly update the Three.js mesh — no store update during drag
|
// Directly update the Three.js mesh — no store update during drag
|
||||||
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
const mesh = sceneRegistry.nodes.get(movingNode.id)
|
||||||
if (mesh) mesh.rotation.y = pendingRotation
|
if (mesh) mesh.rotation.y = pendingRotation
|
||||||
|
|
||||||
|
// Update live transform rotation for 2D floorplan
|
||||||
|
const currentLive = useLiveTransforms.getState().get(movingNode.id)
|
||||||
|
if (currentLive) {
|
||||||
|
useLiveTransforms.getState().set(movingNode.id, {
|
||||||
|
...currentLive,
|
||||||
|
rotation: pendingRotation,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +258,9 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
|
|||||||
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
|
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
|
||||||
if (mergedRoofMesh) mergedRoofMesh.visible = true
|
if (mergedRoofMesh) mergedRoofMesh.visible = true
|
||||||
|
|
||||||
|
// Clear ephemeral live transform
|
||||||
|
useLiveTransforms.getState().clear(movingNode.id)
|
||||||
|
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingNode.id)
|
useScene.getState().deleteNode(movingNode.id)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
|
Box3,
|
||||||
BufferAttribute,
|
BufferAttribute,
|
||||||
BufferGeometry,
|
BufferGeometry,
|
||||||
DoubleSide,
|
DoubleSide,
|
||||||
@@ -151,6 +152,7 @@ function pointInPolygon(x: number, z: number, polygon: [number, number][]): bool
|
|||||||
// ── Node-in-bounds checks ───────────────────────────────────────────────────
|
// ── Node-in-bounds checks ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const _tempVec = new Vector3()
|
const _tempVec = new Vector3()
|
||||||
|
const _tempBox = new Box3()
|
||||||
|
|
||||||
function getNodeWorldXZ(nodeId: string): [number, number] | null {
|
function getNodeWorldXZ(nodeId: string): [number, number] | null {
|
||||||
const obj = sceneRegistry.nodes.get(nodeId)
|
const obj = sceneRegistry.nodes.get(nodeId)
|
||||||
@@ -159,6 +161,26 @@ function getNodeWorldXZ(nodeId: string): [number, number] | null {
|
|||||||
return [_tempVec.x, _tempVec.z]
|
return [_tempVec.x, _tempVec.z]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean {
|
||||||
|
const obj = sceneRegistry.nodes.get(nodeId)
|
||||||
|
if (!obj) return false
|
||||||
|
|
||||||
|
obj.updateWorldMatrix(true, true)
|
||||||
|
_tempBox.setFromObject(obj)
|
||||||
|
|
||||||
|
if (_tempBox.isEmpty()) {
|
||||||
|
const xz = getNodeWorldXZ(nodeId)
|
||||||
|
return Boolean(xz && pointInBounds(xz[0], xz[1], bounds))
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(
|
||||||
|
_tempBox.max.x < bounds.minX ||
|
||||||
|
_tempBox.min.x > bounds.maxX ||
|
||||||
|
_tempBox.max.z < bounds.minZ ||
|
||||||
|
_tempBox.min.z > bounds.maxZ
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||||
const { levelId } = useViewer.getState().selection
|
const { levelId } = useViewer.getState().selection
|
||||||
const { nodes } = useScene.getState()
|
const { nodes } = useScene.getState()
|
||||||
@@ -214,6 +236,10 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|||||||
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||||
result.push(node.id)
|
result.push(node.id)
|
||||||
}
|
}
|
||||||
|
} else if (node.type === 'stair') {
|
||||||
|
if (objectBoundsIntersectsBounds(node.id, bounds)) {
|
||||||
|
result.push(node.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (phase === 'structure' && structureLayer === 'zones') {
|
} else if (phase === 'structure' && structureLayer === 'zones') {
|
||||||
@@ -243,6 +269,13 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
|
||||||
|
return (
|
||||||
|
currentIds.length === nextIds.length &&
|
||||||
|
currentIds.every((currentId, index) => currentId === nextIds[index])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Visual helpers ──────────────────────────────────────────────────────────
|
// ── Visual helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function updateRectVisuals(
|
function updateRectVisuals(
|
||||||
@@ -300,11 +333,11 @@ function createOutlineSegments(): LineSegments {
|
|||||||
geo.setAttribute('position', new BufferAttribute(positions, 3))
|
geo.setAttribute('position', new BufferAttribute(positions, 3))
|
||||||
|
|
||||||
const mat = new LineBasicMaterial({
|
const mat = new LineBasicMaterial({
|
||||||
color: '#818cf8',
|
color: BOX_SELECT_ACCENT_COLOR,
|
||||||
depthTest: false,
|
depthTest: false,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.6,
|
opacity: 0.85,
|
||||||
})
|
})
|
||||||
|
|
||||||
const segments = new LineSegments(geo, mat)
|
const segments = new LineSegments(geo, mat)
|
||||||
@@ -318,8 +351,18 @@ function createOutlineSegments(): LineSegments {
|
|||||||
|
|
||||||
// ── Drag threshold (pixels) ─────────────────────────────────────────────────
|
// ── Drag threshold (pixels) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
|
||||||
const DRAG_THRESHOLD_PX = 4
|
const DRAG_THRESHOLD_PX = 4
|
||||||
|
|
||||||
|
function getSnappedGridPosition(x: number, z: number): [number, number] {
|
||||||
|
return [Math.round(x * 2) / 2, Math.round(z * 2) / 2]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
|
||||||
|
const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
|
||||||
|
target.set(snappedX, y, snappedZ)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ───────────────────────────────────────────────────────────────
|
// ── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const BoxSelectTool: React.FC = () => {
|
export const BoxSelectTool: React.FC = () => {
|
||||||
@@ -344,6 +387,7 @@ const BOX_SELECT_TOOLTIP = (
|
|||||||
|
|
||||||
const BoxSelectToolInner: React.FC = () => {
|
const BoxSelectToolInner: React.FC = () => {
|
||||||
const { camera, gl } = useThree()
|
const { camera, gl } = useThree()
|
||||||
|
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
||||||
const cursorRef = useRef<Group>(null)
|
const cursorRef = useRef<Group>(null)
|
||||||
const rectFillRef = useRef<Mesh>(null!)
|
const rectFillRef = useRef<Mesh>(null!)
|
||||||
const outlineRef = useRef(createOutlineSegments())
|
const outlineRef = useRef(createOutlineSegments())
|
||||||
@@ -354,7 +398,8 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
const startClientX = useRef(0)
|
const startClientX = useRef(0)
|
||||||
const startClientY = useRef(0)
|
const startClientY = useRef(0)
|
||||||
const gridY = useRef(0)
|
const gridY = useRef(0)
|
||||||
const prevHitCount = useRef(0)
|
const previousGridPosition = useRef<[number, number] | null>(null)
|
||||||
|
const previewSelectedIdsRef = useRef<string[]>([])
|
||||||
|
|
||||||
// Raycasting helpers (same technique as useGridEvents)
|
// Raycasting helpers (same technique as useGridEvents)
|
||||||
const raycasterRef = useRef(new Raycaster())
|
const raycasterRef = useRef(new Raycaster())
|
||||||
@@ -366,10 +411,21 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const outline = outlineRef.current
|
const outline = outlineRef.current
|
||||||
return () => {
|
return () => {
|
||||||
|
previewSelectedIdsRef.current = []
|
||||||
|
setPreviewSelectedIds([])
|
||||||
outline.geometry.dispose()
|
outline.geometry.dispose()
|
||||||
;(outline.material as LineBasicMaterial).dispose()
|
;(outline.material as LineBasicMaterial).dispose()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setPreviewSelectedIds])
|
||||||
|
|
||||||
|
const syncPreviewSelectedIds = (nextIds: string[]) => {
|
||||||
|
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previewSelectedIdsRef.current = nextIds
|
||||||
|
setPreviewSelectedIds(nextIds)
|
||||||
|
}
|
||||||
|
|
||||||
// Sync ground plane Y with the current level
|
// Sync ground plane Y with the current level
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -409,14 +465,15 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
const point = raycastToGround(e)
|
const point = raycastToGround(e)
|
||||||
if (!point) return
|
if (!point) return
|
||||||
|
|
||||||
startPoint.current.copy(point)
|
setSnappedPoint(startPoint.current, point.x, point.y, point.z)
|
||||||
currentPoint.current.copy(point)
|
setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
|
||||||
gridY.current = point.y
|
gridY.current = point.y
|
||||||
pointerDown.current = true
|
pointerDown.current = true
|
||||||
isDragging.current = false
|
isDragging.current = false
|
||||||
prevHitCount.current = 0
|
previousGridPosition.current = getSnappedGridPosition(point.x, point.z)
|
||||||
startClientX.current = e.clientX
|
startClientX.current = e.clientX
|
||||||
startClientY.current = e.clientY
|
startClientY.current = e.clientY
|
||||||
|
syncPreviewSelectedIds([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCanvasPointerUp = (e: PointerEvent) => {
|
const onCanvasPointerUp = (e: PointerEvent) => {
|
||||||
@@ -425,7 +482,7 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
|
|
||||||
if (isDragging.current) {
|
if (isDragging.current) {
|
||||||
const point = raycastToGround(e)
|
const point = raycastToGround(e)
|
||||||
if (point) currentPoint.current.copy(point)
|
if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
|
||||||
|
|
||||||
const bounds: Bounds = {
|
const bounds: Bounds = {
|
||||||
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
||||||
@@ -465,6 +522,7 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
// Hide visuals
|
// Hide visuals
|
||||||
if (rectFillRef.current) rectFillRef.current.visible = false
|
if (rectFillRef.current) rectFillRef.current.visible = false
|
||||||
if (outlineRef.current) outlineRef.current.visible = false
|
if (outlineRef.current) outlineRef.current.visible = false
|
||||||
|
syncPreviewSelectedIds([])
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
pointerDown.current = false
|
pointerDown.current = false
|
||||||
@@ -483,14 +541,16 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
// grid:move for cursor tracking + rectangle update during drag
|
// grid:move for cursor tracking + rectangle update during drag
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMove = (event: GridEvent) => {
|
const onMove = (event: GridEvent) => {
|
||||||
|
const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
|
||||||
|
|
||||||
// Always update cursor position
|
// Always update cursor position
|
||||||
if (cursorRef.current) {
|
if (cursorRef.current) {
|
||||||
cursorRef.current.position.set(event.position[0], event.position[1], event.position[2])
|
cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pointerDown.current) return
|
if (!pointerDown.current) return
|
||||||
|
|
||||||
currentPoint.current.set(event.position[0], event.position[1], event.position[2])
|
currentPoint.current.set(snappedX, event.position[1], snappedZ)
|
||||||
|
|
||||||
// Check drag threshold (screen pixels)
|
// Check drag threshold (screen pixels)
|
||||||
const nativeEvent = event.nativeEvent as unknown as PointerEvent
|
const nativeEvent = event.nativeEvent as unknown as PointerEvent
|
||||||
@@ -509,18 +569,23 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
gridY.current,
|
gridY.current,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Play snap sound when the set of captured nodes changes
|
const nextGridPosition: [number, number] = [snappedX, snappedZ]
|
||||||
|
if (
|
||||||
|
previousGridPosition.current &&
|
||||||
|
(nextGridPosition[0] !== previousGridPosition.current[0] ||
|
||||||
|
nextGridPosition[1] !== previousGridPosition.current[1])
|
||||||
|
) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
previousGridPosition.current = nextGridPosition
|
||||||
|
|
||||||
const bounds: Bounds = {
|
const bounds: Bounds = {
|
||||||
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
||||||
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
|
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
|
||||||
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
|
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
|
||||||
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
|
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
|
||||||
}
|
}
|
||||||
const hitCount = collectNodeIdsInBounds(bounds).length
|
syncPreviewSelectedIds(collectNodeIdsInBounds(bounds))
|
||||||
if (hitCount !== prevHitCount.current) {
|
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
|
||||||
prevHitCount.current = hitCount
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,10 +610,10 @@ const BoxSelectToolInner: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<planeGeometry args={[1, 1]} />
|
<planeGeometry args={[1, 1]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
color="#818cf8"
|
color={BOX_SELECT_ACCENT_COLOR}
|
||||||
depthTest={false}
|
depthTest={false}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
opacity={0.12}
|
opacity={0.14}
|
||||||
side={DoubleSide}
|
side={DoubleSide}
|
||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const DEFAULT_STAIR_WIDTH = 1.0
|
||||||
|
export const DEFAULT_STAIR_LENGTH = 3.0
|
||||||
|
export const DEFAULT_STAIR_HEIGHT = 2.5
|
||||||
|
export const DEFAULT_STAIR_STEP_COUNT = 10
|
||||||
|
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
|
||||||
|
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
||||||
|
export const DEFAULT_STAIR_THICKNESS = 0.25
|
||||||
@@ -12,45 +12,48 @@ import { useEffect, useMemo, useRef } from 'react'
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import {
|
||||||
|
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
||||||
|
DEFAULT_STAIR_FILL_TO_FLOOR,
|
||||||
|
DEFAULT_STAIR_HEIGHT,
|
||||||
|
DEFAULT_STAIR_LENGTH,
|
||||||
|
DEFAULT_STAIR_STEP_COUNT,
|
||||||
|
DEFAULT_STAIR_THICKNESS,
|
||||||
|
DEFAULT_STAIR_WIDTH,
|
||||||
|
} from './stair-defaults'
|
||||||
|
|
||||||
const GRID_OFFSET = 0.02
|
const GRID_OFFSET = 0.02
|
||||||
|
|
||||||
// Default stair segment dimensions
|
|
||||||
const DEFAULT_WIDTH = 1.0
|
|
||||||
const DEFAULT_LENGTH = 3.0
|
|
||||||
const DEFAULT_HEIGHT = 2.5
|
|
||||||
const DEFAULT_STEP_COUNT = 10
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the step-profile geometry for the ghost preview.
|
* Generates the step-profile geometry for the ghost preview.
|
||||||
* Same algorithm as StairSystem's generateStairSegmentGeometry.
|
* Same algorithm as StairSystem's generateStairSegmentGeometry.
|
||||||
*/
|
*/
|
||||||
function createStairPreviewGeometry(): THREE.BufferGeometry {
|
function createStairPreviewGeometry(): THREE.BufferGeometry {
|
||||||
const riserHeight = DEFAULT_HEIGHT / DEFAULT_STEP_COUNT
|
const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT
|
||||||
const treadDepth = DEFAULT_LENGTH / DEFAULT_STEP_COUNT
|
const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT
|
||||||
|
|
||||||
const shape = new THREE.Shape()
|
const shape = new THREE.Shape()
|
||||||
shape.moveTo(0, 0)
|
shape.moveTo(0, 0)
|
||||||
|
|
||||||
for (let i = 0; i < DEFAULT_STEP_COUNT; i++) {
|
for (let i = 0; i < DEFAULT_STAIR_STEP_COUNT; i++) {
|
||||||
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
|
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
|
||||||
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
|
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill to floor (absoluteHeight = 0)
|
// Fill to floor (absoluteHeight = 0)
|
||||||
shape.lineTo(DEFAULT_LENGTH, 0)
|
shape.lineTo(DEFAULT_STAIR_LENGTH, 0)
|
||||||
shape.lineTo(0, 0)
|
shape.lineTo(0, 0)
|
||||||
|
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||||
steps: 1,
|
steps: 1,
|
||||||
depth: DEFAULT_WIDTH,
|
depth: DEFAULT_STAIR_WIDTH,
|
||||||
bevelEnabled: false,
|
bevelEnabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Rotate so extrusion is along X (width), shape profile in XZ plane
|
// Rotate so extrusion is along X (width), shape profile in XZ plane
|
||||||
const matrix = new THREE.Matrix4()
|
const matrix = new THREE.Matrix4()
|
||||||
matrix.makeRotationY(-Math.PI / 2)
|
matrix.makeRotationY(-Math.PI / 2)
|
||||||
matrix.setPosition(DEFAULT_WIDTH / 2, 0, 0)
|
matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0)
|
||||||
geometry.applyMatrix4(matrix)
|
geometry.applyMatrix4(matrix)
|
||||||
|
|
||||||
return geometry
|
return geometry
|
||||||
@@ -71,12 +74,13 @@ function commitStairPlacement(
|
|||||||
|
|
||||||
const segment = StairSegmentNode.parse({
|
const segment = StairSegmentNode.parse({
|
||||||
segmentType: 'stair',
|
segmentType: 'stair',
|
||||||
width: DEFAULT_WIDTH,
|
width: DEFAULT_STAIR_WIDTH,
|
||||||
length: DEFAULT_LENGTH,
|
length: DEFAULT_STAIR_LENGTH,
|
||||||
height: DEFAULT_HEIGHT,
|
height: DEFAULT_STAIR_HEIGHT,
|
||||||
stepCount: DEFAULT_STEP_COUNT,
|
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
||||||
attachmentSide: 'front',
|
attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
|
||||||
fillToFloor: true,
|
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
||||||
|
thickness: DEFAULT_STAIR_THICKNESS,
|
||||||
position: [0, 0, 0],
|
position: [0, 0, 0],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+25
-1
@@ -1,32 +1,40 @@
|
|||||||
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
|
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
|
||||||
export type WallPlanPoint = [number, number]
|
export type WallPlanPoint = [number, number]
|
||||||
|
|
||||||
export const WALL_GRID_STEP = 0.5
|
export const WALL_GRID_STEP = 0.5
|
||||||
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
||||||
export const WALL_MIN_LENGTH = 0.5
|
export const WALL_MIN_LENGTH = 0.01
|
||||||
|
|
||||||
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
||||||
const dx = a[0] - b[0]
|
const dx = a[0] - b[0]
|
||||||
const dz = a[1] - b[1]
|
const dz = a[1] - b[1]
|
||||||
return dx * dx + dz * dz
|
return dx * dx + dz * dz
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
||||||
return Math.round(value / step) * step
|
return Math.round(value / step) * step
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {
|
export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {
|
||||||
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
|
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
|
||||||
const dx = cursor[0] - start[0]
|
const dx = cursor[0] - start[0]
|
||||||
const dz = cursor[1] - start[1]
|
const dz = cursor[1] - start[1]
|
||||||
const angle = Math.atan2(dz, dx)
|
const angle = Math.atan2(dz, dx)
|
||||||
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||||
const distance = Math.sqrt(dx * dx + dz * dz)
|
const distance = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
||||||
return snapPointToGrid([
|
return snapPointToGrid([
|
||||||
start[0] + Math.cos(snappedAngle) * distance,
|
start[0] + Math.cos(snappedAngle) * distance,
|
||||||
start[1] + Math.sin(snappedAngle) * distance,
|
start[1] + Math.sin(snappedAngle) * distance,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
||||||
const [x1, z1] = wall.start
|
const [x1, z1] = wall.start
|
||||||
const [x2, z2] = wall.end
|
const [x2, z2] = wall.end
|
||||||
@@ -36,12 +44,15 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
|
|||||||
if (lengthSquared < 1e-9) {
|
if (lengthSquared < 1e-9) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
||||||
if (t <= 0 || t >= 1) {
|
if (t <= 0 || t >= 1) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return [x1 + dx * t, z1 + dz * t]
|
return [x1 + dx * t, z1 + dz * t]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findWallSnapTarget(
|
export function findWallSnapTarget(
|
||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
@@ -51,10 +62,12 @@ export function findWallSnapTarget(
|
|||||||
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
||||||
let bestTarget: WallPlanPoint | null = null
|
let bestTarget: WallPlanPoint | null = null
|
||||||
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
for (const wall of walls) {
|
for (const wall of walls) {
|
||||||
if (ignoreWallIds.has(wall.id)) {
|
if (ignoreWallIds.has(wall.id)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates: Array<WallPlanPoint | null> = [
|
const candidates: Array<WallPlanPoint | null> = [
|
||||||
wall.start,
|
wall.start,
|
||||||
wall.end,
|
wall.end,
|
||||||
@@ -64,6 +77,7 @@ export function findWallSnapTarget(
|
|||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||||
if (
|
if (
|
||||||
candidateDistanceSquared > radiusSquared ||
|
candidateDistanceSquared > radiusSquared ||
|
||||||
@@ -71,12 +85,15 @@ export function findWallSnapTarget(
|
|||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
bestTarget = candidate
|
bestTarget = candidate
|
||||||
bestDistanceSquared = candidateDistanceSquared
|
bestDistanceSquared = candidateDistanceSquared
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestTarget
|
return bestTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapWallDraftPoint(args: {
|
export function snapWallDraftPoint(args: {
|
||||||
point: WallPlanPoint
|
point: WallPlanPoint
|
||||||
walls: WallNode[]
|
walls: WallNode[]
|
||||||
@@ -86,31 +103,38 @@ export function snapWallDraftPoint(args: {
|
|||||||
}): WallPlanPoint {
|
}): WallPlanPoint {
|
||||||
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
||||||
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
findWallSnapTarget(basePoint, walls, {
|
findWallSnapTarget(basePoint, walls, {
|
||||||
ignoreWallIds,
|
ignoreWallIds,
|
||||||
}) ?? basePoint
|
}) ?? basePoint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
|
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
|
||||||
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
|
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWallOnCurrentLevel(
|
export function createWallOnCurrentLevel(
|
||||||
start: WallPlanPoint,
|
start: WallPlanPoint,
|
||||||
end: WallPlanPoint,
|
end: WallPlanPoint,
|
||||||
): WallNode | null {
|
): WallNode | null {
|
||||||
const currentLevelId = useViewer.getState().selection.levelId
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
const { createNode, nodes } = useScene.getState()
|
const { createNode, nodes } = useScene.getState()
|
||||||
|
|
||||||
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
||||||
const wall = WallSchema.parse({
|
const wall = WallSchema.parse({
|
||||||
name: `Wall ${wallCount + 1}`,
|
name: `Wall ${wallCount + 1}`,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
createNode(wall, currentLevelId)
|
createNode(wall, currentLevelId)
|
||||||
sfxEmitter.emit('sfx:structure-build')
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
|
|
||||||
return wall
|
return wall
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+3
-8
@@ -6,12 +6,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import {
|
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
||||||
createWallOnCurrentLevel,
|
|
||||||
snapWallDraftPoint,
|
|
||||||
WALL_MIN_LENGTH,
|
|
||||||
type WallPlanPoint,
|
|
||||||
} from './wall-drafting'
|
|
||||||
|
|
||||||
const WALL_HEIGHT = 2.5
|
const WALL_HEIGHT = 2.5
|
||||||
|
|
||||||
@@ -23,7 +18,7 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
|||||||
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
||||||
const length = direction.length()
|
const length = direction.length()
|
||||||
|
|
||||||
if (length < WALL_MIN_LENGTH) {
|
if (length < 0.01) {
|
||||||
mesh.visible = false
|
mesh.visible = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -148,7 +143,7 @@ export const WallTool: React.FC = () => {
|
|||||||
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
|
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
|
||||||
const dx = endingPoint.current.x - startingPoint.current.x
|
const dx = endingPoint.current.x - startingPoint.current.x
|
||||||
const dz = endingPoint.current.z - startingPoint.current.z
|
const dz = endingPoint.current.z - startingPoint.current.z
|
||||||
if (dx * dx + dz * dz < WALL_MIN_LENGTH * WALL_MIN_LENGTH) return
|
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||||
createWallOnCurrentLevel(
|
createWallOnCurrentLevel(
|
||||||
[startingPoint.current.x, startingPoint.current.z],
|
[startingPoint.current.x, startingPoint.current.z],
|
||||||
[endingPoint.current.x, endingPoint.current.z],
|
[endingPoint.current.x, endingPoint.current.z],
|
||||||
|
|||||||
Regular → Executable
+2
-3
@@ -2,7 +2,6 @@ import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pa
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||||
import { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot'
|
|
||||||
import { EDITOR_LAYER } from './../../../lib/constants'
|
import { EDITOR_LAYER } from './../../../lib/constants'
|
||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
@@ -55,8 +54,8 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
|
|||||||
const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
|
const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
|
||||||
const name = `Zone ${zoneCount + 1}`
|
const name = `Zone ${zoneCount + 1}`
|
||||||
|
|
||||||
// Cycle through colors
|
// Default to blue, cycle through palette for subsequent zones
|
||||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length]
|
const color = '#3b82f6'
|
||||||
|
|
||||||
const zone = ZoneNode.parse({
|
const zone = ZoneNode.parse({
|
||||||
name,
|
name,
|
||||||
|
|||||||
Regular → Executable
-23
@@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
|
||||||
import { emitter } from '@pascal-app/core'
|
import { emitter } from '@pascal-app/core'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import useEditor from '../../../store/use-editor'
|
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
export function CameraActions() {
|
export function CameraActions() {
|
||||||
@@ -19,10 +17,6 @@ export function CameraActions() {
|
|||||||
emitter.emit('camera-controls:orbit-ccw')
|
emitter.emit('camera-controls:orbit-ccw')
|
||||||
}
|
}
|
||||||
|
|
||||||
const enterStreetView = () => {
|
|
||||||
useEditor.getState().setFirstPersonMode(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Orbit CCW */}
|
{/* Orbit CCW */}
|
||||||
@@ -75,23 +69,6 @@ export function CameraActions() {
|
|||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
{/* Street View */}
|
|
||||||
<ActionButton
|
|
||||||
className="group hover:bg-white/5"
|
|
||||||
label="Street View"
|
|
||||||
onClick={enterStreetView}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className="opacity-70 transition-opacity group-hover:opacity-100"
|
|
||||||
color="currentColor"
|
|
||||||
height={22}
|
|
||||||
icon="mdi:walk"
|
|
||||||
width={22}
|
|
||||||
/>
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+48
-1
@@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils'
|
|||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'delete'
|
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete'
|
||||||
|
|
||||||
type ControlConfig = {
|
type ControlConfig = {
|
||||||
id: ControlId
|
id: ControlId
|
||||||
@@ -54,6 +54,22 @@ const controls: ControlConfig[] = [
|
|||||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||||
activeColor: 'bg-green-500/20 text-green-400',
|
activeColor: 'bg-green-500/20 text-green-400',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'furnish',
|
||||||
|
imageSrc: '/icons/couch.png',
|
||||||
|
label: 'Furnish',
|
||||||
|
shortcut: 'F',
|
||||||
|
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||||
|
activeColor: 'bg-green-500/20 text-green-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zone',
|
||||||
|
imageSrc: '/icons/zone.png',
|
||||||
|
label: 'Zone',
|
||||||
|
shortcut: 'Z',
|
||||||
|
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||||
|
activeColor: 'bg-green-500/20 text-green-400',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'delete',
|
id: 'delete',
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -82,11 +98,18 @@ export function ControlModes() {
|
|||||||
const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
|
const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
|
||||||
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
||||||
|
|
||||||
|
const structureLayer = useEditor((state) => state.structureLayer)
|
||||||
|
|
||||||
const getIsActive = (id: ControlId): boolean => {
|
const getIsActive = (id: ControlId): boolean => {
|
||||||
if (isSiteEditing) return id === 'site-edit'
|
if (isSiteEditing) return id === 'site-edit'
|
||||||
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
if (id === 'select') return mode === 'select' && selectionTool === 'click'
|
||||||
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
|
||||||
if (id === 'site-edit') return false
|
if (id === 'site-edit') return false
|
||||||
|
if (id === 'build')
|
||||||
|
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
||||||
|
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
|
||||||
|
if (id === 'zone')
|
||||||
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
||||||
return mode === id
|
return mode === id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +141,30 @@ export function ControlModes() {
|
|||||||
} else if (id === 'box-select') {
|
} else if (id === 'box-select') {
|
||||||
setMode('select')
|
setMode('select')
|
||||||
setSelectionTool('marquee')
|
setSelectionTool('marquee')
|
||||||
|
} else if (id === 'build') {
|
||||||
|
// Toggle: if already in structure build, go back to select
|
||||||
|
if (getIsActive('build')) {
|
||||||
|
setMode('select')
|
||||||
|
} else {
|
||||||
|
setPhase('structure')
|
||||||
|
setStructureLayer('elements')
|
||||||
|
setMode('build')
|
||||||
|
}
|
||||||
|
} else if (id === 'furnish') {
|
||||||
|
if (getIsActive('furnish')) {
|
||||||
|
setMode('select')
|
||||||
|
} else {
|
||||||
|
setPhase('furnish')
|
||||||
|
setMode('build')
|
||||||
|
}
|
||||||
|
} else if (id === 'zone') {
|
||||||
|
if (getIsActive('zone')) {
|
||||||
|
setMode('select')
|
||||||
|
} else {
|
||||||
|
setPhase('structure')
|
||||||
|
setStructureLayer('zones')
|
||||||
|
setMode('build')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setMode(id)
|
setMode(id)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+110
-10
@@ -8,14 +8,18 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
import { useUploadStore } from '../../../store/use-upload'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||||
|
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
||||||
|
|
||||||
// ── Helper: get guide images for the current level ──────────────────────────
|
// ── Helper: get guide images for the current level ──────────────────────────
|
||||||
|
|
||||||
function useLevelGuides(): GuideNode[] {
|
function useLevelGuides(): GuideNode[] {
|
||||||
@@ -48,12 +52,67 @@ function useLevelScans(): ScanNode[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared upload button for dropdowns ──────────────────────────────────────
|
||||||
|
|
||||||
|
function UploadButton() {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!(file && levelId)) return
|
||||||
|
e.target.value = ''
|
||||||
|
|
||||||
|
const { uploadHandler } = useUploadStore.getState()
|
||||||
|
if (!uploadHandler) return
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) return
|
||||||
|
|
||||||
|
const isScan =
|
||||||
|
file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf')
|
||||||
|
const isImage = file.type.startsWith('image/')
|
||||||
|
if (!(isScan || isImage)) return
|
||||||
|
|
||||||
|
const type = isScan ? 'scan' : 'guide'
|
||||||
|
|
||||||
|
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
|
||||||
|
if (!projectId) return
|
||||||
|
|
||||||
|
useUploadStore.getState().clearUpload(levelId)
|
||||||
|
uploadHandler(projectId, levelId, file, type)
|
||||||
|
},
|
||||||
|
[levelId],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label="Upload scan or guide image"
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
accept={ACCEPTED_FILE_TYPES}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Guides toggle + dropdown ────────────────────────────────────────────────
|
// ── Guides toggle + dropdown ────────────────────────────────────────────────
|
||||||
|
|
||||||
function GuidesControl() {
|
function GuidesControl() {
|
||||||
const showGuides = useViewer((state) => state.showGuides)
|
const showGuides = useViewer((state) => state.showGuides)
|
||||||
const setShowGuides = useViewer((state) => state.setShowGuides)
|
const setShowGuides = useViewer((state) => state.setShowGuides)
|
||||||
const updateNode = useScene((state) => state.updateNode)
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
|
const deleteNode = useScene((state) => state.deleteNode)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
const guides = useLevelGuides()
|
const guides = useLevelGuides()
|
||||||
@@ -74,7 +133,7 @@ function GuidesControl() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-r-none p-0',
|
'rounded-r-none p-0',
|
||||||
showGuides
|
showGuides
|
||||||
? 'bg-white/10'
|
? 'bg-white/15'
|
||||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||||
)}
|
)}
|
||||||
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||||
@@ -82,11 +141,16 @@ function GuidesControl() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
alt="Guides"
|
alt="Guides"
|
||||||
className="h-[28px] w-[28px] object-contain"
|
className="h-[28px] w-[28px] object-contain"
|
||||||
src="/icons/floorplan.png"
|
src="/icons/floorplan.png"
|
||||||
/>
|
/>
|
||||||
|
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
||||||
|
{guides.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
{/* Dropdown chevron */}
|
{/* Dropdown chevron */}
|
||||||
@@ -96,7 +160,13 @@ function GuidesControl() {
|
|||||||
aria-label="Guide image settings"
|
aria-label="Guide image settings"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
||||||
isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
showGuides
|
||||||
|
? isOpen
|
||||||
|
? 'bg-white/10'
|
||||||
|
: 'bg-white/5 hover:bg-white/8'
|
||||||
|
: isOpen
|
||||||
|
? 'bg-white/8'
|
||||||
|
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
||||||
)}
|
)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -116,7 +186,7 @@ function GuidesControl() {
|
|||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
||||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
|
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-foreground text-sm">Guide images</p>
|
<p className="font-medium text-foreground text-sm">Guide images</p>
|
||||||
{hasGuides && (
|
{hasGuides && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
@@ -124,13 +194,14 @@ function GuidesControl() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<UploadButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasGuides ? (
|
{hasGuides ? (
|
||||||
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
||||||
{guides.map((guide, index) => (
|
{guides.map((guide, index) => (
|
||||||
<div
|
<div
|
||||||
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
||||||
key={guide.id}
|
key={guide.id}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@@ -142,6 +213,14 @@ function GuidesControl() {
|
|||||||
<p className="truncate font-medium text-foreground text-sm">
|
<p className="truncate font-medium text-foreground text-sm">
|
||||||
{guide.name || `Guide image ${index + 1}`}
|
{guide.name || `Guide image ${index + 1}`}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
aria-label="Delete guide image"
|
||||||
|
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
||||||
|
onClick={() => deleteNode(guide.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Opacity"
|
label="Opacity"
|
||||||
@@ -173,6 +252,7 @@ function ScansControl() {
|
|||||||
const showScans = useViewer((state) => state.showScans)
|
const showScans = useViewer((state) => state.showScans)
|
||||||
const setShowScans = useViewer((state) => state.setShowScans)
|
const setShowScans = useViewer((state) => state.setShowScans)
|
||||||
const updateNode = useScene((state) => state.updateNode)
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
|
const deleteNode = useScene((state) => state.deleteNode)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
const scans = useLevelScans()
|
const scans = useLevelScans()
|
||||||
@@ -193,7 +273,7 @@ function ScansControl() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-r-none p-0',
|
'rounded-r-none p-0',
|
||||||
showScans
|
showScans
|
||||||
? 'bg-white/10'
|
? 'bg-white/15'
|
||||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||||
)}
|
)}
|
||||||
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||||
@@ -201,7 +281,12 @@ function ScansControl() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
|
<div className="relative">
|
||||||
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
||||||
|
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
||||||
|
{scans.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
{/* Dropdown chevron */}
|
{/* Dropdown chevron */}
|
||||||
@@ -211,7 +296,13 @@ function ScansControl() {
|
|||||||
aria-label="Scan settings"
|
aria-label="Scan settings"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
||||||
isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
showScans
|
||||||
|
? isOpen
|
||||||
|
? 'bg-white/10'
|
||||||
|
: 'bg-white/5 hover:bg-white/8'
|
||||||
|
: isOpen
|
||||||
|
? 'bg-white/8'
|
||||||
|
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
||||||
)}
|
)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -231,7 +322,7 @@ function ScansControl() {
|
|||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
||||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
|
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-foreground text-sm">Scans</p>
|
<p className="font-medium text-foreground text-sm">Scans</p>
|
||||||
{hasScans && (
|
{hasScans && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
@@ -239,13 +330,14 @@ function ScansControl() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<UploadButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasScans ? (
|
{hasScans ? (
|
||||||
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
<div className="max-h-56 space-y-2 overflow-y-auto pr-1">
|
||||||
{scans.map((scan, index) => (
|
{scans.map((scan, index) => (
|
||||||
<div
|
<div
|
||||||
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
|
||||||
key={scan.id}
|
key={scan.id}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@@ -257,6 +349,14 @@ function ScansControl() {
|
|||||||
<p className="truncate font-medium text-foreground text-sm">
|
<p className="truncate font-medium text-foreground text-sm">
|
||||||
{scan.name || `Scan ${index + 1}`}
|
{scan.name || `Scan ${index + 1}`}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
aria-label="Delete scan"
|
||||||
|
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
|
||||||
|
onClick={() => deleteNode(scan.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Opacity"
|
label="Opacity"
|
||||||
|
|||||||
Regular → Executable
+37
-2
@@ -3,8 +3,9 @@
|
|||||||
import type { AnyNodeId, LevelNode } from '@pascal-app/core'
|
import type { AnyNodeId, LevelNode } from '@pascal-app/core'
|
||||||
import { useScene } from '@pascal-app/core'
|
import { useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Command } from 'cmdk'
|
import { Command, useCommandState } from 'cmdk'
|
||||||
import { ChevronRight, Search } from 'lucide-react'
|
import { ChevronRight, Search } from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { useShallow } from 'zustand/shallow'
|
import { useShallow } from 'zustand/shallow'
|
||||||
@@ -160,10 +161,41 @@ const PAGE_LABEL: Record<string, string> = {
|
|||||||
'camera-scope': '',
|
'camera-scope': '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty state fallback (force-mounted, visible only when no results)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface CommandPaletteEmptyAction {
|
||||||
|
icon: ReactNode
|
||||||
|
label: (query: string) => string
|
||||||
|
onSelect: (query: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
|
||||||
|
const count = useCommandState((s) => s.filtered.count)
|
||||||
|
const search = useCommandState((s) => s.search)
|
||||||
|
if (count > 0) return null
|
||||||
|
// No Command.Group wrapper — groups hide themselves when not in filtered.groups (which is
|
||||||
|
// empty when nothing matches), swallowing the force-mounted item even with forceMount on
|
||||||
|
// the item itself.
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[selected=true]:bg-accent"
|
||||||
|
forceMount
|
||||||
|
onSelect={() => action.onSelect(search)}
|
||||||
|
value="__empty_action__"
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
{action.icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{action.label(search)}</span>
|
||||||
|
</Command.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main component
|
// Main component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export function CommandPalette() {
|
export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
|
||||||
const {
|
const {
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
@@ -353,9 +385,12 @@ export function CommandPalette() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Command.List className="max-h-100 overflow-y-auto p-1.5">
|
<Command.List className="max-h-100 overflow-y-auto p-1.5">
|
||||||
|
{(!emptyAction || page) && (
|
||||||
<Command.Empty className="py-8 text-center text-muted-foreground text-sm">
|
<Command.Empty className="py-8 text-center text-muted-foreground text-sm">
|
||||||
No commands found.
|
No commands found.
|
||||||
</Command.Empty>
|
</Command.Empty>
|
||||||
|
)}
|
||||||
|
{emptyAction && !page && <EmptyActionItem action={emptyAction} />}
|
||||||
|
|
||||||
{/* ── Registered page view (e.g. 'ai') ─────────────────────── */}
|
{/* ── Registered page view (e.g. 'ai') ─────────────────────── */}
|
||||||
{page &&
|
{page &&
|
||||||
|
|||||||
Regular → Executable
+37
-21
@@ -35,7 +35,9 @@ type MaterialPickerProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
||||||
const [showCustom, setShowCustom] = useState<boolean>(value?.preset === 'custom' || !!value?.properties)
|
const [showCustom, setShowCustom] = useState<boolean>(
|
||||||
|
value?.preset === 'custom' || !!value?.properties,
|
||||||
|
)
|
||||||
|
|
||||||
const currentPreset = value?.preset || 'white'
|
const currentPreset = value?.preset || 'white'
|
||||||
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]
|
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]
|
||||||
@@ -60,7 +62,10 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePropertyChange = (prop: keyof typeof currentProps, val: typeof currentProps[keyof typeof currentProps]) => {
|
const handlePropertyChange = (
|
||||||
|
prop: keyof typeof currentProps,
|
||||||
|
val: (typeof currentProps)[keyof typeof currentProps],
|
||||||
|
) => {
|
||||||
onChange({
|
onChange({
|
||||||
preset: showCustom ? 'custom' : currentPreset,
|
preset: showCustom ? 'custom' : currentPreset,
|
||||||
properties: {
|
properties: {
|
||||||
@@ -84,7 +89,10 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|||||||
onClick={() => handlePresetChange(preset)}
|
onClick={() => handlePresetChange(preset)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: PRESET_COLORS[preset],
|
backgroundColor: PRESET_COLORS[preset],
|
||||||
backgroundImage: preset === 'glass' ? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)' : undefined,
|
backgroundImage:
|
||||||
|
preset === 'glass'
|
||||||
|
? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)'
|
||||||
|
: undefined,
|
||||||
backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
|
backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
|
||||||
}}
|
}}
|
||||||
title={PRESET_LABELS[preset]}
|
title={PRESET_LABELS[preset]}
|
||||||
@@ -96,15 +104,15 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|||||||
{showCustom && (
|
{showCustom && (
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-500 w-16">Color</label>
|
<label className="w-16 text-gray-500 text-xs">Color</label>
|
||||||
<input
|
<input
|
||||||
className="h-7 w-12 rounded border border-gray-300 cursor-pointer"
|
className="h-7 w-12 cursor-pointer rounded border border-gray-300"
|
||||||
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
||||||
type="color"
|
type="color"
|
||||||
value={currentProps.color}
|
value={currentProps.color}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded"
|
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
||||||
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
||||||
type="text"
|
type="text"
|
||||||
value={currentProps.color}
|
value={currentProps.color}
|
||||||
@@ -112,41 +120,45 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-500 w-16">Roughness</label>
|
<label className="w-16 text-gray-500 text-xs">Roughness</label>
|
||||||
<input
|
<input
|
||||||
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
||||||
max={1}
|
max={1}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(e) => handlePropertyChange('roughness', parseFloat(e.target.value))}
|
onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
type="range"
|
type="range"
|
||||||
value={currentProps.roughness}
|
value={currentProps.roughness}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.roughness.toFixed(2)}</span>
|
<span className="w-8 text-right text-gray-400 text-xs">
|
||||||
|
{currentProps.roughness.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-500 w-16">Metalness</label>
|
<label className="w-16 text-gray-500 text-xs">Metalness</label>
|
||||||
<input
|
<input
|
||||||
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
||||||
max={1}
|
max={1}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(e) => handlePropertyChange('metalness', parseFloat(e.target.value))}
|
onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
type="range"
|
type="range"
|
||||||
value={currentProps.metalness}
|
value={currentProps.metalness}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.metalness.toFixed(2)}</span>
|
<span className="w-8 text-right text-gray-400 text-xs">
|
||||||
|
{currentProps.metalness.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-500 w-16">Opacity</label>
|
<label className="w-16 text-gray-500 text-xs">Opacity</label>
|
||||||
<input
|
<input
|
||||||
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
||||||
max={1}
|
max={1}
|
||||||
min={0}
|
min={0}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const opacity = parseFloat(e.target.value)
|
const opacity = Number.parseFloat(e.target.value)
|
||||||
handlePropertyChange('opacity', opacity)
|
handlePropertyChange('opacity', opacity)
|
||||||
if (opacity < 1 && !currentProps.transparent) {
|
if (opacity < 1 && !currentProps.transparent) {
|
||||||
handlePropertyChange('transparent', true)
|
handlePropertyChange('transparent', true)
|
||||||
@@ -156,14 +168,18 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
|
|||||||
type="range"
|
type="range"
|
||||||
value={currentProps.opacity}
|
value={currentProps.opacity}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.opacity.toFixed(2)}</span>
|
<span className="w-8 text-right text-gray-400 text-xs">
|
||||||
|
{currentProps.opacity.toFixed(2)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-500 w-16">Side</label>
|
<label className="w-16 text-gray-500 text-xs">Side</label>
|
||||||
<select
|
<select
|
||||||
className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded"
|
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
||||||
onChange={(e) => handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')}
|
onChange={(e) =>
|
||||||
|
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
|
||||||
|
}
|
||||||
value={currentProps.side}
|
value={currentProps.side}
|
||||||
>
|
>
|
||||||
<option value="front">Front</option>
|
<option value="front">Front</option>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ interface SliderControlProps {
|
|||||||
unit?: string
|
unit?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stepPrecision(s: number): number {
|
||||||
|
if (s <= 0) return 0
|
||||||
|
return Math.max(0, Math.ceil(-Math.log10(s)))
|
||||||
|
}
|
||||||
|
|
||||||
export function SliderControl({
|
export function SliderControl({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -32,7 +37,7 @@ export function SliderControl({
|
|||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||||
|
|
||||||
const dragRef = useRef<{ accumulatedDx: number; startValue: number } | null>(null)
|
const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
|
||||||
const labelRef = useRef<HTMLDivElement>(null)
|
const labelRef = useRef<HTMLDivElement>(null)
|
||||||
const valueRef = useRef(value)
|
const valueRef = useRef(value)
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
@@ -57,7 +62,7 @@ export function SliderControl({
|
|||||||
if (e.shiftKey) s = step * 10
|
if (e.shiftKey) s = step * 10
|
||||||
else if (e.altKey) s = step * 0.1
|
else if (e.altKey) s = step * 0.1
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const newValue = clamp(valueRef.current + direction * s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(precision))
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
}
|
}
|
||||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
@@ -77,7 +82,7 @@ export function SliderControl({
|
|||||||
if (e.shiftKey) s = step * 10
|
if (e.shiftKey) s = step * 10
|
||||||
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
||||||
const newValue = clamp(valueRef.current + direction * s)
|
const newValue = clamp(valueRef.current + direction * s)
|
||||||
const final = Number.parseFloat(newValue.toFixed(precision))
|
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
|
||||||
if (final !== valueRef.current) onChange(final)
|
if (final !== valueRef.current) onChange(final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,15 +94,8 @@ export function SliderControl({
|
|||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (isEditing) return
|
if (isEditing) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// Use PointerLock for infinite dragging (Unity3D-style).
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
// Falls back to pointer capture if lock is denied.
|
dragRef.current = { startX: e.clientX, startValue: valueRef.current }
|
||||||
const el = e.currentTarget
|
|
||||||
if (el.requestPointerLock) {
|
|
||||||
el.requestPointerLock()
|
|
||||||
} else {
|
|
||||||
el.setPointerCapture(e.pointerId)
|
|
||||||
}
|
|
||||||
dragRef.current = { accumulatedDx: 0, startValue: valueRef.current }
|
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
},
|
},
|
||||||
@@ -107,15 +105,15 @@ export function SliderControl({
|
|||||||
const handleLabelPointerMove = useCallback(
|
const handleLabelPointerMove = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragRef.current) return
|
if (!dragRef.current) return
|
||||||
// Accumulate movementX for infinite dragging. movementX gives the
|
const { startX, startValue } = dragRef.current
|
||||||
// delta since the last event, independent of screen bounds.
|
const dx = e.clientX - startX
|
||||||
dragRef.current.accumulatedDx += e.movementX
|
|
||||||
const { accumulatedDx, startValue } = dragRef.current
|
|
||||||
let s = step
|
let s = step
|
||||||
if (e.shiftKey) s = step * 10
|
if (e.shiftKey) s = step * 10
|
||||||
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
||||||
// 4 px per step at default sensitivity
|
// 4 px per step at default sensitivity
|
||||||
const newValue = clamp(Number.parseFloat((startValue + (accumulatedDx / 4) * s).toFixed(precision)))
|
const newValue = clamp(
|
||||||
|
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
||||||
|
)
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
},
|
},
|
||||||
[step, precision, clamp, onChange],
|
[step, precision, clamp, onChange],
|
||||||
@@ -128,12 +126,7 @@ export function SliderControl({
|
|||||||
const finalVal = valueRef.current
|
const finalVal = valueRef.current
|
||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
|
|
||||||
if (document.pointerLockElement) {
|
|
||||||
document.exitPointerLock()
|
|
||||||
} else {
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
}
|
|
||||||
|
|
||||||
if (startValue !== finalVal) {
|
if (startValue !== finalVal) {
|
||||||
onChange(startValue)
|
onChange(startValue)
|
||||||
@@ -146,28 +139,6 @@ export function SliderControl({
|
|||||||
[onChange],
|
[onChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clean up drag state if pointer lock is lost unexpectedly (e.g. Escape key)
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePointerLockChange = () => {
|
|
||||||
if (!document.pointerLockElement && dragRef.current) {
|
|
||||||
const { startValue } = dragRef.current
|
|
||||||
const finalVal = valueRef.current
|
|
||||||
dragRef.current = null
|
|
||||||
setIsDragging(false)
|
|
||||||
|
|
||||||
if (startValue !== finalVal) {
|
|
||||||
onChange(startValue)
|
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
onChange(finalVal)
|
|
||||||
} else {
|
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
return () => document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
||||||
}, [onChange])
|
|
||||||
|
|
||||||
const handleValueClick = useCallback(() => {
|
const handleValueClick = useCallback(() => {
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
setInputValue(value.toFixed(precision))
|
setInputValue(value.toFixed(precision))
|
||||||
@@ -262,7 +233,7 @@ export function SliderControl({
|
|||||||
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
|
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
|
||||||
onClick={handleValueClick}
|
onClick={handleValueClick}
|
||||||
>
|
>
|
||||||
<span className="font-mono tabular-nums tracking-tight">
|
<span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
|
||||||
{Number(value.toFixed(precision)).toFixed(precision)}
|
{Number(value.toFixed(precision)).toFixed(precision)}
|
||||||
</span>
|
</span>
|
||||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||||
|
|||||||
Regular → Executable
+299
-20
@@ -1,20 +1,178 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type BuildingNode,
|
||||||
|
LevelNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { MoreVertical, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './primitives/dialog'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
|
||||||
|
|
||||||
function getLevelDisplayLabel(level: LevelNode) {
|
function getLevelDisplayLabel(level: LevelNode) {
|
||||||
return level.name || `Level ${level.level}`
|
return level.name || `Level ${level.level}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inline rename input for a level row ─────────────────────────────────────
|
||||||
|
|
||||||
|
function LevelInlineRename({
|
||||||
|
level,
|
||||||
|
isEditing,
|
||||||
|
onStopEditing,
|
||||||
|
}: {
|
||||||
|
level: LevelNode
|
||||||
|
isEditing: boolean
|
||||||
|
onStopEditing: () => void
|
||||||
|
}) {
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const defaultName = `Level ${level.level}`
|
||||||
|
const [value, setValue] = useState(level.name || '')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setValue(level.name || '')
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}, [isEditing, level.name])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed !== level.name) {
|
||||||
|
updateNode(level.id, { name: trimmed || undefined })
|
||||||
|
}
|
||||||
|
onStopEditing()
|
||||||
|
}, [value, level.id, level.name, updateNode, onStopEditing])
|
||||||
|
|
||||||
|
if (!isEditing) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="m-0 h-full w-full min-w-0 rounded-lg bg-transparent px-2.5 py-1.5 font-medium text-foreground text-xs outline-none ring-1 ring-primary/50"
|
||||||
|
onBlur={handleSave}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSave()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
onStopEditing()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={defaultName}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Level row with three-dot menu ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function LevelRow({
|
||||||
|
level,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
onRequestDelete,
|
||||||
|
}: {
|
||||||
|
level: LevelNode
|
||||||
|
isSelected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
onRequestDelete: () => void
|
||||||
|
}) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/level">
|
||||||
|
{isEditing ? (
|
||||||
|
<LevelInlineRename
|
||||||
|
isEditing={isEditing}
|
||||||
|
level={level}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded-lg transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-white/10 text-foreground'
|
||||||
|
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex min-w-0 flex-1 items-center justify-start px-2.5 py-1.5 font-medium text-xs"
|
||||||
|
onClick={onSelect}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsEditing(true)
|
||||||
|
}}
|
||||||
|
title={getLevelDisplayLabel(level)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate">{getLevelDisplayLabel(level)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Vertical three-dot menu — inside the pill */}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground/40 opacity-0 transition-all hover:text-foreground group-hover/level:opacity-100"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-36 p-1" side="right" sideOffset={8}>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRequestDelete()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
Delete level
|
||||||
|
</button>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function FloatingLevelSelector() {
|
export function FloatingLevelSelector() {
|
||||||
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
const levelId = useViewer((s) => s.selection.levelId)
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const createNode = useScene((s) => s.createNode)
|
||||||
|
const updateNodes = useScene((s) => s.updateNodes)
|
||||||
|
|
||||||
|
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
|
||||||
|
|
||||||
// Resolve the effective building ID — selected or first in scene (scalar, stable reference)
|
|
||||||
const resolvedBuildingId = useScene((state) => {
|
const resolvedBuildingId = useScene((state) => {
|
||||||
if (selectedBuildingId) return selectedBuildingId
|
if (selectedBuildingId) return selectedBuildingId
|
||||||
const first = Object.values(state.nodes).find((n) => n?.type === 'building') as
|
const first = Object.values(state.nodes).find((n) => n?.type === 'building') as
|
||||||
@@ -23,7 +181,6 @@ export function FloatingLevelSelector() {
|
|||||||
return first?.id ?? null
|
return first?.id ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get levels for the resolved building (array, useShallow for stable reference)
|
|
||||||
const levels = useScene(
|
const levels = useScene(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
if (!resolvedBuildingId) return [] as LevelNode[]
|
if (!resolvedBuildingId) return [] as LevelNode[]
|
||||||
@@ -36,41 +193,163 @@ export function FloatingLevelSelector() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (levels.length <= 1) return null
|
const handleAddAbove = useCallback(() => {
|
||||||
|
if (!resolvedBuildingId) return
|
||||||
|
const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1
|
||||||
|
const newLevel = LevelNode.parse({
|
||||||
|
level: maxLevel + 1,
|
||||||
|
children: [],
|
||||||
|
parentId: resolvedBuildingId,
|
||||||
|
})
|
||||||
|
createNode(newLevel, resolvedBuildingId)
|
||||||
|
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
|
||||||
|
}, [resolvedBuildingId, levels, createNode, setSelection])
|
||||||
|
|
||||||
|
const handleAddBelow = useCallback(() => {
|
||||||
|
if (!resolvedBuildingId) return
|
||||||
|
const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1
|
||||||
|
const newLevel = LevelNode.parse({
|
||||||
|
level: minLevel - 1,
|
||||||
|
children: [],
|
||||||
|
parentId: resolvedBuildingId,
|
||||||
|
})
|
||||||
|
createNode(newLevel, resolvedBuildingId)
|
||||||
|
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
|
||||||
|
}, [resolvedBuildingId, levels, createNode, setSelection])
|
||||||
|
|
||||||
|
const handleInsertBetween = useCallback(
|
||||||
|
(lowerIndex: number) => {
|
||||||
|
if (!resolvedBuildingId) return
|
||||||
|
const lower = levels[lowerIndex]
|
||||||
|
if (!lower) return
|
||||||
|
|
||||||
|
const newLevelNumber = lower.level + 1
|
||||||
|
const toShift = levels.filter((l) => l.level >= newLevelNumber)
|
||||||
|
if (toShift.length > 0) {
|
||||||
|
updateNodes(
|
||||||
|
toShift.map((l) => ({
|
||||||
|
id: l.id as AnyNodeId,
|
||||||
|
data: { level: l.level + 1 } as Partial<AnyNode>,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLevel = LevelNode.parse({
|
||||||
|
level: newLevelNumber,
|
||||||
|
children: [],
|
||||||
|
parentId: resolvedBuildingId,
|
||||||
|
})
|
||||||
|
createNode(newLevel, resolvedBuildingId)
|
||||||
|
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
|
||||||
|
},
|
||||||
|
[resolvedBuildingId, levels, createNode, updateNodes, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleConfirmDelete = useCallback(() => {
|
||||||
|
if (!deletingLevel) return
|
||||||
|
deleteLevelWithFallbackSelection(deletingLevel.id)
|
||||||
|
setDeletingLevel(null)
|
||||||
|
}, [deletingLevel])
|
||||||
|
|
||||||
|
if (levels.length === 0) return null
|
||||||
|
|
||||||
// Display highest level at top, ground at bottom
|
|
||||||
const reversedLevels = [...levels].reverse()
|
const reversedLevels = [...levels].reverse()
|
||||||
|
|
||||||
|
const addButtonClass =
|
||||||
|
'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="pointer-events-auto absolute top-14 left-3 z-20">
|
<div className="pointer-events-auto absolute top-14 left-3 z-20">
|
||||||
{/* Outer: rounded-xl (12px) with p-1 (4px) → inner: rounded-lg (8px) for concentric radii */}
|
<div className="relative">
|
||||||
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
|
{/* Floating + at top edge */}
|
||||||
{reversedLevels.map((level) => {
|
|
||||||
const isSelected = level.id === levelId
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
|
||||||
'flex min-w-[80px] items-center justify-start rounded-lg px-2.5 py-1.5 font-medium text-xs transition-colors',
|
onClick={handleAddAbove}
|
||||||
isSelected
|
title="Add level above"
|
||||||
? 'bg-white/10 text-foreground'
|
type="button"
|
||||||
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
|
>
|
||||||
)}
|
<Plus className="h-2.5 w-2.5" />
|
||||||
key={level.id}
|
</button>
|
||||||
onClick={() =>
|
|
||||||
|
{/* Floating + at bottom edge */}
|
||||||
|
<button
|
||||||
|
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
|
||||||
|
onClick={handleAddBelow}
|
||||||
|
title="Add level below"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Level list */}
|
||||||
|
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
|
||||||
|
{reversedLevels.map((level, i) => {
|
||||||
|
const isSelected = level.id === levelId
|
||||||
|
const sortedIndex = levels.indexOf(level)
|
||||||
|
const showGapBelow = i < reversedLevels.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" key={level.id}>
|
||||||
|
<LevelRow
|
||||||
|
isSelected={isSelected}
|
||||||
|
level={level}
|
||||||
|
onRequestDelete={() => setDeletingLevel(level)}
|
||||||
|
onSelect={() =>
|
||||||
setSelection(
|
setSelection(
|
||||||
resolvedBuildingId
|
resolvedBuildingId
|
||||||
? { buildingId: resolvedBuildingId, levelId: level.id }
|
? { buildingId: resolvedBuildingId, levelId: level.id }
|
||||||
: { levelId: level.id },
|
: { levelId: level.id },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={getLevelDisplayLabel(level)}
|
/>
|
||||||
|
|
||||||
|
{showGapBelow && (
|
||||||
|
<button
|
||||||
|
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
|
||||||
|
onClick={() => handleInsertBetween(sortedIndex - 1)}
|
||||||
|
title="Insert level here"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="truncate">{getLevelDisplayLabel(level)}</span>
|
<Plus className="h-2.5 w-2.5" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<Dialog onOpenChange={(open) => !open && setDeletingLevel(null)} open={!!deletingLevel}>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete level</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete{' '}
|
||||||
|
<strong>{deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}</strong>? All
|
||||||
|
walls, floors, and objects on this level will be permanently removed.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
|
||||||
|
onClick={() => setDeletingLevel(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { type AssetInput, ItemNode } from '@pascal-app/core'
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
export const CATALOG_ITEMS: AssetInput[] = [
|
export const CATALOG_ITEMS: AssetInput[] = [
|
||||||
{
|
{
|
||||||
id: 'tesla',
|
id: 'tesla',
|
||||||
|
|||||||
Regular → Executable
+10
-14
@@ -32,6 +32,13 @@ export function CeilingPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleMaterialChange = useCallback(
|
||||||
|
(material: MaterialSchema) => {
|
||||||
|
handleUpdate({ material })
|
||||||
|
},
|
||||||
|
[handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
@@ -95,10 +102,6 @@ export function CeilingPanel() {
|
|||||||
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback((material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
}, [handleUpdate])
|
|
||||||
|
|
||||||
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
const calculateArea = (polygon: Array<[number, number]>): number => {
|
const calculateArea = (polygon: Array<[number, number]>): number => {
|
||||||
@@ -107,12 +110,8 @@ export function CeilingPanel() {
|
|||||||
const n = polygon.length
|
const n = polygon.length
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const j = (i + 1) % n
|
const j = (i + 1) % n
|
||||||
const pi = polygon[i]
|
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||||
const pj = polygon[j]
|
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||||
if (pi && pj) {
|
|
||||||
area += pi[0] * pj[1]
|
|
||||||
area -= pj[0] * pi[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Math.abs(area) / 2
|
return Math.abs(area) / 2
|
||||||
}
|
}
|
||||||
@@ -224,10 +223,7 @@ export function CeilingPanel() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
<PanelSection title="Material">
|
||||||
<MaterialPicker
|
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||||
onChange={handleMaterialChange}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
Regular → Executable
+21
-12
@@ -1,6 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, type MaterialSchema, DoorNode, emitter, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
DoorNode,
|
||||||
|
emitter,
|
||||||
|
type MaterialSchema,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
@@ -39,6 +46,13 @@ export function DoorPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleMaterialChange = useCallback(
|
||||||
|
(material: MaterialSchema) => {
|
||||||
|
handleUpdate({ material })
|
||||||
|
},
|
||||||
|
[handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -80,10 +94,9 @@ export function DoorPanel() {
|
|||||||
}, [node, setMovingNode, setSelection])
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
|
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
|
||||||
if (!node) return
|
const numSegs = node?.segments.length
|
||||||
const numSegs = node.segments.length
|
const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
|
||||||
const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0)
|
const normH = node?.segments.map((s) => s.heightRatio / totalH)
|
||||||
const normH = node.segments.map((s) => s.heightRatio / totalH)
|
|
||||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||||
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
|
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
|
||||||
const delta = clamped - normH[segIdx]!
|
const delta = clamped - normH[segIdx]!
|
||||||
@@ -563,13 +576,6 @@ export function DoorPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
onChange={(material) => handleUpdate({ material })}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
@@ -586,6 +592,9 @@ export function DoorPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
<PanelSection title="Material">
|
||||||
|
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||||
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+2
-2
@@ -37,12 +37,12 @@ export function PanelManager() {
|
|||||||
return <RoofPanel />
|
return <RoofPanel />
|
||||||
case 'roof-segment':
|
case 'roof-segment':
|
||||||
return <RoofSegmentPanel />
|
return <RoofSegmentPanel />
|
||||||
case 'slab':
|
|
||||||
return <SlabPanel />
|
|
||||||
case 'stair':
|
case 'stair':
|
||||||
return <StairPanel />
|
return <StairPanel />
|
||||||
case 'stair-segment':
|
case 'stair-segment':
|
||||||
return <StairSegmentPanel />
|
return <StairSegmentPanel />
|
||||||
|
case 'slab':
|
||||||
|
return <SlabPanel />
|
||||||
case 'ceiling':
|
case 'ceiling':
|
||||||
return <CeilingPanel />
|
return <CeilingPanel />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
|
|||||||
Regular → Executable
+10
-11
@@ -41,6 +41,13 @@ export function RoofPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleMaterialChange = useCallback(
|
||||||
|
(material: MaterialSchema) => {
|
||||||
|
handleUpdate({ material })
|
||||||
|
},
|
||||||
|
[handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -124,10 +131,6 @@ export function RoofPanel() {
|
|||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [selectedId, node, setSelection])
|
}, [selectedId, node, setSelection])
|
||||||
|
|
||||||
const handleMaterialChange = useCallback((material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
}, [handleUpdate])
|
|
||||||
|
|
||||||
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
const segments = (node.children ?? [])
|
const segments = (node.children ?? [])
|
||||||
@@ -235,13 +238,6 @@ export function RoofPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
@@ -258,6 +254,9 @@ export function RoofPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
<PanelSection title="Material">
|
||||||
|
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||||
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+10
-11
@@ -55,6 +55,13 @@ export function RoofSegmentPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleMaterialChange = useCallback(
|
||||||
|
(material: MaterialSchema) => {
|
||||||
|
handleUpdate({ material })
|
||||||
|
},
|
||||||
|
[handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -110,10 +117,6 @@ export function RoofSegmentPanel() {
|
|||||||
}
|
}
|
||||||
}, [selectedId, node, setSelection])
|
}, [selectedId, node, setSelection])
|
||||||
|
|
||||||
const handleMaterialChange = useCallback((material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
}, [handleUpdate])
|
|
||||||
|
|
||||||
if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -299,13 +302,6 @@ export function RoofSegmentPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
@@ -322,6 +318,9 @@ export function RoofSegmentPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
<PanelSection title="Material">
|
||||||
|
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||||
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+8
-13
@@ -30,9 +30,12 @@ export function SlabPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback((material: MaterialSchema) => {
|
const handleMaterialChange = useCallback(
|
||||||
|
(material: MaterialSchema) => {
|
||||||
handleUpdate({ material })
|
handleUpdate({ material })
|
||||||
}, [handleUpdate])
|
},
|
||||||
|
[handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
@@ -105,12 +108,8 @@ export function SlabPanel() {
|
|||||||
const n = polygon.length
|
const n = polygon.length
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const j = (i + 1) % n
|
const j = (i + 1) % n
|
||||||
const pi = polygon[i]
|
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||||
const pj = polygon[j]
|
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||||
if (pi && pj) {
|
|
||||||
area += pi[0] * pj[1]
|
|
||||||
area -= pj[0] * pi[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Math.abs(area) / 2
|
return Math.abs(area) / 2
|
||||||
}
|
}
|
||||||
@@ -221,12 +220,8 @@ export function SlabPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
<PanelSection title="Material">
|
||||||
<MaterialPicker
|
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||||
onChange={handleMaterialChange}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
Regular → Executable
+19
-10
@@ -1,6 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, type MaterialSchema, useScene, type WallNode } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type MaterialSchema,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
import { MaterialPicker } from '../controls/material-picker'
|
||||||
@@ -26,7 +32,8 @@ export function WallPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleUpdateLength = useCallback((newLength: number) => {
|
const handleUpdateLength = useCallback(
|
||||||
|
(newLength: number) => {
|
||||||
if (!node || newLength <= 0) return
|
if (!node || newLength <= 0) return
|
||||||
|
|
||||||
const dx = node.end[0] - node.start[0]
|
const dx = node.end[0] - node.start[0]
|
||||||
@@ -40,15 +47,20 @@ export function WallPanel() {
|
|||||||
|
|
||||||
const newEnd: [number, number] = [
|
const newEnd: [number, number] = [
|
||||||
node.start[0] + dirX * newLength,
|
node.start[0] + dirX * newLength,
|
||||||
node.start[1] + dirZ * newLength
|
node.start[1] + dirZ * newLength,
|
||||||
]
|
]
|
||||||
|
|
||||||
handleUpdate({ end: newEnd })
|
handleUpdate({ end: newEnd })
|
||||||
}, [node, handleUpdate])
|
},
|
||||||
|
[node, handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback((material: MaterialSchema) => {
|
const handleMaterialChange = useCallback(
|
||||||
|
(material: MaterialSchema) => {
|
||||||
handleUpdate({ material })
|
handleUpdate({ material })
|
||||||
}, [handleUpdate])
|
},
|
||||||
|
[handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
@@ -104,10 +116,7 @@ export function WallPanel() {
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
<PanelSection title="Material">
|
||||||
<MaterialPicker
|
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||||
onChange={handleMaterialChange}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
Regular → Executable
+18
-12
@@ -1,6 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, emitter, type MaterialSchema, useScene, WindowNode } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
emitter,
|
||||||
|
type MaterialSchema,
|
||||||
|
useScene,
|
||||||
|
WindowNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
@@ -40,6 +47,13 @@ export function WindowPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleMaterialChange = useCallback(
|
||||||
|
(material: MaterialSchema) => {
|
||||||
|
handleUpdate({ material })
|
||||||
|
},
|
||||||
|
[handleUpdate],
|
||||||
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -139,10 +153,6 @@ export function WindowPanel() {
|
|||||||
[handleUpdate],
|
[handleUpdate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback((material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
}, [handleUpdate])
|
|
||||||
|
|
||||||
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
const numCols = node.columnRatios.length
|
const numCols = node.columnRatios.length
|
||||||
@@ -407,13 +417,6 @@ export function WindowPanel() {
|
|||||||
)}
|
)}
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
@@ -430,6 +433,9 @@ export function WindowPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
<PanelSection title="Material">
|
||||||
|
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||||
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
-1
@@ -32,7 +32,6 @@ const SIDEBAR_WIDTH = '18rem'
|
|||||||
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||||
const SIDEBAR_WIDTH_ICON = '3rem'
|
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
const SIDEBAR_COLLAPSE_THRESHOLD = 220
|
||||||
const SIDEBAR_MAX_WIDTH = 800
|
const SIDEBAR_MAX_WIDTH = 800
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+34
-11
@@ -1,16 +1,19 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type ReactNode, useEffect, useState } from 'react'
|
import { type ReactNode, useEffect } from 'react'
|
||||||
import { CommandPalette } from './../../../components/ui/command-palette'
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
type CommandPaletteEmptyAction,
|
||||||
|
} from './../../../components/ui/command-palette'
|
||||||
import { EditorCommands } from './../../../components/ui/command-palette/editor-commands'
|
import { EditorCommands } from './../../../components/ui/command-palette/editor-commands'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
useSidebarStore,
|
useSidebarStore,
|
||||||
} from './../../../components/ui/primitives/sidebar'
|
} from './../../../components/ui/primitives/sidebar'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import { IconRail, type PanelId } from './icon-rail'
|
import useEditor from './../../../store/use-editor'
|
||||||
|
import { type ExtraPanel, IconRail } from './icon-rail'
|
||||||
import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'
|
import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'
|
||||||
import { SitePanel, type SitePanelProps } from './panels/site-panel'
|
import { SitePanel, type SitePanelProps } from './panels/site-panel'
|
||||||
|
|
||||||
@@ -19,6 +22,8 @@ interface AppSidebarProps {
|
|||||||
sidebarTop?: ReactNode
|
sidebarTop?: ReactNode
|
||||||
settingsPanelProps?: SettingsPanelProps
|
settingsPanelProps?: SettingsPanelProps
|
||||||
sitePanelProps?: SitePanelProps
|
sitePanelProps?: SitePanelProps
|
||||||
|
extraPanels?: ExtraPanel[]
|
||||||
|
commandPaletteEmptyAction?: CommandPaletteEmptyAction
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
@@ -26,8 +31,15 @@ export function AppSidebar({
|
|||||||
sidebarTop,
|
sidebarTop,
|
||||||
settingsPanelProps,
|
settingsPanelProps,
|
||||||
sitePanelProps,
|
sitePanelProps,
|
||||||
|
extraPanels,
|
||||||
|
commandPaletteEmptyAction,
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
const [activePanel, setActivePanel] = useState<PanelId>('site')
|
const activePanel = useEditor((s) => s.activeSidebarPanel)
|
||||||
|
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
|
||||||
|
const hasActivePanel =
|
||||||
|
activePanel === 'site' ||
|
||||||
|
activePanel === 'settings' ||
|
||||||
|
Boolean(extraPanels?.some((panel) => panel.id === activePanel))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Widen default sidebar (288px → 432px) for better project title visibility
|
// Widen default sidebar (288px → 432px) for better project title visibility
|
||||||
@@ -37,25 +49,37 @@ export function AppSidebar({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActivePanel) {
|
||||||
|
setActivePanel('site')
|
||||||
|
}
|
||||||
|
}, [hasActivePanel, setActivePanel])
|
||||||
|
|
||||||
const renderPanelContent = () => {
|
const renderPanelContent = () => {
|
||||||
switch (activePanel) {
|
switch (activePanel) {
|
||||||
case 'site':
|
case 'site':
|
||||||
return <SitePanel {...sitePanelProps} />
|
return <SitePanel {...sitePanelProps} />
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SettingsPanel {...settingsPanelProps} />
|
return <SettingsPanel {...settingsPanelProps} />
|
||||||
default:
|
default: {
|
||||||
return null
|
const extra = extraPanels?.find((p) => p.id === activePanel)
|
||||||
|
if (extra) {
|
||||||
|
const Component = extra.component
|
||||||
|
return <Component />
|
||||||
|
}
|
||||||
|
return <SitePanel {...sitePanelProps} />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar className={cn('dark text-white')} variant="floating">
|
<div className={cn('dark flex h-full w-full bg-sidebar text-sidebar-foreground')}>
|
||||||
<div className="flex h-full">
|
|
||||||
{/* Icon Rail */}
|
{/* Icon Rail */}
|
||||||
<IconRail
|
<IconRail
|
||||||
activePanel={activePanel}
|
activePanel={activePanel}
|
||||||
appMenuButton={appMenuButton}
|
appMenuButton={appMenuButton}
|
||||||
|
extraPanels={extraPanels}
|
||||||
onPanelChange={setActivePanel}
|
onPanelChange={setActivePanel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -72,9 +96,8 @@ export function AppSidebar({
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sidebar>
|
|
||||||
<EditorCommands />
|
<EditorCommands />
|
||||||
<CommandPalette />
|
<CommandPalette emptyAction={commandPaletteEmptyAction} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+67
-47
@@ -1,9 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import type { ComponentType, ReactNode } from 'react'
|
||||||
import { Moon, Ruler, Sun } from 'lucide-react'
|
|
||||||
import { motion } from 'motion/react'
|
|
||||||
import { type ReactNode, useEffect, useState } from 'react'
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -11,31 +8,39 @@ import {
|
|||||||
} from './../../../components/ui/primitives/tooltip'
|
} from './../../../components/ui/primitives/tooltip'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
|
|
||||||
export type PanelId = 'site' | 'settings'
|
export type PanelId = string
|
||||||
|
|
||||||
|
export type ExtraPanel = { id: string; icon: ReactNode; label: string; component: ComponentType }
|
||||||
|
|
||||||
interface IconRailProps {
|
interface IconRailProps {
|
||||||
activePanel: PanelId
|
activePanel: PanelId
|
||||||
onPanelChange: (panel: PanelId) => void
|
onPanelChange: (panel: PanelId) => void
|
||||||
appMenuButton?: ReactNode
|
appMenuButton?: ReactNode
|
||||||
|
extraPanels?: ExtraPanel[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const panels: { id: PanelId; iconSrc: string; label: string }[] = [
|
const sitePanel: { id: PanelId; iconSrc: string; label: string } = {
|
||||||
{ id: 'site', iconSrc: '/icons/level.png', label: 'Site' },
|
id: 'site',
|
||||||
{ id: 'settings', iconSrc: '/icons/settings.png', label: 'Settings' },
|
iconSrc: '/icons/level.png',
|
||||||
]
|
label: 'Site',
|
||||||
|
}
|
||||||
|
|
||||||
export function IconRail({ activePanel, onPanelChange, appMenuButton, className }: IconRailProps) {
|
const settingsPanel: { id: PanelId; iconSrc: string; label: string } = {
|
||||||
const theme = useViewer((state) => state.theme)
|
id: 'settings',
|
||||||
const setTheme = useViewer((state) => state.setTheme)
|
iconSrc: '/icons/settings.png',
|
||||||
const unit = useViewer((state) => state.unit)
|
label: 'Settings',
|
||||||
const setUnit = useViewer((state) => state.setUnit)
|
}
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const panels: { id: PanelId; iconSrc: string; label: string }[] = [sitePanel, settingsPanel]
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
|
export function IconRail({
|
||||||
|
activePanel,
|
||||||
|
onPanelChange,
|
||||||
|
appMenuButton,
|
||||||
|
extraPanels,
|
||||||
|
className,
|
||||||
|
}: IconRailProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -49,7 +54,8 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mb-1 h-px w-8 bg-border/50" />
|
<div className="mb-1 h-px w-8 bg-border/50" />
|
||||||
|
|
||||||
{panels.map((panel) => {
|
{/* Site panel */}
|
||||||
|
{[sitePanel].map((panel) => {
|
||||||
const isActive = activePanel === panel.id
|
const isActive = activePanel === panel.id
|
||||||
return (
|
return (
|
||||||
<Tooltip key={panel.id}>
|
<Tooltip key={panel.id}>
|
||||||
@@ -77,49 +83,63 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Extra panels (injected between site and settings) */}
|
||||||
<div className="flex-1" />
|
{extraPanels?.map((panel) => {
|
||||||
|
const isActive = activePanel === panel.id
|
||||||
{/* Unit Toggle */}
|
return (
|
||||||
{mounted && (
|
<Tooltip key={panel.id}>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="mb-1 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent"
|
className={cn(
|
||||||
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
|
'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
|
||||||
|
isActive ? 'bg-accent' : 'hover:bg-accent',
|
||||||
|
)}
|
||||||
|
onClick={() => onPanelChange(panel.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 font-medium text-[10px] leading-none">
|
<span
|
||||||
{unit === 'metric' ? 'm' : 'ft'}
|
className={cn(
|
||||||
</div>
|
'flex h-6 w-6 items-center justify-center transition-all',
|
||||||
|
!isActive && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{panel.icon}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">Toggle units (metric/imperial)</TooltipContent>
|
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* Settings panel */}
|
||||||
{mounted && (
|
{[settingsPanel].map((panel) => {
|
||||||
<Tooltip>
|
const isActive = activePanel === panel.id
|
||||||
|
return (
|
||||||
|
<Tooltip key={panel.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent"
|
className={cn(
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
|
||||||
|
isActive ? 'bg-accent' : 'hover:bg-accent',
|
||||||
|
)}
|
||||||
|
onClick={() => onPanelChange(panel.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<motion.div
|
<img
|
||||||
animate={{ rotate: 0, opacity: 1 }}
|
alt={panel.label}
|
||||||
initial={{ rotate: -90, opacity: 0 }}
|
className={cn(
|
||||||
key={theme}
|
'h-6 w-6 object-contain transition-all',
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
!isActive && 'opacity-50 saturate-0',
|
||||||
>
|
)}
|
||||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
src={panel.iconSrc}
|
||||||
</motion.div>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">Toggle theme</TooltipContent>
|
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
+21
-14
@@ -18,7 +18,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from './../../../../../components/ui/primitives/dialog'
|
} from './../../../../../components/ui/primitives/dialog'
|
||||||
import { Switch } from './../../../../../components/ui/primitives/switch'
|
import { Switch } from './../../../../../components/ui/primitives/switch'
|
||||||
import useEditor from './../../../../../store/use-editor'
|
import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
|
||||||
import { AudioSettingsDialog } from './audio-settings-dialog'
|
import { AudioSettingsDialog } from './audio-settings-dialog'
|
||||||
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
|
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
|
||||||
|
|
||||||
@@ -202,12 +202,6 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
|
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
|
||||||
|
|
||||||
const handleExport = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
|
|
||||||
if (exportScene) {
|
|
||||||
await exportScene(format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveBuild = () => {
|
const handleSaveBuild = () => {
|
||||||
const sceneData = { nodes, rootNodeIds }
|
const sceneData = { nodes, rootNodeIds }
|
||||||
const json = JSON.stringify(sceneData, null, 2)
|
const json = JSON.stringify(sceneData, null, 2)
|
||||||
@@ -247,7 +241,8 @@ export function SettingsPanel({
|
|||||||
const handleResetToDefault = () => {
|
const handleResetToDefault = () => {
|
||||||
clearScene()
|
clearScene()
|
||||||
resetSelection()
|
resetSelection()
|
||||||
setPhase('site')
|
setPhase('structure')
|
||||||
|
selectDefaultBuildingAndLevel()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateThumbnail = () => {
|
const handleGenerateThumbnail = () => {
|
||||||
@@ -318,17 +313,29 @@ export function SettingsPanel({
|
|||||||
{/* Export Section */}
|
{/* Export Section */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
||||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('glb')} variant="outline">
|
<Button
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
onClick={() => exportScene?.('glb')}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
Export as GLB
|
Export GLB
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('stl')} variant="outline">
|
<Button
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
onClick={() => exportScene?.('stl')}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
Export as STL
|
Export STL
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('obj')} variant="outline">
|
<Button
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
onClick={() => exportScene?.('obj')}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
Export as OBJ
|
Export OBJ
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+2
-6
@@ -118,12 +118,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const j = (i + 1) % n
|
const j = (i + 1) % n
|
||||||
const pi = polygon[i]
|
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||||
const pj = polygon[j]
|
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||||
if (pi && pj) {
|
|
||||||
area += pi[0] * pj[1]
|
|
||||||
area -= pj[0] * pi[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.abs(area) / 2
|
return Math.abs(area) / 2
|
||||||
|
|||||||
Regular → Executable
+1
-1
@@ -908,7 +908,7 @@ function LayerToggle() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||||
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
|
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
|
||||||
S
|
B
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Regular → Executable
+2
-6
@@ -88,12 +88,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const j = (i + 1) % n
|
const j = (i + 1) % n
|
||||||
const pi = polygon[i]
|
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||||
const pj = polygon[j]
|
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||||
if (pi && pj) {
|
|
||||||
area += pi[0] * pj[1]
|
|
||||||
area -= pj[0] * pi[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.abs(area) / 2
|
return Math.abs(area) / 2
|
||||||
|
|||||||
Regular → Executable
+2
-6
@@ -79,12 +79,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const j = (i + 1) % n
|
const j = (i + 1) % n
|
||||||
const pi = polygon[i]
|
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||||
const pj = polygon[j]
|
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||||
if (pi && pj) {
|
|
||||||
area += pi[0] * pj[1]
|
|
||||||
area -= pj[0] * pi[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.abs(area) / 2
|
return Math.abs(area) / 2
|
||||||
|
|||||||
Regular → Executable
+1
-16
@@ -11,11 +11,10 @@ import {
|
|||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Footprints, Sun } from 'lucide-react'
|
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import useEditor from '../store/use-editor'
|
|
||||||
import { ActionButton } from './ui/action-menu/action-button'
|
import { ActionButton } from './ui/action-menu/action-button'
|
||||||
import { TooltipProvider } from './ui/primitives/tooltip'
|
import { TooltipProvider } from './ui/primitives/tooltip'
|
||||||
|
|
||||||
@@ -492,20 +491,6 @@ export const ViewerOverlay = ({
|
|||||||
src="/icons/topview.png"
|
src="/icons/topview.png"
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
|
||||||
|
|
||||||
{/* Street View */}
|
|
||||||
<ActionButton
|
|
||||||
className="group hover:bg-white/5"
|
|
||||||
label="Street View"
|
|
||||||
onClick={() => useEditor.getState().setFirstPersonMode(true)}
|
|
||||||
size="icon"
|
|
||||||
tooltipSide="top"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Footprints className="h-5 w-5 opacity-70 transition-opacity group-hover:opacity-100" />
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Regular → Executable
+18
-7
@@ -3,10 +3,13 @@
|
|||||||
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import type { Mesh } from 'three'
|
||||||
|
import useEditor from '../store/use-editor'
|
||||||
|
|
||||||
export const ViewerZoneSystem = () => {
|
export const ViewerZoneSystem = () => {
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const { levelId, zoneId } = useViewer.getState().selection
|
const { levelId, zoneId } = useViewer.getState().selection
|
||||||
|
const structureLayer = useEditor.getState().structureLayer
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
sceneRegistry.byType.zone.forEach((id) => {
|
sceneRegistry.byType.zone.forEach((id) => {
|
||||||
@@ -16,16 +19,24 @@ export const ViewerZoneSystem = () => {
|
|||||||
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
|
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
|
||||||
if (!zone) return
|
if (!zone) return
|
||||||
|
|
||||||
// Hide zones if:
|
|
||||||
// 1. No level is selected
|
|
||||||
// 2. Zone is not on the selected level
|
|
||||||
// 3. A zone is already selected (hide all zones to show zone contents)
|
|
||||||
const isOnSelectedLevel = zone.parentId === levelId
|
const isOnSelectedLevel = zone.parentId === levelId
|
||||||
const shouldShow = !!levelId && isOnSelectedLevel && !zoneId
|
|
||||||
|
|
||||||
obj.visible = shouldShow
|
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
|
||||||
|
// Zone geometry: visible in zone mode on the right level, OR when this zone is selected.
|
||||||
|
// The editor ZoneSystem handles the selected zone's opacity animation.
|
||||||
|
const isSelected = id === zoneId
|
||||||
|
const shouldShowGeometry =
|
||||||
|
(structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected
|
||||||
|
if (!obj.visible) obj.visible = true
|
||||||
|
obj.traverse((child) => {
|
||||||
|
if ((child as Mesh).isMesh) {
|
||||||
|
child.visible = shouldShowGeometry
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const targetOpacity = shouldShow ? '1' : '0'
|
// Labels: always visible on the current level (regardless of mode or zone selection)
|
||||||
|
const showLabel = !!levelId && isOnSelectedLevel
|
||||||
|
const targetOpacity = showLabel ? '1' : '0'
|
||||||
const labelEl = document.getElementById(`${id}-label`)
|
const labelEl = document.getElementById(`${id}-label`)
|
||||||
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
if (labelEl && labelEl.style.opacity !== targetOpacity) {
|
||||||
labelEl.style.opacity = targetOpacity
|
labelEl.style.opacity = targetOpacity
|
||||||
|
|||||||
Regular → Executable
+47
-10
@@ -11,7 +11,7 @@ export const markToolCancelConsumed = () => {
|
|||||||
_toolCancelConsumed = true
|
_toolCancelConsumed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useKeyboard = () => {
|
export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Don't handle shortcuts if user is typing in an input
|
// Don't handle shortcuts if user is typing in an input
|
||||||
@@ -30,9 +30,20 @@ export const useKeyboard = () => {
|
|||||||
// Only switch to select mode if no tool had an active mid-action to cancel.
|
// Only switch to select mode if no tool had an active mid-action to cancel.
|
||||||
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
||||||
if (!_toolCancelConsumed) {
|
if (!_toolCancelConsumed) {
|
||||||
// Return to the default select tool while keeping the active building/level context.
|
const currentPhase = useEditor.getState().phase
|
||||||
|
const currentStructureLayer = useEditor.getState().structureLayer
|
||||||
|
|
||||||
useEditor.getState().setEditingHole(null)
|
useEditor.getState().setEditingHole(null)
|
||||||
|
|
||||||
|
// From zone mode, return to structure select
|
||||||
|
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
||||||
|
useEditor.getState().setStructureLayer('elements')
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
|
} else {
|
||||||
|
// Return to the default select tool while keeping the active building/level context.
|
||||||
|
useEditor.getState().setMode('select')
|
||||||
|
}
|
||||||
|
|
||||||
useEditor.getState().setFloorplanSelectionTool('click')
|
useEditor.getState().setFloorplanSelectionTool('click')
|
||||||
|
|
||||||
// Clear selections to close UI panels, but KEEP the active building and level context.
|
// Clear selections to close UI panels, but KEEP the active building and level context.
|
||||||
@@ -51,29 +62,34 @@ export const useKeyboard = () => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setPhase('furnish')
|
useEditor.getState().setPhase('furnish')
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
useEditor.getState().setPhase('structure')
|
|
||||||
useEditor.getState().setStructureLayer('elements')
|
|
||||||
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setPhase('furnish')
|
useEditor.getState().setPhase('furnish')
|
||||||
|
useEditor.getState().setMode('build')
|
||||||
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setPhase('structure')
|
useEditor.getState().setPhase('structure')
|
||||||
useEditor.getState().setStructureLayer('zones')
|
useEditor.getState().setStructureLayer('zones')
|
||||||
|
useEditor.getState().setMode('build')
|
||||||
}
|
}
|
||||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
useEditor.getState().setFloorplanSelectionTool('click')
|
useEditor.getState().setFloorplanSelectionTool('click')
|
||||||
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
useEditor.getState().setStructureLayer('elements')
|
||||||
useEditor.getState().setMode('build')
|
useEditor.getState().setMode('build')
|
||||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useScene.temporal.getState().undo()
|
useScene.temporal.getState().undo()
|
||||||
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||||
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useScene.temporal.getState().redo()
|
useScene.temporal.getState().redo()
|
||||||
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -108,7 +124,7 @@ export const useKeyboard = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.key === 'r' || e.key === 'R') {
|
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
||||||
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedNodeIds.length === 1) {
|
if (selectedNodeIds.length === 1) {
|
||||||
@@ -128,7 +144,7 @@ export const useKeyboard = () => {
|
|||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.key === 't' || e.key === 'T') {
|
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
|
||||||
// Rotate selected node counter-clockwise
|
// Rotate selected node counter-clockwise
|
||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedNodeIds.length === 1) {
|
if (selectedNodeIds.length === 1) {
|
||||||
@@ -147,9 +163,30 @@ export const useKeyboard = () => {
|
|||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Check for a selected reference (guide/scan) first
|
||||||
|
const selectedRefId = useEditor.getState().selectedReferenceId
|
||||||
|
if (selectedRefId) {
|
||||||
|
const refNode = useScene.getState().nodes[selectedRefId as AnyNodeId]
|
||||||
|
if (refNode && (refNode.type === 'guide' || refNode.type === 'scan')) {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
useScene.getState().deleteNode(selectedRefId as AnyNodeId)
|
||||||
|
useEditor.getState().setSelectedReferenceId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete selected zone
|
||||||
|
const selectedZoneId = useViewer.getState().selection.zoneId
|
||||||
|
if (selectedZoneId) {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
useScene.getState().deleteNode(selectedZoneId as AnyNodeId)
|
||||||
|
useViewer.getState().setSelection({ zoneId: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
|
|
||||||
if (selectedNodeIds.length > 0) {
|
if (selectedNodeIds.length > 0) {
|
||||||
@@ -171,7 +208,7 @@ export const useKeyboard = () => {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [])
|
}, [isVersionPreviewMode])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export {
|
|||||||
} from './components/ui/sidebar/panels/settings-panel'
|
} from './components/ui/sidebar/panels/settings-panel'
|
||||||
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
||||||
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
||||||
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
|
|
||||||
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
||||||
export { PresetsProvider } from './contexts/presets-context'
|
export { PresetsProvider } from './contexts/presets-context'
|
||||||
export type { SaveStatus } from './hooks/use-auto-save'
|
export type { SaveStatus } from './hooks/use-auto-save'
|
||||||
@@ -31,3 +30,4 @@ export {
|
|||||||
usePaletteViewRegistry,
|
usePaletteViewRegistry,
|
||||||
} from './store/use-palette-view-registry'
|
} from './store/use-palette-view-registry'
|
||||||
export { useUploadStore } from './store/use-upload'
|
export { useUploadStore } from './store/use-upload'
|
||||||
|
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
|
||||||
|
|||||||
Regular → Executable
+20
-15
@@ -20,14 +20,6 @@ type PersistedSelectionPath = {
|
|||||||
selectedIds: string[]
|
selectedIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* IDs are stored as plain strings in localStorage. Cast them back to their
|
|
||||||
* branded template-literal types before passing to the viewer store.
|
|
||||||
*/
|
|
||||||
function toViewerSelection(s: PersistedSelectionPath) {
|
|
||||||
return s as unknown as Parameters<ReturnType<typeof useViewer.getState>['setSelection']>[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = {
|
const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = {
|
||||||
buildingId: null,
|
buildingId: null,
|
||||||
levelId: null,
|
levelId: null,
|
||||||
@@ -271,9 +263,27 @@ export function syncEditorSelectionFromCurrentScene() {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
if (firstBuilding && firstLevel) {
|
if (firstBuilding && firstLevel) {
|
||||||
|
const isEmptyLevel = !firstLevel.children || firstLevel.children.length === 0
|
||||||
|
|
||||||
|
// For empty projects (new/blank), always start in structure/build/wall
|
||||||
|
// regardless of persisted state from a previous project
|
||||||
|
if (isEmptyLevel) {
|
||||||
|
useViewer.getState().setSelection({
|
||||||
|
buildingId: firstBuilding.id,
|
||||||
|
levelId: firstLevel.id,
|
||||||
|
selectedIds: [],
|
||||||
|
zoneId: null,
|
||||||
|
})
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
useEditor.getState().setStructureLayer('elements')
|
||||||
|
useEditor.getState().setMode('build')
|
||||||
|
useEditor.getState().setTool('wall')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldRestoreEditorUiState) {
|
if (shouldRestoreEditorUiState) {
|
||||||
if (restoredSelection) {
|
if (restoredSelection) {
|
||||||
useViewer.getState().setSelection(toViewerSelection(restoredSelection))
|
useViewer.getState().setSelection(restoredSelection)
|
||||||
useEditor.setState(
|
useEditor.setState(
|
||||||
restoredEditorUiState.phase === 'site'
|
restoredEditorUiState.phase === 'site'
|
||||||
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
|
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
|
||||||
@@ -295,7 +305,7 @@ export function syncEditorSelectionFromCurrentScene() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (restoredSelection) {
|
if (restoredSelection) {
|
||||||
useViewer.getState().setSelection(toViewerSelection(restoredSelection))
|
useViewer.getState().setSelection(restoredSelection)
|
||||||
if (selectionDrivenEditorUiState) {
|
if (selectionDrivenEditorUiState) {
|
||||||
useEditor.setState(selectionDrivenEditorUiState)
|
useEditor.setState(selectionDrivenEditorUiState)
|
||||||
}
|
}
|
||||||
@@ -310,11 +320,6 @@ export function syncEditorSelectionFromCurrentScene() {
|
|||||||
})
|
})
|
||||||
useEditor.getState().setPhase('structure')
|
useEditor.getState().setPhase('structure')
|
||||||
useEditor.getState().setStructureLayer('elements')
|
useEditor.getState().setStructureLayer('elements')
|
||||||
|
|
||||||
if (!firstLevel.children || firstLevel.children.length === 0) {
|
|
||||||
useEditor.getState().setMode('build')
|
|
||||||
useEditor.getState().setTool('wall')
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
useEditor.getState().setPhase('site')
|
useEditor.getState().setPhase('site')
|
||||||
useViewer.getState().setSelection({
|
useViewer.getState().setSelection({
|
||||||
|
|||||||
@@ -81,9 +81,25 @@ type EditorState = {
|
|||||||
setCatalogCategory: (category: CatalogCategory | null) => void
|
setCatalogCategory: (category: CatalogCategory | null) => void
|
||||||
selectedItem: AssetInput | null
|
selectedItem: AssetInput | null
|
||||||
setSelectedItem: (item: AssetInput) => void
|
setSelectedItem: (item: AssetInput) => void
|
||||||
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null
|
movingNode:
|
||||||
|
| ItemNode
|
||||||
|
| WindowNode
|
||||||
|
| DoorNode
|
||||||
|
| RoofNode
|
||||||
|
| RoofSegmentNode
|
||||||
|
| StairNode
|
||||||
|
| StairSegmentNode
|
||||||
|
| null
|
||||||
setMovingNode: (
|
setMovingNode: (
|
||||||
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
|
node:
|
||||||
|
| ItemNode
|
||||||
|
| WindowNode
|
||||||
|
| DoorNode
|
||||||
|
| RoofNode
|
||||||
|
| RoofSegmentNode
|
||||||
|
| StairNode
|
||||||
|
| StairSegmentNode
|
||||||
|
| null,
|
||||||
) => void
|
) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
@@ -109,12 +125,13 @@ type EditorState = {
|
|||||||
setFloorplanHovered: (hovered: boolean) => void
|
setFloorplanHovered: (hovered: boolean) => void
|
||||||
floorplanSelectionTool: FloorplanSelectionTool
|
floorplanSelectionTool: FloorplanSelectionTool
|
||||||
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
||||||
|
// First-person walkthrough mode (street view)
|
||||||
|
isFirstPersonMode: boolean
|
||||||
|
_viewModeBeforeFirstPerson: ViewMode | null
|
||||||
|
setFirstPersonMode: (enabled: boolean) => void
|
||||||
// Development-only camera debug flag for inspecting underside geometry
|
// Development-only camera debug flag for inspecting underside geometry
|
||||||
allowUndergroundCamera: boolean
|
allowUndergroundCamera: boolean
|
||||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||||
// First-person walkthrough mode (street view)
|
|
||||||
isFirstPersonMode: boolean
|
|
||||||
setFirstPersonMode: (enabled: boolean) => void
|
|
||||||
activeSidebarPanel: string
|
activeSidebarPanel: string
|
||||||
setActiveSidebarPanel: (id: string) => void
|
setActiveSidebarPanel: (id: string) => void
|
||||||
floorplanPaneRatio: number
|
floorplanPaneRatio: number
|
||||||
@@ -403,7 +420,15 @@ const useEditor = create<EditorState>()(
|
|||||||
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
setSelectedItem: (item) => set({ selectedItem: item }),
|
setSelectedItem: (item) => set({ selectedItem: item }),
|
||||||
movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
|
movingNode: null as
|
||||||
|
| ItemNode
|
||||||
|
| WindowNode
|
||||||
|
| DoorNode
|
||||||
|
| RoofNode
|
||||||
|
| RoofSegmentNode
|
||||||
|
| StairNode
|
||||||
|
| StairSegmentNode
|
||||||
|
| null,
|
||||||
setMovingNode: (node) => set({ movingNode: node }),
|
setMovingNode: (node) => set({ movingNode: node }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
@@ -442,9 +467,7 @@ const useEditor = create<EditorState>()(
|
|||||||
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
||||||
setFirstPersonMode: (enabled) => {
|
setFirstPersonMode: (enabled) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// Save current view mode and force 3D for immersive walkthrough
|
|
||||||
const currentViewMode = get().viewMode
|
const currentViewMode = get().viewMode
|
||||||
// Force perspective camera and full-height walls for immersive walkthrough
|
|
||||||
useViewer.getState().setCameraMode('perspective')
|
useViewer.getState().setCameraMode('perspective')
|
||||||
useViewer.getState().setWallMode('up')
|
useViewer.getState().setWallMode('up')
|
||||||
set({
|
set({
|
||||||
@@ -458,7 +481,6 @@ const useEditor = create<EditorState>()(
|
|||||||
})
|
})
|
||||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||||
} else {
|
} else {
|
||||||
// Restore previous view mode
|
|
||||||
const prevMode = get()._viewModeBeforeFirstPerson
|
const prevMode = get()._viewModeBeforeFirstPerson
|
||||||
set({
|
set({
|
||||||
isFirstPersonMode: false,
|
isFirstPersonMode: false,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
|
|||||||
const gridPattern = lineX.max(lineY)
|
const gridPattern = lineX.max(lineY)
|
||||||
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
||||||
|
|
||||||
function createCeilingMaterials(color: string = '#999999') {
|
function createCeilingMaterials(color = '#999999') {
|
||||||
const topMaterial = new MeshBasicNodeMaterial({
|
const topMaterial = new MeshBasicNodeMaterial({
|
||||||
color,
|
color,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type AnimationEffect,
|
type AnimationEffect,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
baseMaterial,
|
||||||
|
glassMaterial,
|
||||||
type Interactive,
|
type Interactive,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LightEffect,
|
type LightEffect,
|
||||||
@@ -16,36 +18,18 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
|
|||||||
import type { AnimationAction, Group, Material, Mesh } 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 { 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 { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||||
import { ErrorBoundary } from '../../error-boundary'
|
import { ErrorBoundary } from '../../error-boundary'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
// Shared materials to avoid creating new instances for every mesh
|
|
||||||
const defaultMaterial = new MeshStandardNodeMaterial({
|
|
||||||
color: 0xff_ff_ff,
|
|
||||||
roughness: 1,
|
|
||||||
metalness: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const glassMaterial = new MeshStandardNodeMaterial({
|
|
||||||
name: 'glass',
|
|
||||||
color: 'lightgray',
|
|
||||||
roughness: 0.8,
|
|
||||||
metalness: 0,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.35,
|
|
||||||
side: DoubleSide,
|
|
||||||
depthWrite: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
|
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
|
||||||
if (original.name.toLowerCase() === 'glass') {
|
if (original.name.toLowerCase() === 'glass') {
|
||||||
return glassMaterial
|
return glassMaterial
|
||||||
}
|
}
|
||||||
return defaultMaterial
|
return baseMaterial
|
||||||
}
|
}
|
||||||
|
|
||||||
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||||
@@ -145,6 +129,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
if (Array.isArray(mesh.material)) {
|
if (Array.isArray(mesh.material)) {
|
||||||
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
|
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
|
||||||
hasGlass = mesh.material.some((mat) => mat.name === 'glass')
|
hasGlass = mesh.material.some((mat) => mat.name === 'glass')
|
||||||
|
|
||||||
|
// Fix geometry groups that reference materialIndex beyond the material
|
||||||
|
// array length — this causes three-mesh-bvh to crash with
|
||||||
|
// "Cannot read properties of undefined (reading 'side')"
|
||||||
|
const matCount = mesh.material.length
|
||||||
|
if (mesh.geometry.groups.length > 0) {
|
||||||
|
for (const group of mesh.geometry.groups) {
|
||||||
|
if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
|
||||||
|
group.materialIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
mesh.material = getMaterialForOriginal(mesh.material)
|
mesh.material = getMaterialForOriginal(mesh.material)
|
||||||
hasGlass = mesh.material.name === 'glass'
|
hasGlass = mesh.material.name === 'glass'
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { type SiteNode, useRegistry } from '@pascal-app/core'
|
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import polygonClipping from 'polygon-clipping'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } 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 { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
const Y_OFFSET = 0.01
|
const Y_OFFSET = 0.01
|
||||||
@@ -29,29 +31,76 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
|
|||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type S = ReturnType<typeof useScene.getState>
|
||||||
|
|
||||||
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
|
|
||||||
useRegistry(node.id, 'site', ref)
|
useRegistry(node.id, 'site', ref)
|
||||||
|
|
||||||
// Create floor shape from polygon points
|
const theme = useViewer((state) => state.theme)
|
||||||
const floorShape = useMemo(() => {
|
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||||
|
|
||||||
|
// Cache slab polygon references to keep the selector stable across unrelated store updates
|
||||||
|
const slabPolygonsCache = useRef<[number, number][][]>([])
|
||||||
|
const slabPolygons = useScene((state: S) => {
|
||||||
|
const nodeList = Object.values(state.nodes)
|
||||||
|
|
||||||
|
const levelIndexById = new Map<string, number>()
|
||||||
|
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||||
|
nodeList.forEach((n) => {
|
||||||
|
if (n.type !== 'level') return
|
||||||
|
levelIndexById.set(n.id, n.level)
|
||||||
|
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
|
||||||
|
})
|
||||||
|
|
||||||
|
const next = nodeList
|
||||||
|
.filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3)
|
||||||
|
.filter((n) => {
|
||||||
|
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||||
|
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
||||||
|
return parentLevel === lowestLevelIndex
|
||||||
|
})
|
||||||
|
.map((n) => n.polygon as [number, number][])
|
||||||
|
|
||||||
|
const prev = slabPolygonsCache.current
|
||||||
|
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
|
||||||
|
slabPolygonsCache.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ground shape: site polygon with slab footprints punched as holes
|
||||||
|
const groundShape = useMemo(() => {
|
||||||
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
|
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
|
||||||
|
|
||||||
|
const pts = node.polygon.points
|
||||||
const shape = new Shape()
|
const shape = new Shape()
|
||||||
const firstPt = node.polygon.points[0]!
|
shape.moveTo(pts[0]![0], -pts[0]![1])
|
||||||
|
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||||
// Shape is in X-Y plane, we rotate it to X-Z plane
|
|
||||||
// Negate Y (which becomes Z) to get correct orientation
|
|
||||||
shape.moveTo(firstPt[0]!, -firstPt[1]!)
|
|
||||||
|
|
||||||
for (let i = 1; i < node.polygon.points.length; i++) {
|
|
||||||
const pt = node.polygon.points[i]!
|
|
||||||
shape.lineTo(pt[0]!, -pt[1]!)
|
|
||||||
}
|
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
|
if (slabPolygons.length > 0) {
|
||||||
|
const multiPolygons = slabPolygons.map((p) => [
|
||||||
|
p.map((pt) => [pt[0], -pt[1]] as [number, number]),
|
||||||
|
])
|
||||||
|
const unioned = polygonClipping.union(
|
||||||
|
multiPolygons[0] as polygonClipping.Polygon,
|
||||||
|
...(multiPolygons.slice(1) as polygonClipping.Polygon[]),
|
||||||
|
)
|
||||||
|
for (const geom of unioned) {
|
||||||
|
const ring = geom[0]
|
||||||
|
if (ring && ring.length > 0) {
|
||||||
|
const hole = new Path()
|
||||||
|
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||||
|
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||||
|
hole.closePath()
|
||||||
|
shape.holes.push(hole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return shape
|
return shape
|
||||||
}, [node?.polygon?.points])
|
}, [node?.polygon?.points, slabPolygons])
|
||||||
|
|
||||||
// Create boundary line geometry
|
// Create boundary line geometry
|
||||||
const lineGeometry = useMemo(() => {
|
const lineGeometry = useMemo(() => {
|
||||||
@@ -61,7 +110,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
|
|
||||||
const handlers = useNodeEvents(node, 'site')
|
const handlers = useNodeEvents(node, 'site')
|
||||||
|
|
||||||
if (!(node && floorShape && lineGeometry)) {
|
if (!(node && lineGeometry)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +124,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Transparent floor fill */}
|
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||||
<mesh position={[0, Y_OFFSET - 0.005, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
{groundShape && (
|
||||||
<shapeGeometry args={[floorShape]} />
|
<mesh position={[0, -0.05, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<shadowMaterial opacity={0.75} transparent />
|
<shapeGeometry args={[groundShape]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={bgColor}
|
||||||
|
depthWrite={true}
|
||||||
|
polygonOffset={true}
|
||||||
|
polygonOffsetFactor={1}
|
||||||
|
polygonOffsetUnits={1}
|
||||||
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Simple boundary line */}
|
{/* Simple boundary line */}
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
receiveShadow
|
receiveShadow
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
visible={node.visible}
|
|
||||||
material={material}
|
material={material}
|
||||||
|
visible={node.visible}
|
||||||
>
|
>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow receiveShadow ref={ref} visible={node.visible} material={material}>
|
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
<mesh name="collision-mesh" visible={false} {...handlers}>
|
<mesh name="collision-mesh" visible={false} {...handlers}>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// Must run before @react-three/fiber's Canvas instantiates new THREE.Clock().
|
|
||||||
// See lib/suppress-three-clock-warning.ts for rationale and removal condition.
|
|
||||||
import '../../lib/suppress-three-clock-warning'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CeilingSystem,
|
CeilingSystem,
|
||||||
DoorSystem,
|
DoorSystem,
|
||||||
@@ -19,7 +15,6 @@ import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@re
|
|||||||
import { useEffect, 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 { ExportSystem } from '../../systems/export/export-system'
|
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
||||||
import { LevelSystem } from '../../systems/level/level-system'
|
import { LevelSystem } from '../../systems/level/level-system'
|
||||||
@@ -151,7 +146,6 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<WallSystem />
|
<WallSystem />
|
||||||
<WindowSystem />
|
<WindowSystem />
|
||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
<ExportSystem />
|
|
||||||
<PostProcessing />
|
<PostProcessing />
|
||||||
{/* <DebugRenderer /> */}
|
{/* <DebugRenderer /> */}
|
||||||
<GPUDeviceWatcher />
|
<GPUDeviceWatcher />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useFrame, useThree } from '@react-three/fiber'
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
import { useCallback, 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 { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
||||||
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
} 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 { mergedOutline } from '../../lib/merged-outline-node'
|
||||||
import useViewer from '../../store/use-viewer'
|
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
|
||||||
@@ -67,6 +67,7 @@ const PostProcessingPasses = () => {
|
|||||||
l.disable(SCENE_LAYER)
|
l.disable(SCENE_LAYER)
|
||||||
return l
|
return l
|
||||||
}, [])
|
}, [])
|
||||||
|
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
||||||
|
|
||||||
// Subscribe to projectId so the pipeline rebuilds on project switch
|
// Subscribe to projectId so the pipeline rebuilds on project switch
|
||||||
const projectId = useViewer((s) => s.projectId)
|
const projectId = useViewer((s) => s.projectId)
|
||||||
@@ -197,60 +198,45 @@ const PostProcessingPasses = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSelectedOutlinePass() {
|
// Single merged outline node: one shared depth pass for both selected + hovered groups.
|
||||||
const edgeStrength = uniform(3)
|
const outliner = useViewer.getState().outliner
|
||||||
const edgeGlow = uniform(0)
|
const outlineNode = mergedOutline(scene, camera, {
|
||||||
const edgeThickness = uniform(1)
|
primaryObjects: outliner.selectedObjects,
|
||||||
const visibleEdgeColor = uniform(new Color(0xff_ff_ff))
|
secondaryObjects: outliner.hoveredObjects,
|
||||||
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))
|
primaryEdgeThickness: uniform(1),
|
||||||
|
secondaryEdgeThickness: uniform(1.5),
|
||||||
const outlinePass = outline(scene, camera, {
|
|
||||||
selectedObjects: useViewer.getState().outliner.selectedObjects,
|
|
||||||
edgeGlow,
|
|
||||||
edgeThickness,
|
|
||||||
})
|
})
|
||||||
const { visibleEdge, hiddenEdge } = outlinePass
|
|
||||||
|
|
||||||
const outlineColor = visibleEdge
|
// Selected: white visible, yellow hidden
|
||||||
.mul(visibleEdgeColor)
|
const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
|
||||||
.add(hiddenEdge.mul(hiddenEdgeColor))
|
const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
|
||||||
.mul(edgeStrength)
|
const selectedStrength = uniform(3)
|
||||||
|
const selectedOutline = outlineNode.primaryVisibleEdge
|
||||||
|
.mul(selectedVisibleColor)
|
||||||
|
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
|
||||||
|
.mul(selectedStrength)
|
||||||
|
|
||||||
return outlineColor
|
// Hovered: blue visible, yellow hidden, pulsing
|
||||||
}
|
const hoverVisibleColor = uniform(
|
||||||
|
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
|
||||||
function generateHoverOutlinePass() {
|
)
|
||||||
const edgeStrength = uniform(5)
|
const hoverHiddenColor = uniform(
|
||||||
const edgeGlow = uniform(0.5)
|
new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
|
||||||
const edgeThickness = uniform(1.5)
|
)
|
||||||
|
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
|
||||||
const pulsePeriod = uniform(3)
|
const pulsePeriod = uniform(3)
|
||||||
const visibleEdgeColor = uniform(new Color(0x00_aa_ff))
|
const osc =
|
||||||
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))
|
hoverHighlightMode === 'delete'
|
||||||
|
? float(1)
|
||||||
const outlinePass = outline(scene, camera, {
|
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
|
||||||
selectedObjects: useViewer.getState().outliner.hoveredObjects,
|
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||||
edgeGlow,
|
.mul(hoverVisibleColor)
|
||||||
edgeThickness,
|
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||||
})
|
.mul(hoverStrength)
|
||||||
const { visibleEdge, hiddenEdge } = outlinePass
|
.mul(osc)
|
||||||
|
|
||||||
const period = time.div(pulsePeriod).mul(2)
|
|
||||||
const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ]
|
|
||||||
|
|
||||||
const outlineColor = visibleEdge
|
|
||||||
.mul(visibleEdgeColor)
|
|
||||||
.add(hiddenEdge.mul(hiddenEdgeColor))
|
|
||||||
.mul(edgeStrength)
|
|
||||||
const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor)
|
|
||||||
|
|
||||||
return outlinePulse
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedOutlinePass = generateSelectedOutlinePass()
|
|
||||||
const hoverOutlinePass = generateHoverOutlinePass()
|
|
||||||
|
|
||||||
const compositeWithOutlines = vec4(
|
const compositeWithOutlines = vec4(
|
||||||
add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)),
|
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
|
||||||
sceneColor.a,
|
sceneColor.a,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -280,7 +266,7 @@ const PostProcessingPasses = () => {
|
|||||||
}
|
}
|
||||||
renderPipelineRef.current = null
|
renderPipelineRef.current = null
|
||||||
}
|
}
|
||||||
}, [renderer, scene, camera, isInitialized, zoneLayers])
|
}, [renderer, scene, camera, hoverHighlightMode, isInitialized, zoneLayers])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { PointerLockControls } from '@react-three/drei'
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber'
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
|
const MOVE_SPEED = 5
|
||||||
|
const EYE_HEIGHT = 1.6
|
||||||
|
|
||||||
|
const _direction = new Vector3()
|
||||||
|
const _forward = new Vector3()
|
||||||
|
const _right = new Vector3()
|
||||||
|
|
||||||
|
export const WalkthroughControls = () => {
|
||||||
|
const controlsRef = useRef<any>(null!)
|
||||||
|
const walkthroughMode = useViewer((s: any) => s.walkthroughMode)
|
||||||
|
const keys = useRef({ w: false, a: false, s: false, d: false })
|
||||||
|
const camera = useThree((s) => s.camera)
|
||||||
|
|
||||||
|
// Set initial eye height
|
||||||
|
useEffect(() => {
|
||||||
|
if (walkthroughMode) {
|
||||||
|
camera.position.y = EYE_HEIGHT
|
||||||
|
}
|
||||||
|
}, [walkthroughMode, camera])
|
||||||
|
|
||||||
|
// Keyboard handlers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!walkthroughMode) return
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
|
||||||
|
// ESC exits walkthrough mode completely
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
useViewer.getState().setWalkthroughMode(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'w' || key === 'arrowup') keys.current.w = true
|
||||||
|
if (key === 'a' || key === 'arrowleft') keys.current.a = true
|
||||||
|
if (key === 's' || key === 'arrowdown') keys.current.s = true
|
||||||
|
if (key === 'd' || key === 'arrowright') keys.current.d = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
if (key === 'w' || key === 'arrowup') keys.current.w = false
|
||||||
|
if (key === 'a' || key === 'arrowleft') keys.current.a = false
|
||||||
|
if (key === 's' || key === 'arrowdown') keys.current.s = false
|
||||||
|
if (key === 'd' || key === 'arrowright') keys.current.d = false
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
// Reset keys on cleanup
|
||||||
|
keys.current = { w: false, a: false, s: false, d: false }
|
||||||
|
}
|
||||||
|
}, [walkthroughMode])
|
||||||
|
|
||||||
|
// Release pointer lock when walkthrough mode is turned off
|
||||||
|
useEffect(() => {
|
||||||
|
if (!walkthroughMode && document.pointerLockElement) {
|
||||||
|
document.exitPointerLock()
|
||||||
|
}
|
||||||
|
}, [walkthroughMode])
|
||||||
|
|
||||||
|
// Movement loop
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (!(walkthroughMode && controlsRef.current)) return
|
||||||
|
|
||||||
|
_direction.set(0, 0, 0)
|
||||||
|
|
||||||
|
// Get camera forward and right vectors (XZ plane only)
|
||||||
|
camera.getWorldDirection(_forward)
|
||||||
|
_forward.y = 0
|
||||||
|
_forward.normalize()
|
||||||
|
|
||||||
|
_right.crossVectors(_forward, camera.up).normalize()
|
||||||
|
|
||||||
|
if (keys.current.w) _direction.add(_forward)
|
||||||
|
if (keys.current.s) _direction.sub(_forward)
|
||||||
|
if (keys.current.d) _direction.add(_right)
|
||||||
|
if (keys.current.a) _direction.sub(_right)
|
||||||
|
|
||||||
|
if (_direction.lengthSq() > 0) {
|
||||||
|
_direction.normalize().multiplyScalar(MOVE_SPEED * delta)
|
||||||
|
camera.position.add(_direction)
|
||||||
|
// Keep eye height constant
|
||||||
|
camera.position.y = EYE_HEIGHT
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (walkthroughMode && controlsRef.current) {
|
||||||
|
// Feature detection: some browsers (Facebook/Instagram in-app, older Safari)
|
||||||
|
// don't support pointer lock on the canvas element
|
||||||
|
if (typeof controlsRef.current.lock === 'function') {
|
||||||
|
try {
|
||||||
|
controlsRef.current.lock()
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — pointer lock unavailable in this browser context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [walkthroughMode])
|
||||||
|
|
||||||
|
// Click to lock
|
||||||
|
useEffect(() => {
|
||||||
|
if (!walkthroughMode) return
|
||||||
|
const canvas = document.querySelector('canvas')
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
canvas.addEventListener('click', handleClick)
|
||||||
|
return () => canvas.removeEventListener('click', handleClick)
|
||||||
|
}, [walkthroughMode, handleClick])
|
||||||
|
|
||||||
|
if (!walkthroughMode) return null
|
||||||
|
|
||||||
|
// Skip PointerLockControls on browsers that don't support pointer lock
|
||||||
|
// (Facebook/Instagram in-app browsers, some iOS WebViews)
|
||||||
|
if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PointerLockControls ref={controlsRef} />
|
||||||
|
}
|
||||||
@@ -36,5 +36,4 @@ const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
|
|||||||
loader.setMeshoptDecoder(MeshoptDecoder)
|
loader.setMeshoptDecoder(MeshoptDecoder)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useGLTFKTX2 }
|
export { useGLTFKTX2 }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as Viewer } from './components/viewer'
|
export { default as Viewer } from './components/viewer'
|
||||||
|
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
|
||||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||||
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
||||||
export {
|
export {
|
||||||
@@ -13,7 +14,7 @@ export {
|
|||||||
DEFAULT_WINDOW_MATERIAL,
|
DEFAULT_WINDOW_MATERIAL,
|
||||||
disposeMaterial,
|
disposeMaterial,
|
||||||
} from './lib/materials'
|
} from './lib/materials'
|
||||||
|
export { mergedOutline } from './lib/merged-outline-node'
|
||||||
export { default as useViewer } from './store/use-viewer'
|
export { default as useViewer } from './store/use-viewer'
|
||||||
export { ExportSystem } from './systems/export/export-system'
|
|
||||||
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
||||||
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
// @ts-nocheck — Three.js TSL/WebGPU internal APIs have incomplete type definitions;
|
||||||
|
// this file is a fork of OutlineNode and is intentionally exempt from strict TS checking.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MergedOutlineNode — a fork of Three.js OutlineNode that processes two object
|
||||||
|
* groups (primary = selected, secondary = hovered) in a single pass, sharing the
|
||||||
|
* expensive non-selected depth pre-render between both groups.
|
||||||
|
*
|
||||||
|
* Cost comparison vs two separate OutlineNode instances:
|
||||||
|
* Before: depth_A + mask_A + edge_A×6 + depth_B + mask_B + edge_B×6 = 2 depth passes
|
||||||
|
* After: depth_AB (shared) + mask_A + edge_A×6 + mask_B + edge_B×6 = 1 depth pass
|
||||||
|
*
|
||||||
|
* Additional early-outs:
|
||||||
|
* - Both empty → skip everything (0 passes)
|
||||||
|
* - Only primary → skip secondary mask/edge/blur
|
||||||
|
* - Only secondary → skip primary mask/edge/blur
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DepthTexture, FloatType, type Object3D, RenderTarget, Vector2 } from 'three'
|
||||||
|
import {
|
||||||
|
color,
|
||||||
|
exp,
|
||||||
|
Fn,
|
||||||
|
float,
|
||||||
|
int,
|
||||||
|
Loop,
|
||||||
|
min,
|
||||||
|
mul,
|
||||||
|
nodeObject,
|
||||||
|
orthographicDepthToViewZ,
|
||||||
|
passTexture,
|
||||||
|
perspectiveDepthToViewZ,
|
||||||
|
positionView,
|
||||||
|
reference,
|
||||||
|
screenUV,
|
||||||
|
texture,
|
||||||
|
textureSize,
|
||||||
|
uniform,
|
||||||
|
uv,
|
||||||
|
vec2,
|
||||||
|
vec3,
|
||||||
|
vec4,
|
||||||
|
} from 'three/tsl'
|
||||||
|
import {
|
||||||
|
NodeMaterial,
|
||||||
|
NodeUpdateType,
|
||||||
|
QuadMesh,
|
||||||
|
RendererUtils,
|
||||||
|
SpriteNodeMaterial,
|
||||||
|
TempNode,
|
||||||
|
} from 'three/webgpu'
|
||||||
|
|
||||||
|
const _quadMesh = new QuadMesh()
|
||||||
|
const _size = new Vector2()
|
||||||
|
const _BLUR_X = new Vector2(1.0, 0.0)
|
||||||
|
const _BLUR_Y = new Vector2(0.0, 1.0)
|
||||||
|
let _rendererState: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: render targets for one outline group
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function makeGroupTargets(downSampleRatio: number) {
|
||||||
|
const maskBuffer = new RenderTarget()
|
||||||
|
const maskDownSample = new RenderTarget(1, 1, { depthBuffer: false })
|
||||||
|
const edgeBuffer1 = new RenderTarget(1, 1, { depthBuffer: false })
|
||||||
|
const edgeBuffer2 = new RenderTarget(1, 1, { depthBuffer: false })
|
||||||
|
const blurBuffer1 = new RenderTarget(1, 1, { depthBuffer: false })
|
||||||
|
const blurBuffer2 = new RenderTarget(1, 1, { depthBuffer: false })
|
||||||
|
const composite = new RenderTarget(1, 1, { depthBuffer: false })
|
||||||
|
|
||||||
|
function setSize(w: number, h: number) {
|
||||||
|
maskBuffer.setSize(w, h)
|
||||||
|
composite.setSize(w, h)
|
||||||
|
let rx = Math.round(w / downSampleRatio)
|
||||||
|
let ry = Math.round(h / downSampleRatio)
|
||||||
|
maskDownSample.setSize(rx, ry)
|
||||||
|
edgeBuffer1.setSize(rx, ry)
|
||||||
|
blurBuffer1.setSize(rx, ry)
|
||||||
|
rx = Math.round(rx / 2)
|
||||||
|
ry = Math.round(ry / 2)
|
||||||
|
edgeBuffer2.setSize(rx, ry)
|
||||||
|
blurBuffer2.setSize(rx, ry)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
maskBuffer.dispose()
|
||||||
|
maskDownSample.dispose()
|
||||||
|
edgeBuffer1.dispose()
|
||||||
|
edgeBuffer2.dispose()
|
||||||
|
blurBuffer1.dispose()
|
||||||
|
blurBuffer2.dispose()
|
||||||
|
composite.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
maskBuffer,
|
||||||
|
maskDownSample,
|
||||||
|
edgeBuffer1,
|
||||||
|
edgeBuffer2,
|
||||||
|
blurBuffer1,
|
||||||
|
blurBuffer2,
|
||||||
|
composite,
|
||||||
|
setSize,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupTargets = ReturnType<typeof makeGroupTargets>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MergedOutlineNode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export class MergedOutlineNode extends TempNode {
|
||||||
|
static get type() {
|
||||||
|
return 'MergedOutlineNode'
|
||||||
|
}
|
||||||
|
|
||||||
|
scene: any
|
||||||
|
camera: any
|
||||||
|
primaryObjects: Object3D[]
|
||||||
|
secondaryObjects: Object3D[]
|
||||||
|
primaryEdgeThicknessNode: any
|
||||||
|
secondaryEdgeThicknessNode: any
|
||||||
|
primaryEdgeGlowNode: any
|
||||||
|
secondaryEdgeGlowNode: any
|
||||||
|
downSampleRatio: number
|
||||||
|
updateBeforeType: string
|
||||||
|
|
||||||
|
private readonly _depthRT: RenderTarget
|
||||||
|
private readonly _depthTexUniform: any
|
||||||
|
|
||||||
|
private readonly _groupA: GroupTargets
|
||||||
|
private readonly _groupB: GroupTargets
|
||||||
|
private readonly _maskTexA: any
|
||||||
|
private readonly _maskDownTexA: any
|
||||||
|
private readonly _edge1TexA: any
|
||||||
|
private readonly _edge2TexA: any
|
||||||
|
private readonly _blurColorTexA: any
|
||||||
|
private readonly _maskTexB: any
|
||||||
|
private readonly _maskDownTexB: any
|
||||||
|
private readonly _edge1TexB: any
|
||||||
|
private readonly _edge2TexB: any
|
||||||
|
private readonly _blurColorTexB: any
|
||||||
|
private readonly _blurDirectionA: any
|
||||||
|
private readonly _blurDirectionB: any
|
||||||
|
private readonly _cameraNear: any
|
||||||
|
private readonly _cameraFar: any
|
||||||
|
|
||||||
|
private readonly _depthMaterial: NodeMaterial
|
||||||
|
private readonly _depthSpriteMaterial: SpriteNodeMaterial
|
||||||
|
private readonly _prepareMaskMatA: NodeMaterial
|
||||||
|
private readonly _prepareMaskSpriteMatA: SpriteNodeMaterial
|
||||||
|
private readonly _copyMatA: NodeMaterial
|
||||||
|
private readonly _edgeDetectMatA: NodeMaterial
|
||||||
|
private readonly _blurMat1A: NodeMaterial
|
||||||
|
private readonly _blurMat2A: NodeMaterial
|
||||||
|
private readonly _compositeMatA: NodeMaterial
|
||||||
|
private readonly _prepareMaskMatB: NodeMaterial
|
||||||
|
private readonly _prepareMaskSpriteMatB: SpriteNodeMaterial
|
||||||
|
private readonly _copyMatB: NodeMaterial
|
||||||
|
private readonly _edgeDetectMatB: NodeMaterial
|
||||||
|
private readonly _blurMat1B: NodeMaterial
|
||||||
|
private readonly _blurMat2B: NodeMaterial
|
||||||
|
private readonly _compositeMatB: NodeMaterial
|
||||||
|
|
||||||
|
private readonly _cacheA = new Set<Object3D>()
|
||||||
|
private readonly _cacheB = new Set<Object3D>()
|
||||||
|
|
||||||
|
private readonly _textureNodeA: any
|
||||||
|
private readonly _textureNodeB: any
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
scene: any,
|
||||||
|
camera: any,
|
||||||
|
params: {
|
||||||
|
primaryObjects?: Object3D[]
|
||||||
|
secondaryObjects?: Object3D[]
|
||||||
|
primaryEdgeThickness?: any
|
||||||
|
secondaryEdgeThickness?: any
|
||||||
|
primaryEdgeGlow?: any
|
||||||
|
secondaryEdgeGlow?: any
|
||||||
|
downSampleRatio?: number
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
super('vec4')
|
||||||
|
|
||||||
|
const {
|
||||||
|
primaryObjects = [],
|
||||||
|
secondaryObjects = [],
|
||||||
|
primaryEdgeThickness = float(1),
|
||||||
|
secondaryEdgeThickness = float(1),
|
||||||
|
primaryEdgeGlow = float(0),
|
||||||
|
secondaryEdgeGlow = float(0),
|
||||||
|
downSampleRatio = 2,
|
||||||
|
} = params
|
||||||
|
|
||||||
|
this.scene = scene
|
||||||
|
this.camera = camera
|
||||||
|
this.primaryObjects = primaryObjects
|
||||||
|
this.secondaryObjects = secondaryObjects
|
||||||
|
this.primaryEdgeThicknessNode = nodeObject(primaryEdgeThickness)
|
||||||
|
this.secondaryEdgeThicknessNode = nodeObject(secondaryEdgeThickness)
|
||||||
|
this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow)
|
||||||
|
this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow)
|
||||||
|
this.downSampleRatio = downSampleRatio
|
||||||
|
this.updateBeforeType = NodeUpdateType.FRAME
|
||||||
|
|
||||||
|
this._depthRT = new RenderTarget()
|
||||||
|
this._depthRT.depthTexture = new DepthTexture()
|
||||||
|
this._depthRT.depthTexture.type = FloatType
|
||||||
|
|
||||||
|
this._groupA = makeGroupTargets(downSampleRatio)
|
||||||
|
this._groupB = makeGroupTargets(downSampleRatio)
|
||||||
|
|
||||||
|
this._cameraNear = reference('near', 'float', camera)
|
||||||
|
this._cameraFar = reference('far', 'float', camera)
|
||||||
|
this._blurDirectionA = uniform(new Vector2())
|
||||||
|
this._blurDirectionB = uniform(new Vector2())
|
||||||
|
this._depthTexUniform = texture(this._depthRT.depthTexture)
|
||||||
|
|
||||||
|
this._maskTexA = texture(this._groupA.maskBuffer.texture)
|
||||||
|
this._maskDownTexA = texture(this._groupA.maskDownSample.texture)
|
||||||
|
this._edge1TexA = texture(this._groupA.edgeBuffer1.texture)
|
||||||
|
this._edge2TexA = texture(this._groupA.edgeBuffer2.texture)
|
||||||
|
this._blurColorTexA = texture(this._groupA.edgeBuffer1.texture)
|
||||||
|
|
||||||
|
this._maskTexB = texture(this._groupB.maskBuffer.texture)
|
||||||
|
this._maskDownTexB = texture(this._groupB.maskDownSample.texture)
|
||||||
|
this._edge1TexB = texture(this._groupB.edgeBuffer1.texture)
|
||||||
|
this._edge2TexB = texture(this._groupB.edgeBuffer2.texture)
|
||||||
|
this._blurColorTexB = texture(this._groupB.edgeBuffer1.texture)
|
||||||
|
|
||||||
|
this._depthMaterial = new NodeMaterial()
|
||||||
|
this._depthMaterial.colorNode = color(0, 0, 0)
|
||||||
|
this._depthMaterial.name = 'MergedOutline.depth'
|
||||||
|
this._depthSpriteMaterial = new SpriteNodeMaterial()
|
||||||
|
this._depthSpriteMaterial.colorNode = color(0, 0, 0)
|
||||||
|
this._depthSpriteMaterial.name = 'MergedOutline.depthSprite'
|
||||||
|
|
||||||
|
this._prepareMaskMatA = new NodeMaterial()
|
||||||
|
this._prepareMaskMatA.name = 'MergedOutline.maskA'
|
||||||
|
this._prepareMaskSpriteMatA = new SpriteNodeMaterial()
|
||||||
|
this._prepareMaskSpriteMatA.name = 'MergedOutline.maskSpriteA'
|
||||||
|
this._copyMatA = new NodeMaterial()
|
||||||
|
this._copyMatA.name = 'MergedOutline.copyA'
|
||||||
|
this._edgeDetectMatA = new NodeMaterial()
|
||||||
|
this._edgeDetectMatA.name = 'MergedOutline.edgeA'
|
||||||
|
this._blurMat1A = new NodeMaterial()
|
||||||
|
this._blurMat1A.name = 'MergedOutline.blur1A'
|
||||||
|
this._blurMat2A = new NodeMaterial()
|
||||||
|
this._blurMat2A.name = 'MergedOutline.blur2A'
|
||||||
|
this._compositeMatA = new NodeMaterial()
|
||||||
|
this._compositeMatA.name = 'MergedOutline.compositeA'
|
||||||
|
|
||||||
|
this._prepareMaskMatB = new NodeMaterial()
|
||||||
|
this._prepareMaskMatB.name = 'MergedOutline.maskB'
|
||||||
|
this._prepareMaskSpriteMatB = new SpriteNodeMaterial()
|
||||||
|
this._prepareMaskSpriteMatB.name = 'MergedOutline.maskSpriteB'
|
||||||
|
this._copyMatB = new NodeMaterial()
|
||||||
|
this._copyMatB.name = 'MergedOutline.copyB'
|
||||||
|
this._edgeDetectMatB = new NodeMaterial()
|
||||||
|
this._edgeDetectMatB.name = 'MergedOutline.edgeB'
|
||||||
|
this._blurMat1B = new NodeMaterial()
|
||||||
|
this._blurMat1B.name = 'MergedOutline.blur1B'
|
||||||
|
this._blurMat2B = new NodeMaterial()
|
||||||
|
this._blurMat2B.name = 'MergedOutline.blur2B'
|
||||||
|
this._compositeMatB = new NodeMaterial()
|
||||||
|
this._compositeMatB.name = 'MergedOutline.compositeB'
|
||||||
|
|
||||||
|
// Output: R = visibleEdge, G = hiddenEdge
|
||||||
|
this._textureNodeA = passTexture(this, this._groupA.composite.texture)
|
||||||
|
this._textureNodeB = passTexture(this, this._groupB.composite.texture)
|
||||||
|
}
|
||||||
|
|
||||||
|
get primaryVisibleEdge() {
|
||||||
|
return this._textureNodeA.r
|
||||||
|
}
|
||||||
|
get primaryHiddenEdge() {
|
||||||
|
return this._textureNodeA.g
|
||||||
|
}
|
||||||
|
get secondaryVisibleEdge() {
|
||||||
|
return this._textureNodeB.r
|
||||||
|
}
|
||||||
|
get secondaryHiddenEdge() {
|
||||||
|
return this._textureNodeB.g
|
||||||
|
}
|
||||||
|
|
||||||
|
setSize(width: number, height: number) {
|
||||||
|
this._depthRT.setSize(width, height)
|
||||||
|
this._groupA.setSize(width, height)
|
||||||
|
this._groupB.setSize(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBefore(frame: any) {
|
||||||
|
const hasPrimary = this.primaryObjects.length > 0
|
||||||
|
const hasSecondary = this.secondaryObjects.length > 0
|
||||||
|
|
||||||
|
const { renderer } = frame
|
||||||
|
const { camera, scene } = this
|
||||||
|
|
||||||
|
_rendererState = RendererUtils.resetRendererAndSceneState(renderer, scene, _rendererState)
|
||||||
|
|
||||||
|
const size = renderer.getDrawingBufferSize(_size)
|
||||||
|
this.setSize(size.width, size.height)
|
||||||
|
|
||||||
|
// Clear composites for inactive groups so stale outlines don't persist on GPU.
|
||||||
|
// Must happen inside resetRendererAndSceneState to avoid MSAA state corruption.
|
||||||
|
if (!hasPrimary) {
|
||||||
|
renderer.setRenderTarget(this._groupA.composite)
|
||||||
|
renderer.clearColor()
|
||||||
|
}
|
||||||
|
if (!hasSecondary) {
|
||||||
|
renderer.setRenderTarget(this._groupB.composite)
|
||||||
|
renderer.clearColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAny = hasPrimary || hasSecondary
|
||||||
|
if (!hasAny) {
|
||||||
|
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.setClearColor(0xff_ff_ff, 1)
|
||||||
|
|
||||||
|
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
||||||
|
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
||||||
|
|
||||||
|
const savedName = scene.name
|
||||||
|
|
||||||
|
// ── 1. Shared depth pass: all objects NOT in either group ─────────────────
|
||||||
|
renderer.setRenderTarget(this._depthRT)
|
||||||
|
renderer.setRenderObjectFunction(
|
||||||
|
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||||||
|
const inCache = this._cacheA.has(obj) || this._cacheB.has(obj)
|
||||||
|
if (!inCache) {
|
||||||
|
const m = obj.isSprite ? this._depthSpriteMaterial : this._depthMaterial
|
||||||
|
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
scene.name = 'MergedOutline [ Depth ]'
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
|
||||||
|
// ── 2a. Primary mask pass ─────────────────────────────────────────────────
|
||||||
|
if (hasPrimary) {
|
||||||
|
renderer.setRenderTarget(this._groupA.maskBuffer)
|
||||||
|
renderer.setRenderObjectFunction(
|
||||||
|
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||||||
|
if (this._cacheA.has(obj)) {
|
||||||
|
const m = obj.isSprite ? this._prepareMaskSpriteMatA : this._prepareMaskMatA
|
||||||
|
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
scene.name = 'MergedOutline [ Mask A ]'
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2b. Secondary mask pass ───────────────────────────────────────────────
|
||||||
|
if (hasSecondary) {
|
||||||
|
renderer.setRenderTarget(this._groupB.maskBuffer)
|
||||||
|
renderer.setRenderObjectFunction(
|
||||||
|
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||||||
|
if (this._cacheB.has(obj)) {
|
||||||
|
const m = obj.isSprite ? this._prepareMaskSpriteMatB : this._prepareMaskMatB
|
||||||
|
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
scene.name = 'MergedOutline [ Mask B ]'
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.setRenderObjectFunction(_rendererState.renderObjectFunction)
|
||||||
|
this._cacheA.clear()
|
||||||
|
this._cacheB.clear()
|
||||||
|
scene.name = savedName
|
||||||
|
|
||||||
|
// ── 3–7. Edge detect + blur + composite per active group ──────────────────
|
||||||
|
if (hasPrimary) this._runEdgePipeline(renderer, 'A')
|
||||||
|
if (hasSecondary) this._runEdgePipeline(renderer, 'B')
|
||||||
|
|
||||||
|
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _runEdgePipeline(renderer: any, group: 'A' | 'B') {
|
||||||
|
const isA = group === 'A'
|
||||||
|
const g = isA ? this._groupA : this._groupB
|
||||||
|
const copyMat = isA ? this._copyMatA : this._copyMatB
|
||||||
|
const edgeMat = isA ? this._edgeDetectMatA : this._edgeDetectMatB
|
||||||
|
const blur1 = isA ? this._blurMat1A : this._blurMat1B
|
||||||
|
const blur2 = isA ? this._blurMat2A : this._blurMat2B
|
||||||
|
const blurDir = isA ? this._blurDirectionA : this._blurDirectionB
|
||||||
|
const blurColorTex = isA ? this._blurColorTexA : this._blurColorTexB
|
||||||
|
const compositeMat = isA ? this._compositeMatA : this._compositeMatB
|
||||||
|
|
||||||
|
_quadMesh.material = copyMat
|
||||||
|
renderer.setRenderTarget(g.maskDownSample)
|
||||||
|
_quadMesh.render(renderer)
|
||||||
|
|
||||||
|
_quadMesh.material = edgeMat
|
||||||
|
renderer.setRenderTarget(g.edgeBuffer1)
|
||||||
|
_quadMesh.render(renderer)
|
||||||
|
|
||||||
|
blurColorTex.value = g.edgeBuffer1.texture
|
||||||
|
blurDir.value.copy(_BLUR_X)
|
||||||
|
_quadMesh.material = blur1
|
||||||
|
renderer.setRenderTarget(g.blurBuffer1)
|
||||||
|
_quadMesh.render(renderer)
|
||||||
|
|
||||||
|
blurColorTex.value = g.blurBuffer1.texture
|
||||||
|
blurDir.value.copy(_BLUR_Y)
|
||||||
|
renderer.setRenderTarget(g.edgeBuffer1)
|
||||||
|
_quadMesh.render(renderer)
|
||||||
|
|
||||||
|
blurColorTex.value = g.edgeBuffer1.texture
|
||||||
|
blurDir.value.copy(_BLUR_X)
|
||||||
|
_quadMesh.material = blur2
|
||||||
|
renderer.setRenderTarget(g.blurBuffer2)
|
||||||
|
_quadMesh.render(renderer)
|
||||||
|
|
||||||
|
blurColorTex.value = g.blurBuffer2.texture
|
||||||
|
blurDir.value.copy(_BLUR_Y)
|
||||||
|
renderer.setRenderTarget(g.edgeBuffer2)
|
||||||
|
_quadMesh.render(renderer)
|
||||||
|
|
||||||
|
_quadMesh.material = compositeMat
|
||||||
|
renderer.setRenderTarget(g.composite)
|
||||||
|
_quadMesh.render(renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(_builder: any) {
|
||||||
|
// ── prepareMask ───────────────────────────────────────────────────────────
|
||||||
|
const buildPrepareMask = () => {
|
||||||
|
const depth = this._depthTexUniform.sample(screenUV)
|
||||||
|
const viewZ = this.camera.isPerspectiveCamera
|
||||||
|
? perspectiveDepthToViewZ(depth, this._cameraNear, this._cameraFar)
|
||||||
|
: orthographicDepthToViewZ(depth, this._cameraNear, this._cameraFar)
|
||||||
|
const depthTest = positionView.z.lessThanEqual(viewZ).select(1, 0)
|
||||||
|
return vec3(0.0, depthTest, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskColorA = buildPrepareMask()
|
||||||
|
this._prepareMaskMatA.colorNode = maskColorA
|
||||||
|
this._prepareMaskMatA.needsUpdate = true
|
||||||
|
this._prepareMaskSpriteMatA.colorNode = maskColorA
|
||||||
|
this._prepareMaskSpriteMatA.needsUpdate = true
|
||||||
|
|
||||||
|
const maskColorB = buildPrepareMask()
|
||||||
|
this._prepareMaskMatB.colorNode = maskColorB
|
||||||
|
this._prepareMaskMatB.needsUpdate = true
|
||||||
|
this._prepareMaskSpriteMatB.colorNode = maskColorB
|
||||||
|
this._prepareMaskSpriteMatB.needsUpdate = true
|
||||||
|
|
||||||
|
// ── Copy ──────────────────────────────────────────────────────────────────
|
||||||
|
this._copyMatA.fragmentNode = this._maskTexA
|
||||||
|
this._copyMatA.needsUpdate = true
|
||||||
|
this._copyMatB.fragmentNode = this._maskTexB
|
||||||
|
this._copyMatB.needsUpdate = true
|
||||||
|
|
||||||
|
// ── Edge detection ────────────────────────────────────────────────────────
|
||||||
|
const buildEdgeDetect = (maskDownTex: any) =>
|
||||||
|
Fn(() => {
|
||||||
|
const resolution = textureSize(maskDownTex)
|
||||||
|
const invSize = vec2(1).div(resolution).toVar()
|
||||||
|
const uvOffset = vec4(1.0, 0.0, 0.0, 1.0).mul(vec4(invSize, invSize))
|
||||||
|
const uvNode = uv()
|
||||||
|
const c1 = maskDownTex.sample(uvNode.add(uvOffset.xy)).toVar()
|
||||||
|
const c2 = maskDownTex.sample(uvNode.sub(uvOffset.xy)).toVar()
|
||||||
|
const c3 = maskDownTex.sample(uvNode.add(uvOffset.yw)).toVar()
|
||||||
|
const c4 = maskDownTex.sample(uvNode.sub(uvOffset.yw)).toVar()
|
||||||
|
const diff1 = mul(c1.r.sub(c2.r), 0.5)
|
||||||
|
const diff2 = mul(c3.r.sub(c4.r), 0.5)
|
||||||
|
const d = vec2(diff1, diff2).length()
|
||||||
|
const a1 = min(c1.g, c2.g)
|
||||||
|
const a2 = min(c3.g, c4.g)
|
||||||
|
const visibilityFactor = min(a1, a2)
|
||||||
|
// R = visible edge, G = hidden edge (matches OutlineNode convention)
|
||||||
|
const edgeColor = visibilityFactor
|
||||||
|
.oneMinus()
|
||||||
|
.greaterThan(0.001)
|
||||||
|
.select(vec3(1, 0, 0), vec3(0, 1, 0))
|
||||||
|
return vec4(edgeColor, 1).mul(d)
|
||||||
|
})()
|
||||||
|
|
||||||
|
this._edgeDetectMatA.fragmentNode = buildEdgeDetect(this._maskDownTexA)
|
||||||
|
this._edgeDetectMatA.needsUpdate = true
|
||||||
|
this._edgeDetectMatB.fragmentNode = buildEdgeDetect(this._maskDownTexB)
|
||||||
|
this._edgeDetectMatB.needsUpdate = true
|
||||||
|
|
||||||
|
// ── Separable blur ────────────────────────────────────────────────────────
|
||||||
|
const MAX_RADIUS = 4
|
||||||
|
|
||||||
|
const gaussianPdf = Fn(([x, sigma]: any[]) =>
|
||||||
|
float(0.398_94).mul(exp(float(-0.5).mul(x).mul(x).div(sigma.mul(sigma))).div(sigma)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const buildBlur = (maskDownTex: any, blurColorTex: any, blurDir: any, kernelRadius: any) =>
|
||||||
|
Fn(() => {
|
||||||
|
const resolution = textureSize(maskDownTex)
|
||||||
|
const invSize = vec2(1).div(resolution).toVar()
|
||||||
|
const uvNode = uv()
|
||||||
|
const sigma = kernelRadius.div(2).toVar()
|
||||||
|
const weightSum = gaussianPdf(0, sigma).toVar()
|
||||||
|
const diffuseSum = blurColorTex.sample(uvNode).mul(weightSum).toVar()
|
||||||
|
const delta = blurDir.mul(invSize).mul(kernelRadius).div(MAX_RADIUS).toVar()
|
||||||
|
const uvOffset = delta.toVar()
|
||||||
|
Loop(
|
||||||
|
{ start: int(1), end: int(MAX_RADIUS), type: 'int', condition: '<=' },
|
||||||
|
({ i }: any) => {
|
||||||
|
const x = kernelRadius.mul(float(i)).div(MAX_RADIUS)
|
||||||
|
const w = gaussianPdf(x, sigma)
|
||||||
|
diffuseSum.addAssign(
|
||||||
|
blurColorTex
|
||||||
|
.sample(uvNode.add(uvOffset))
|
||||||
|
.add(blurColorTex.sample(uvNode.sub(uvOffset)))
|
||||||
|
.mul(w),
|
||||||
|
)
|
||||||
|
weightSum.addAssign(w.mul(2))
|
||||||
|
uvOffset.addAssign(delta)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return diffuseSum.div(weightSum)
|
||||||
|
})()
|
||||||
|
|
||||||
|
this._blurMat1A.fragmentNode = buildBlur(
|
||||||
|
this._maskDownTexA,
|
||||||
|
this._blurColorTexA,
|
||||||
|
this._blurDirectionA,
|
||||||
|
this.primaryEdgeThicknessNode,
|
||||||
|
)
|
||||||
|
this._blurMat1A.needsUpdate = true
|
||||||
|
this._blurMat2A.fragmentNode = buildBlur(
|
||||||
|
this._maskDownTexA,
|
||||||
|
this._blurColorTexA,
|
||||||
|
this._blurDirectionA,
|
||||||
|
float(MAX_RADIUS),
|
||||||
|
)
|
||||||
|
this._blurMat2A.needsUpdate = true
|
||||||
|
this._blurMat1B.fragmentNode = buildBlur(
|
||||||
|
this._maskDownTexB,
|
||||||
|
this._blurColorTexB,
|
||||||
|
this._blurDirectionB,
|
||||||
|
this.secondaryEdgeThicknessNode,
|
||||||
|
)
|
||||||
|
this._blurMat1B.needsUpdate = true
|
||||||
|
this._blurMat2B.fragmentNode = buildBlur(
|
||||||
|
this._maskDownTexB,
|
||||||
|
this._blurColorTexB,
|
||||||
|
this._blurDirectionB,
|
||||||
|
float(MAX_RADIUS),
|
||||||
|
)
|
||||||
|
this._blurMat2B.needsUpdate = true
|
||||||
|
|
||||||
|
// ── Composite ─────────────────────────────────────────────────────────────
|
||||||
|
const buildComposite = (maskTex: any, edge1Tex: any, edge2Tex: any, edgeGlowNode: any) =>
|
||||||
|
Fn(() => maskTex.r.mul(edge1Tex.add(edge2Tex.mul(edgeGlowNode))))()
|
||||||
|
|
||||||
|
this._compositeMatA.fragmentNode = buildComposite(
|
||||||
|
this._maskTexA,
|
||||||
|
this._edge1TexA,
|
||||||
|
this._edge2TexA,
|
||||||
|
this.primaryEdgeGlowNode,
|
||||||
|
)
|
||||||
|
this._compositeMatA.needsUpdate = true
|
||||||
|
this._compositeMatB.fragmentNode = buildComposite(
|
||||||
|
this._maskTexB,
|
||||||
|
this._edge1TexB,
|
||||||
|
this._edge2TexB,
|
||||||
|
this.secondaryEdgeGlowNode,
|
||||||
|
)
|
||||||
|
this._compositeMatB.needsUpdate = true
|
||||||
|
|
||||||
|
return this._textureNodeA
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.primaryObjects.length = 0
|
||||||
|
this.secondaryObjects.length = 0
|
||||||
|
this._depthRT.dispose()
|
||||||
|
this._groupA.dispose()
|
||||||
|
this._groupB.dispose()
|
||||||
|
this._depthMaterial.dispose()
|
||||||
|
this._depthSpriteMaterial.dispose()
|
||||||
|
this._prepareMaskMatA.dispose()
|
||||||
|
this._prepareMaskSpriteMatA.dispose()
|
||||||
|
this._copyMatA.dispose()
|
||||||
|
this._edgeDetectMatA.dispose()
|
||||||
|
this._blurMat1A.dispose()
|
||||||
|
this._blurMat2A.dispose()
|
||||||
|
this._compositeMatA.dispose()
|
||||||
|
this._prepareMaskMatB.dispose()
|
||||||
|
this._prepareMaskSpriteMatB.dispose()
|
||||||
|
this._copyMatB.dispose()
|
||||||
|
this._edgeDetectMatB.dispose()
|
||||||
|
this._blurMat1B.dispose()
|
||||||
|
this._blurMat2B.dispose()
|
||||||
|
this._compositeMatB.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildCache(objects: Object3D[], cache: Set<Object3D>) {
|
||||||
|
for (const obj of objects) {
|
||||||
|
obj.traverse((child: any) => {
|
||||||
|
if (child.isMesh || child.isSprite) cache.add(child)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergedOutline = (
|
||||||
|
scene: any,
|
||||||
|
camera: any,
|
||||||
|
params?: ConstructorParameters<typeof MergedOutlineNode>[2],
|
||||||
|
) => new MergedOutlineNode(scene, camera, params)
|
||||||
Vendored
+3
@@ -78,18 +78,21 @@ interface ThreeJSXElements {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module 'react' {
|
declare module 'react' {
|
||||||
|
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
interface IntrinsicElements extends ThreeJSXElements {}
|
interface IntrinsicElements extends ThreeJSXElements {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'react/jsx-runtime' {
|
declare module 'react/jsx-runtime' {
|
||||||
|
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
interface IntrinsicElements extends ThreeJSXElements {}
|
interface IntrinsicElements extends ThreeJSXElements {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'react/jsx-dev-runtime' {
|
declare module 'react/jsx-dev-runtime' {
|
||||||
|
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
interface IntrinsicElements extends ThreeJSXElements {}
|
interface IntrinsicElements extends ThreeJSXElements {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ type ViewerState = {
|
|||||||
debugColors: boolean
|
debugColors: boolean
|
||||||
setDebugColors: (enabled: boolean) => void
|
setDebugColors: (enabled: boolean) => void
|
||||||
|
|
||||||
|
walkthroughMode: boolean
|
||||||
|
setWalkthroughMode: (mode: boolean) => void
|
||||||
|
|
||||||
cameraDragging: boolean
|
cameraDragging: boolean
|
||||||
setCameraDragging: (dragging: boolean) => void
|
setCameraDragging: (dragging: boolean) => void
|
||||||
}
|
}
|
||||||
@@ -194,6 +197,9 @@ const useViewer = create<ViewerState>()(
|
|||||||
debugColors: false,
|
debugColors: false,
|
||||||
setDebugColors: (enabled) => set({ debugColors: enabled }),
|
setDebugColors: (enabled) => set({ debugColors: enabled }),
|
||||||
|
|
||||||
|
walkthroughMode: false,
|
||||||
|
setWalkthroughMode: (mode) => set({ walkthroughMode: mode }),
|
||||||
|
|
||||||
cameraDragging: false,
|
cameraDragging: false,
|
||||||
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
|
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
baseMaterial,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
import { Color } from 'three'
|
||||||
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
||||||
|
|
||||||
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
const tmpVec = new Vector3()
|
const tmpVec = new Vector3()
|
||||||
const u = new Vector3()
|
const u = new Vector3()
|
||||||
const v = new Vector3()
|
const v = new Vector3()
|
||||||
|
const DEFAULT_WALL_COLOR = '#f2f0ed'
|
||||||
|
const WALL_HIGHLIGHT_PROFILES = {
|
||||||
|
delete: {
|
||||||
|
color: new Color('#dc2626'),
|
||||||
|
blend: 0.78,
|
||||||
|
emissiveIntensity: 0.46,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
color: new Color('#818cf8'),
|
||||||
|
blend: 0.32,
|
||||||
|
emissiveIntensity: 0.42,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
|
||||||
|
|
||||||
const dotPattern = Fn(() => {
|
const dotPattern = Fn(() => {
|
||||||
const scale = float(0.1)
|
const scale = float(0.1)
|
||||||
@@ -30,23 +51,15 @@ const dotPattern = Fn(() => {
|
|||||||
interface WallMaterials {
|
interface WallMaterials {
|
||||||
visible: MeshStandardNodeMaterial
|
visible: MeshStandardNodeMaterial
|
||||||
invisible: MeshStandardNodeMaterial
|
invisible: MeshStandardNodeMaterial
|
||||||
|
deleteVisible: MeshStandardNodeMaterial
|
||||||
|
deleteInvisible: MeshStandardNodeMaterial
|
||||||
|
highlightedVisible: MeshStandardNodeMaterial
|
||||||
|
highlightedInvisible: MeshStandardNodeMaterial
|
||||||
materialHash: string
|
materialHash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallMaterialCache = new Map<string, WallMaterials>()
|
const wallMaterialCache = new Map<string, WallMaterials>()
|
||||||
|
|
||||||
function getMaterialHash(wallNode: WallNode): string {
|
|
||||||
if (!wallNode.material) return 'none'
|
|
||||||
const mat = wallNode.material
|
|
||||||
if (mat.preset && mat.preset !== 'custom') {
|
|
||||||
return `preset-${mat.preset}`
|
|
||||||
}
|
|
||||||
if (mat.properties) {
|
|
||||||
return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}`
|
|
||||||
}
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
const presetColors = {
|
const presetColors = {
|
||||||
white: '#ffffff',
|
white: '#ffffff',
|
||||||
brick: '#8b4513',
|
brick: '#8b4513',
|
||||||
@@ -59,10 +72,43 @@ const presetColors = {
|
|||||||
marble: '#f5f5f5',
|
marble: '#f5f5f5',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
function getMaterialHash(wallNode: WallNode): string {
|
||||||
|
if (!wallNode.material) return 'none'
|
||||||
|
const mat = wallNode.material
|
||||||
|
if (mat.preset && mat.preset !== 'custom') {
|
||||||
|
return `preset-${mat.preset}`
|
||||||
|
}
|
||||||
|
if (mat.properties) {
|
||||||
|
return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}`
|
||||||
|
}
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
function getPresetColor(preset: string): string {
|
function getPresetColor(preset: string): string {
|
||||||
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
|
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHighlightedColor(color: string, kind: WallHighlightKind): Color {
|
||||||
|
const profile = WALL_HIGHLIGHT_PROFILES[kind]
|
||||||
|
return new Color(color).lerp(profile.color, profile.blend)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHighlightedWallMaterial(
|
||||||
|
material: MeshStandardNodeMaterial,
|
||||||
|
baseColor: string,
|
||||||
|
kind: WallHighlightKind,
|
||||||
|
): MeshStandardNodeMaterial {
|
||||||
|
const highlightedMaterial = material.clone()
|
||||||
|
const highlightedColor = getHighlightedColor(baseColor, kind)
|
||||||
|
const profile = WALL_HIGHLIGHT_PROFILES[kind]
|
||||||
|
|
||||||
|
highlightedMaterial.color = highlightedColor
|
||||||
|
highlightedMaterial.emissive = highlightedColor.clone()
|
||||||
|
highlightedMaterial.emissiveIntensity = profile.emissiveIntensity
|
||||||
|
|
||||||
|
return highlightedMaterial
|
||||||
|
}
|
||||||
|
|
||||||
function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
||||||
const cacheKey = wallNode.id
|
const cacheKey = wallNode.id
|
||||||
const materialHash = getMaterialHash(wallNode)
|
const materialHash = getMaterialHash(wallNode)
|
||||||
@@ -75,20 +121,26 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
existing.visible.dispose()
|
existing.visible.dispose()
|
||||||
existing.invisible.dispose()
|
existing.invisible.dispose()
|
||||||
|
existing.deleteVisible.dispose()
|
||||||
|
existing.deleteInvisible.dispose()
|
||||||
|
existing.highlightedVisible.dispose()
|
||||||
|
existing.highlightedInvisible.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
let userColor = '#ffffff'
|
let userColor = DEFAULT_WALL_COLOR
|
||||||
if (wallNode.material?.properties?.color) {
|
if (wallNode.material?.properties?.color) {
|
||||||
userColor = wallNode.material.properties.color
|
userColor = wallNode.material.properties.color
|
||||||
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
|
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
|
||||||
userColor = getPresetColor(wallNode.material.preset)
|
userColor = getPresetColor(wallNode.material.preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleMat = new MeshStandardNodeMaterial({
|
const visibleMat = wallNode.material
|
||||||
|
? new MeshStandardNodeMaterial({
|
||||||
color: userColor,
|
color: userColor,
|
||||||
roughness: 1,
|
roughness: 1,
|
||||||
metalness: 0,
|
metalness: 0,
|
||||||
})
|
})
|
||||||
|
: (baseMaterial.clone() as MeshStandardNodeMaterial)
|
||||||
|
|
||||||
const invisibleMat = new MeshStandardNodeMaterial({
|
const invisibleMat = new MeshStandardNodeMaterial({
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -98,7 +150,20 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
|
|||||||
emissive: userColor,
|
emissive: userColor,
|
||||||
})
|
})
|
||||||
|
|
||||||
const result: WallMaterials = { visible: visibleMat, invisible: invisibleMat, materialHash }
|
const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection')
|
||||||
|
const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection')
|
||||||
|
const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete')
|
||||||
|
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete')
|
||||||
|
|
||||||
|
const result: WallMaterials = {
|
||||||
|
visible: visibleMat,
|
||||||
|
invisible: invisibleMat,
|
||||||
|
deleteVisible,
|
||||||
|
deleteInvisible,
|
||||||
|
highlightedVisible,
|
||||||
|
highlightedInvisible,
|
||||||
|
materialHash,
|
||||||
|
}
|
||||||
wallMaterialCache.set(cacheKey, result)
|
wallMaterialCache.set(cacheKey, result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -135,78 +200,77 @@ export const WallCutout = () => {
|
|||||||
const lastUpdateTime = useRef(0)
|
const lastUpdateTime = useRef(0)
|
||||||
const lastWallMode = useRef<string>(useViewer.getState().wallMode)
|
const lastWallMode = useRef<string>(useViewer.getState().wallMode)
|
||||||
const lastNumberOfWalls = useRef(0)
|
const lastNumberOfWalls = useRef(0)
|
||||||
const lastWallMaterials = useRef<Map<string, WallMaterials>>(new Map())
|
const lastHighlightKey = useRef('')
|
||||||
|
|
||||||
useFrame(({ camera, clock }) => {
|
useFrame(({ camera, clock }) => {
|
||||||
const wallMode = useViewer.getState().wallMode
|
const wallMode = useViewer.getState().wallMode
|
||||||
|
const selectedIds = useViewer.getState().selection.selectedIds
|
||||||
|
const previewSelectedIds = useViewer.getState().previewSelectedIds
|
||||||
|
const hoveredId = useViewer.getState().hoveredId
|
||||||
|
const hoverHighlightMode = useViewer.getState().hoverHighlightMode
|
||||||
const currentTime = clock.elapsedTime
|
const currentTime = clock.elapsedTime
|
||||||
const currentCameraPosition = camera.position
|
const currentCameraPosition = camera.position
|
||||||
camera.getWorldDirection(tmpVec)
|
camera.getWorldDirection(tmpVec)
|
||||||
tmpVec.add(currentCameraPosition)
|
tmpVec.add(currentCameraPosition)
|
||||||
|
const highlightedWallIds = new Set(
|
||||||
|
[...selectedIds, ...previewSelectedIds].filter(
|
||||||
|
(id) => useScene.getState().nodes[id as AnyNodeId]?.type === 'wall',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const deleteHoveredWallId =
|
||||||
|
hoverHighlightMode === 'delete' &&
|
||||||
|
hoveredId &&
|
||||||
|
useScene.getState().nodes[hoveredId as AnyNodeId]?.type === 'wall'
|
||||||
|
? hoveredId
|
||||||
|
: null
|
||||||
|
const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}`
|
||||||
|
|
||||||
const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)
|
const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)
|
||||||
const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)
|
const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)
|
||||||
const timeSinceUpdate = currentTime - lastUpdateTime.current
|
const timeSinceUpdate = currentTime - lastUpdateTime.current
|
||||||
|
|
||||||
const shouldUpdate =
|
if (
|
||||||
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
|
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
|
||||||
lastWallMode.current !== wallMode ||
|
lastWallMode.current !== wallMode ||
|
||||||
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current
|
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current ||
|
||||||
|
lastHighlightKey.current !== highlightKey
|
||||||
|
) {
|
||||||
|
lastCameraPosition.current.copy(currentCameraPosition)
|
||||||
|
lastCameraTarget.current.copy(tmpVec)
|
||||||
|
lastUpdateTime.current = currentTime
|
||||||
|
camera.getWorldDirection(u)
|
||||||
|
|
||||||
const walls = sceneRegistry.byType.wall
|
const walls = sceneRegistry.byType.wall
|
||||||
const currentWallIds = new Set<string>()
|
|
||||||
|
|
||||||
walls.forEach((wallId) => {
|
walls.forEach((wallId) => {
|
||||||
const wallMesh = sceneRegistry.nodes.get(wallId)
|
const wallMesh = sceneRegistry.nodes.get(wallId)
|
||||||
if (!wallMesh) return
|
if (!wallMesh) return
|
||||||
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
|
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
|
||||||
if (!wallNode || wallNode.type !== 'wall') return
|
if (!wallNode || wallNode.type !== 'wall') return
|
||||||
|
|
||||||
currentWallIds.add(wallId)
|
|
||||||
|
|
||||||
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
|
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
|
||||||
|
const isDeleteHighlighted = deleteHoveredWallId === wallId
|
||||||
if (shouldUpdate) {
|
const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId)
|
||||||
const materials = getMaterialsForWall(wallNode)
|
const materials = getMaterialsForWall(wallNode)
|
||||||
;(wallMesh as Mesh).material = hideWall ? materials.invisible : materials.visible
|
|
||||||
|
if (hideWall) {
|
||||||
|
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||||
|
? materials.deleteInvisible
|
||||||
|
: isSelectionHighlighted
|
||||||
|
? materials.highlightedInvisible
|
||||||
|
: materials.invisible
|
||||||
} else {
|
} else {
|
||||||
const currentMaterial = (wallMesh as Mesh).material
|
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||||
const materials = wallMaterialCache.get(wallId)
|
? materials.deleteVisible
|
||||||
if (
|
: isSelectionHighlighted
|
||||||
!materials ||
|
? materials.highlightedVisible
|
||||||
currentMaterial !== (hideWall ? materials.invisible : materials.visible)
|
: wallNode.material
|
||||||
) {
|
? materials.visible
|
||||||
const newMaterials = getMaterialsForWall(wallNode)
|
: baseMaterial
|
||||||
;(wallMesh as Mesh).material = hideWall ? newMaterials.invisible : newMaterials.visible
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (shouldUpdate) {
|
|
||||||
lastCameraPosition.current.copy(currentCameraPosition)
|
|
||||||
lastCameraTarget.current.copy(tmpVec)
|
|
||||||
lastUpdateTime.current = currentTime
|
|
||||||
camera.getWorldDirection(u)
|
|
||||||
|
|
||||||
if (lastWallMode.current !== wallMode) {
|
|
||||||
wallMaterialCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [wallId, mats] of lastWallMaterials.current) {
|
|
||||||
if (!currentWallIds.has(wallId)) {
|
|
||||||
mats.visible.dispose()
|
|
||||||
mats.invisible.dispose()
|
|
||||||
wallMaterialCache.delete(wallId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastWallMaterials.current.clear()
|
|
||||||
for (const [wallId, mats] of wallMaterialCache) {
|
|
||||||
lastWallMaterials.current.set(wallId, mats)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastWallMode.current = wallMode
|
lastWallMode.current = wallMode
|
||||||
lastNumberOfWalls.current = sceneRegistry.byType.wall.size
|
lastNumberOfWalls.current = sceneRegistry.byType.wall.size
|
||||||
|
lastHighlightKey.current = highlightKey
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user