-
-
- Hinges Side
-
- handleUpdate({ hingesSide: v })}
- options={[
- { label: 'Left', value: 'left' },
- { label: 'Right', value: 'right' },
- ]}
- value={node.hingesSide}
- />
-
-
-
- Direction
-
- handleUpdate({ swingDirection: v })}
- options={[
- { label: 'Inward', value: 'inward' },
- { label: 'Outward', value: 'outward' },
- ]}
- value={node.swingDirection}
- />
-
+ {isSwingDoor && (
+
+
+ {supportsHingeSide && (
+
+
+ Hinges Side
+
+ handleUpdate({ hingesSide: v })}
+ options={[
+ { label: 'Left', value: 'left' },
+ { label: 'Right', value: 'right' },
+ ]}
+ value={node.hingesSide}
+ />
-
- )}
-
- {isSwingDoor && (
-
- handleUpdate({ threshold: checked })}
+ )}
+
+
+ Direction
+
+
handleUpdate({ swingDirection: v })}
+ options={[
+ { label: 'Inward', value: 'inward' },
+ { label: 'Outward', value: 'outward' },
+ ]}
+ value={node.swingDirection}
/>
- {node.threshold && (
-
+
+
+
+ )}
+
+ {isSwingDoor && (
+
+ handleUpdate({ threshold: checked })}
+ />
+ {node.threshold && (
+
+
handleUpdate({ showInfill: checked })}
+ />
diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx
index 8ab8e6ac3..c1fae2e70 100644
--- a/packages/editor/src/components/ui/panels/wall-panel.tsx
+++ b/packages/editor/src/components/ui/panels/wall-panel.tsx
@@ -4,60 +4,28 @@ import {
type AnyNode,
type AnyNodeId,
getClampedWallCurveOffset,
- getEffectiveWallSurfaceMaterial,
getMaxWallCurveOffset,
getWallCurveLength,
- getWallSurfaceMaterialSignature,
- type MaterialSchema,
normalizeWallCurveOffset,
useScene,
type WallNode,
- type WallSurfaceSide,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
-import { useCallback, useMemo } from 'react'
+import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
-import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
-function buildWallSurfaceMaterialPatch(
- node: WallNode,
- targetSide: WallSurfaceSide | null,
- material: MaterialSchema | undefined,
- materialPreset: string | undefined,
-): Partial {
- const nextSurfaceMaterial = { material, materialPreset }
- const nextInterior =
- targetSide === null || targetSide === 'interior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'interior')
- const nextExterior =
- targetSide === null || targetSide === 'exterior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'exterior')
-
- return {
- interiorMaterial: nextInterior.material,
- interiorMaterialPreset: nextInterior.materialPreset,
- exteriorMaterial: nextExterior.material,
- exteriorMaterialPreset: nextExterior.materialPreset,
- material: undefined,
- materialPreset: undefined,
- }
-}
-
export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
- const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -88,35 +56,6 @@ export function WallPanel() {
[selectedId, updateNode],
)
- const effectiveInteriorMaterial = useMemo(
- () => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
- [node],
- )
- const effectiveExteriorMaterial = useMemo(
- () => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
- [node],
- )
- const surfaceMaterialsMatch = useMemo(
- () =>
- getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
- getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
- [effectiveExteriorMaterial, effectiveInteriorMaterial],
- )
- const materialTargetSide =
- selectedMaterialTarget &&
- selectedMaterialTarget.nodeId === node?.id &&
- (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
- ? selectedMaterialTarget.role
- : null
- const materialPickerValue =
- materialTargetSide === 'interior'
- ? effectiveInteriorMaterial
- : materialTargetSide === 'exterior'
- ? effectiveExteriorMaterial
- : surfaceMaterialsMatch
- ? effectiveInteriorMaterial
- : {}
-
const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return
@@ -140,24 +79,6 @@ export function WallPanel() {
[node, handleUpdate],
)
- const handleMaterialPresetChange = useCallback(
- (materialPreset: string) => {
- if (!(node && materialTargetSide)) return
- handleUpdate(
- buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
- )
- },
- [handleUpdate, materialTargetSide, node],
- )
-
- const handleCustomMaterialChange = useCallback(
- (material: MaterialSchema) => {
- if (!(node && materialTargetSide)) return
- handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
- },
- [handleUpdate, materialTargetSide, node],
- )
-
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -239,23 +160,6 @@ export function WallPanel() {
)}
-
- {materialTargetSide ? null : (
-
- Click the wall face you want to edit. Materials now apply to one side at a time.
-
- )}
-
-
-
} label="Move" onClick={handleMove} />
diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx
index dbb096b91..b26cf0f0d 100644
--- a/packages/editor/src/components/ui/panels/window-panel.tsx
+++ b/packages/editor/src/components/ui/panels/window-panel.tsx
@@ -16,7 +16,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
-import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -81,14 +80,16 @@ const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType']
{ label: 'Louvered', value: 'louvered' },
]
-const rectangleOnlyWindowTypes = new Set([
- 'sliding',
- 'single-hung',
- 'double-hung',
- 'bay',
- 'bow',
+const shapedWindowTypes = new Set([
+ 'fixed',
+ 'casement',
+ 'awning',
+ 'hopper',
+ 'louvered',
])
+const silllessWindowTypes = new Set(['bay', 'bow'])
+
export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
@@ -110,14 +111,19 @@ export function WindowPanel() {
const handleUpdate = useCallback(
(updates: Partial) => {
if (!(selectedId && node)) return
+ const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
+ if (liveNode?.type !== 'window') return
+
const hasChange = Object.entries(updates).some(([key, value]) => {
- const currentValue = node[key as keyof WindowNode]
+ const currentValue = liveNode[key as keyof WindowNode]
return !isSameWindowValue(currentValue, value)
})
if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates)
- useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
+ const scene = useScene.getState()
+ scene.dirtyNodes.add(selectedId as AnyNodeId)
+ if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
},
[selectedId, node, updateNode],
)
@@ -321,6 +327,9 @@ export function WindowPanel() {
node.windowType === 'single-hung' ||
node.windowType === 'double-hung' ||
node.windowType === 'louvered'
+ const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed')
+ const supportsGrid = node.windowType === 'fixed'
+ const supportsSill = !silllessWindowTypes.has(node.windowType)
const setOperationState = (value: number) => {
useInteractive.getState().cancelWindowAnimation(node.id)
@@ -472,10 +481,10 @@ export function WindowPanel() {
handleUpdate({
windowType: option.value,
...(option.value === 'awning' ? { awningDirection } : {}),
- ...(rectangleOnlyWindowTypes.has(option.value)
+ ...(!shapedWindowTypes.has(option.value)
? { openingShape: 'rectangle' }
: {}),
- ...(option.value === 'bay' || option.value === 'bow' ? { sill: false } : {}),
+ ...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
})
}
type="button"
@@ -605,7 +614,7 @@ export function WindowPanel() {
/>
- {!(isOpening || rectangleOnlyWindowTypes.has(node.windowType)) && (
+ {!isOpening && supportsWindowShape && (
@@ -822,128 +831,132 @@ export function WindowPanel() {
/>
-
- {
- const n = Math.max(1, Math.min(8, Math.round(v)))
- handleUpdate({ columnRatios: Array(n).fill(1 / n) })
- }}
- precision={0}
- step={1}
- value={numCols}
- />
- {
- const n = Math.max(1, Math.min(8, Math.round(v)))
- handleUpdate({ rowRatios: Array(n).fill(1 / n) })
- }}
- precision={0}
- step={1}
- value={numRows}
- />
+ {supportsGrid && (
+
+ {
+ const n = Math.max(1, Math.min(8, Math.round(v)))
+ handleUpdate({ columnRatios: Array(n).fill(1 / n) })
+ }}
+ precision={0}
+ step={1}
+ value={numCols}
+ />
+ {
+ const n = Math.max(1, Math.min(8, Math.round(v)))
+ handleUpdate({ rowRatios: Array(n).fill(1 / n) })
+ }}
+ precision={0}
+ step={1}
+ value={numRows}
+ />
- {numCols > 1 && (
-
-
- Col Widths
+ {numCols > 1 && (
+
+
+ Col Widths
+
+ {normCols.map((ratio, i) => (
+
setColumnRatio(i, v / 100)}
+ precision={1}
+ step={1}
+ unit="%"
+ value={Math.round(ratio * 100 * 10) / 10}
+ />
+ ))}
+
+ handleUpdate({ columnDividerThickness: v })}
+ precision={3}
+ step={0.01}
+ unit="m"
+ value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
+ />
+
- {normCols.map((ratio, i) => (
-
setColumnRatio(i, v / 100)}
- precision={1}
- step={1}
- unit="%"
- value={Math.round(ratio * 100 * 10) / 10}
- />
- ))}
-
+ )}
+
+ {numRows > 1 && (
+
+
+ Row Heights
+
+ {normRows.map((ratio, i) => (
+
setRowRatio(i, v / 100)}
+ precision={1}
+ step={1}
+ unit="%"
+ value={Math.round(ratio * 100 * 10) / 10}
+ />
+ ))}
+
+ handleUpdate({ rowDividerThickness: v })}
+ precision={3}
+ step={0.01}
+ unit="m"
+ value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
+ />
+
+
+ )}
+
+ )}
+
+ {supportsSill && (
+
+ handleUpdate({ sill: checked })}
+ />
+ {node.sill && (
+
handleUpdate({ columnDividerThickness: v })}
+ label="Depth"
+ min={0}
+ onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
unit="m"
- value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
+ value={Math.round(node.sillDepth * 1000) / 1000}
/>
-
-
- )}
-
- {numRows > 1 && (
-
-
- Row Heights
-
- {normRows.map((ratio, i) => (
setRowRatio(i, v / 100)}
- precision={1}
- step={1}
- unit="%"
- value={Math.round(ratio * 100 * 10) / 10}
- />
- ))}
-
- handleUpdate({ rowDividerThickness: v })}
+ label="Thickness"
+ min={0}
+ onChange={(v) => handleUpdate({ sillThickness: v })}
precision={3}
step={0.01}
unit="m"
- value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
+ value={Math.round(node.sillThickness * 1000) / 1000}
/>
-
- )}
-
-
-
- handleUpdate({ sill: checked })}
- />
- {node.sill && (
-
- handleUpdate({ sillDepth: v })}
- precision={3}
- step={0.01}
- unit="m"
- value={Math.round(node.sillDepth * 1000) / 1000}
- />
- handleUpdate({ sillThickness: v })}
- precision={3}
- step={0.01}
- unit="m"
- value={Math.round(node.sillThickness * 1000) / 1000}
- />
-
- )}
-
+ )}
+
+ )}
>
)}
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts
index 2a5454f6d..734dfc622 100644
--- a/packages/editor/src/hooks/use-keyboard.ts
+++ b/packages/editor/src/hooks/use-keyboard.ts
@@ -3,6 +3,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history'
+import {
+ copySelectedNodesToEditorClipboard,
+ pasteEditorClipboardToLevel,
+} from '../lib/scene-clipboard'
import { sfxEmitter } from '../lib/sfx-bus'
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
import useEditor from '../store/use-editor'
@@ -106,6 +110,17 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('material-paint')
+ } else if (e.key === 'c' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
+ if (isVersionPreviewMode) return
+ e.preventDefault()
+ copySelectedNodesToEditorClipboard()
+ } else if (e.key === 'v' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
+ if (isVersionPreviewMode) return
+ e.preventDefault()
+ const result = pasteEditorClipboardToLevel()
+ if (result?.pastedIds.length) {
+ sfxEmitter.emit('sfx:item-place')
+ }
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
diff --git a/packages/editor/src/lib/floorplan/selection-tool.ts b/packages/editor/src/lib/floorplan/selection-tool.ts
index f6827e950..15e133f03 100644
--- a/packages/editor/src/lib/floorplan/selection-tool.ts
+++ b/packages/editor/src/lib/floorplan/selection-tool.ts
@@ -1,5 +1,6 @@
import type {
CeilingNode,
+ ColumnNode,
DoorNode,
ItemNode,
Point2D,
@@ -53,6 +54,11 @@ type CeilingEntry = {
holes: Point2D[][]
}
+type ColumnEntry = {
+ column: ColumnNode
+ polygon: Point2D[]
+}
+
type RoofEntry = {
roof: RoofNode
segments: Array<{
@@ -71,6 +77,7 @@ type FloorplanSelectionToolContext = {
walls: WallEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
+ columns: ColumnEntry[]
roofs: RoofEntry[]
openingHitTolerance: number
wallHitTolerance: number
@@ -123,6 +130,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
return stairHit.stair.id
}
+ const columnHit = context.columns.find(({ polygon }) =>
+ isPointInsidePolygon(context.point, polygon),
+ )
+ if (columnHit) {
+ return columnHit.column.id
+ }
+
const wallHit = context.walls.find(
({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) ||
@@ -166,6 +180,7 @@ type FloorplanSelectionBoundsContext = {
openings: OpeningPolygonEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
+ columns: ColumnEntry[]
stairs: StairEntry[]
roofs: RoofEntry[]
}
@@ -179,6 +194,7 @@ export function getFloorplanSelectionIdsInBounds({
openings,
slabs,
ceilings,
+ columns,
stairs,
roofs,
}: FloorplanSelectionBoundsContext) {
@@ -204,6 +220,9 @@ export function getFloorplanSelectionIdsInBounds({
const ceilingIds = ceilings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ ceiling }) => ceiling.id)
+ const columnIds = columns
+ .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
+ .map(({ column }) => column.id)
const stairIds = stairs
.filter((stair) =>
getStairHitPolygons(stair).some((polygon) =>
@@ -224,6 +243,7 @@ export function getFloorplanSelectionIdsInBounds({
...openingIds,
...slabIds,
...ceilingIds,
+ ...columnIds,
...stairIds,
...roofIds,
]),
diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts
new file mode 100644
index 000000000..4cbbef3ae
--- /dev/null
+++ b/packages/editor/src/lib/scene-clipboard.ts
@@ -0,0 +1,267 @@
+import {
+ AnyNode,
+ type AnyNodeId,
+ generateId,
+ type LevelNode,
+ type StairNode,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+
+type ClipboardPayload = {
+ copiedAt: number
+ nodes: AnyNode[]
+ rootIds: AnyNodeId[]
+}
+
+type PasteResult = {
+ pastedIds: AnyNodeId[]
+ skippedIds: AnyNodeId[]
+}
+
+const COPYABLE_ROOT_TYPES = new Set([
+ 'wall',
+ 'fence',
+ 'column',
+ 'item',
+ 'slab',
+ 'ceiling',
+ 'roof',
+ 'stair',
+ 'spawn',
+ 'zone',
+])
+
+let clipboardPayload: ClipboardPayload | null = null
+const subscribers = new Set<() => void>()
+
+function notifySubscribers() {
+ for (const subscriber of subscribers) {
+ subscriber()
+ }
+}
+
+export function subscribeEditorClipboard(subscriber: () => void) {
+ subscribers.add(subscriber)
+ return () => {
+ subscribers.delete(subscriber)
+ }
+}
+
+export function getEditorClipboardSnapshot() {
+ return clipboardPayload
+}
+
+export function hasEditorClipboard() {
+ return !!clipboardPayload && clipboardPayload.rootIds.length > 0
+}
+
+function extractIdPrefix(id: string) {
+ const underscoreIndex = id.indexOf('_')
+ return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex)
+}
+
+function collectSubtreeIds(
+ nodes: Record,
+ rootId: AnyNodeId,
+ ids: Set,
+) {
+ if (ids.has(rootId)) return
+ const node = nodes[rootId]
+ if (!node) return
+ ids.add(rootId)
+
+ if ('children' in node && Array.isArray(node.children)) {
+ for (const childId of node.children as AnyNodeId[]) {
+ collectSubtreeIds(nodes, childId, ids)
+ }
+ }
+}
+
+function hasSelectedAncestor(
+ nodes: Record,
+ id: AnyNodeId,
+ selectedIds: Set,
+) {
+ let parentId = nodes[id]?.parentId as AnyNodeId | null
+
+ while (parentId) {
+ if (selectedIds.has(parentId)) return true
+ parentId = nodes[parentId]?.parentId as AnyNodeId | null
+ }
+
+ return false
+}
+
+function isLevelChildRoot(nodes: Record, node: AnyNode) {
+ const parentId = node.parentId as AnyNodeId | null
+ if (!parentId) return true
+ return nodes[parentId]?.type === 'level'
+}
+
+function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
+ const scene = useScene.getState()
+ const resolvedLevelId =
+ targetLevelId ?? (useViewer.getState().selection.levelId as AnyNodeId | null)
+ if (!resolvedLevelId) return null
+
+ const level = scene.nodes[resolvedLevelId]
+ return level?.type === 'level' ? level : null
+}
+
+function getNextLevelId(level: LevelNode, nodes: Record) {
+ const parentId = level.parentId as AnyNodeId | null
+ if (!parentId) return null
+
+ const building = nodes[parentId]
+ if (!building || building.type !== 'building') return null
+
+ const siblingLevels = building.children
+ .map((childId) => nodes[childId as AnyNodeId])
+ .filter((node): node is LevelNode => node?.type === 'level')
+
+ return (
+ siblingLevels
+ .filter((candidate) => candidate.level > level.level)
+ .sort((a, b) => a.level - b.level)[0]?.id ?? null
+ )
+}
+
+function remapNodeReferences(
+ node: AnyNode,
+ oldId: AnyNodeId,
+ targetLevel: LevelNode,
+ idMap: Map,
+ rootIds: Set,
+ nodes: Record,
+) {
+ const clone = JSON.parse(JSON.stringify(node)) as AnyNode
+ ;(clone as Record).id = idMap.get(oldId)
+
+ if (rootIds.has(oldId)) {
+ clone.parentId = targetLevel.id
+ } else if (clone.parentId && typeof clone.parentId === 'string') {
+ clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
+ }
+
+ if ('children' in clone && Array.isArray(clone.children)) {
+ ;(clone as Record).children = (clone.children as AnyNodeId[])
+ .map((childId) => idMap.get(childId))
+ .filter((childId): childId is AnyNodeId => !!childId)
+ }
+
+ if ('wallId' in clone && typeof clone.wallId === 'string') {
+ const nextWallId = idMap.get(clone.wallId as AnyNodeId)
+ if (nextWallId) {
+ ;(clone as Record).wallId = nextWallId
+ } else {
+ delete (clone as Record).wallId
+ }
+ }
+
+ if (clone.type === 'stair') {
+ const nextLevelId = getNextLevelId(targetLevel, nodes)
+ ;(clone as StairNode).fromLevelId = targetLevel.id
+ ;(clone as StairNode).toLevelId = nextLevelId
+ }
+
+ const metadata =
+ clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
+ ? { ...(clone.metadata as Record) }
+ : {}
+ delete metadata.isNew
+ delete metadata.isTransient
+ ;(clone as Record).metadata = metadata
+
+ return AnyNode.parse(clone)
+}
+
+export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
+ const scene = useScene.getState()
+ const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
+ const selectedIdSet = new Set(ids)
+ const rootIds = ids.filter((id) => {
+ const node = scene.nodes[id]
+ return (
+ node &&
+ COPYABLE_ROOT_TYPES.has(node.type) &&
+ isLevelChildRoot(scene.nodes, node) &&
+ !hasSelectedAncestor(scene.nodes, id, selectedIdSet)
+ )
+ })
+
+ if (rootIds.length === 0) {
+ return false
+ }
+
+ const subtreeIds = new Set()
+ for (const rootId of rootIds) {
+ collectSubtreeIds(scene.nodes, rootId, subtreeIds)
+ }
+
+ clipboardPayload = {
+ copiedAt: Date.now(),
+ nodes: [...subtreeIds]
+ .map((id) => scene.nodes[id])
+ .filter((node): node is AnyNode => !!node)
+ .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
+ rootIds,
+ }
+ notifySubscribers()
+
+ return true
+}
+
+export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
+ const payload = clipboardPayload
+ const targetLevel = getPasteTargetLevel(targetLevelId)
+ if (!payload || !targetLevel) return null
+
+ const scene = useScene.getState()
+ const idMap = new Map()
+
+ for (const node of payload.nodes) {
+ idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
+ }
+
+ const rootIdSet = new Set(payload.rootIds)
+ const pastedNodes: AnyNode[] = []
+ const skippedIds: AnyNodeId[] = []
+
+ for (const node of payload.nodes) {
+ try {
+ pastedNodes.push(
+ remapNodeReferences(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes),
+ )
+ } catch (error) {
+ console.error('Failed to paste copied node', node.id, error)
+ skippedIds.push(node.id as AnyNodeId)
+ }
+ }
+
+ if (pastedNodes.length === 0) {
+ return { pastedIds: [], skippedIds }
+ }
+
+ scene.createNodes(
+ pastedNodes.map((node) => ({
+ node,
+ parentId: (node.parentId as AnyNodeId | null) ?? undefined,
+ })),
+ )
+
+ const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
+ const pastedRootIds = payload.rootIds
+ .map((rootId) => idMap.get(rootId))
+ .filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
+
+ useViewer.getState().setSelection({
+ levelId: targetLevel.id,
+ selectedIds: pastedRootIds,
+ })
+
+ return {
+ pastedIds: pastedRootIds,
+ skippedIds,
+ }
+}
diff --git a/packages/viewer/package.json b/packages/viewer/package.json
index e37f4ab4e..f4334b804 100644
--- a/packages/viewer/package.json
+++ b/packages/viewer/package.json
@@ -29,7 +29,6 @@
"three": "^0.184"
},
"dependencies": {
- "polygon-clipping": "^0.15.7",
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
"zustand": "^5"
diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
index 91e7487c5..2bf712e0c 100644
--- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
+++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
@@ -4,12 +4,19 @@ import {
resolveMaterial,
useRegistry,
} from '@pascal-app/core'
-import { useMemo, useRef } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
+import { BufferGeometry, Float32BufferAttribute } from 'three'
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
+function createEmptyGeometry() {
+ const geometry = new BufferGeometry()
+ geometry.setAttribute('position', new Float32BufferAttribute([], 3))
+ return geometry
+}
+
const gridScale = 5
const gridX = positionWorld.x.mul(gridScale).fract()
const gridY = positionWorld.z.mul(gridScale).fract()
@@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') {
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef(null!)
+ const placeholderGeometry = useMemo(createEmptyGeometry, [])
+ const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling')
+ useEffect(
+ () => () => {
+ placeholderGeometry.dispose()
+ gridPlaceholderGeometry.dispose()
+ },
+ [gridPlaceholderGeometry, placeholderGeometry],
+ )
+
const materials = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
@@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
])
return (
-
-
+
-
-
+ />
{node.children.map((childId) => (
))}
diff --git a/packages/viewer/src/components/renderers/column/column-renderer.tsx b/packages/viewer/src/components/renderers/column/column-renderer.tsx
index 2ec8d4e48..e90e02d16 100644
--- a/packages/viewer/src/components/renderers/column/column-renderer.tsx
+++ b/packages/viewer/src/components/renderers/column/column-renderer.tsx
@@ -1,6 +1,6 @@
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import { createContext, useContext, useMemo, useRef } from 'react'
-import type { Group, Material } from 'three'
+import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import {
@@ -84,6 +84,10 @@ function getShaftScaleAt(node: ColumnNode, t: number) {
type VectorTuple = [number, number, number]
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value))
+}
+
function MappedBox({
depth,
height,
@@ -117,6 +121,605 @@ function MappedBox({
)
}
+function FlatEndedBeam({
+ depth,
+ end,
+ start,
+ width,
+}: {
+ depth: number
+ end: VectorTuple
+ start: VectorTuple
+ width: number
+}) {
+ const dx = end[0] - start[0]
+ const dy = end[1] - start[1]
+ const dz = end[2] - start[2]
+ const length = Math.hypot(dx, dy, dz)
+ const geometry = useMemo(() => {
+ if (length <= 0.001 || width <= 0 || depth <= 0) return null
+
+ const halfWidth = width / 2
+ const halfDepth = depth / 2
+ const bottomY = start[1]
+ const topY = end[1]
+ const bottomCenterX = start[0]
+ const topCenterX = end[0]
+ const bottomCenterZ = start[2]
+ const topCenterZ = end[2]
+ const vertices: VectorTuple[] = [
+ [bottomCenterX - halfWidth, bottomY, bottomCenterZ - halfDepth],
+ [bottomCenterX + halfWidth, bottomY, bottomCenterZ - halfDepth],
+ [bottomCenterX + halfWidth, bottomY, bottomCenterZ + halfDepth],
+ [bottomCenterX - halfWidth, bottomY, bottomCenterZ + halfDepth],
+ [topCenterX - halfWidth, topY, topCenterZ - halfDepth],
+ [topCenterX + halfWidth, topY, topCenterZ - halfDepth],
+ [topCenterX + halfWidth, topY, topCenterZ + halfDepth],
+ [topCenterX - halfWidth, topY, topCenterZ + halfDepth],
+ ]
+ const faceQuads: [number, number, number, number][] = [
+ [0, 1, 2, 3],
+ [4, 7, 6, 5],
+ [0, 4, 5, 1],
+ [1, 5, 6, 2],
+ [2, 6, 7, 3],
+ [3, 7, 4, 0],
+ ]
+ const positions: number[] = []
+ const uvs: number[] = []
+ const pushVertex = (vertexIndex: number, uv: [number, number]) => {
+ const vertex = vertices[vertexIndex]
+ if (!vertex) return false
+ positions.push(...vertex)
+ uvs.push(...uv)
+ return true
+ }
+ const pushTriangle = (
+ a: number,
+ b: number,
+ c: number,
+ uvA: [number, number],
+ uvB: [number, number],
+ uvC: [number, number],
+ ) => {
+ const va = vertices[a]
+ const vb = vertices[b]
+ const vc = vertices[c]
+ if (!va || !vb || !vc) return
+ pushVertex(a, uvA)
+ pushVertex(b, uvB)
+ pushVertex(c, uvC)
+ }
+
+ for (const [a, b, c, d] of faceQuads) {
+ const va = vertices[a]
+ const vb = vertices[b]
+ const vc = vertices[c]
+ const vd = vertices[d]
+ if (!va || !vb || !vc || !vd) continue
+
+ const edgeU = Math.hypot(vb[0] - va[0], vb[1] - va[1], vb[2] - va[2])
+ const edgeV = Math.hypot(vd[0] - va[0], vd[1] - va[1], vd[2] - va[2])
+ const uvA: [number, number] = [0, 0]
+ const uvB: [number, number] = [edgeU, 0]
+ const uvC: [number, number] = [edgeU, edgeV]
+ const uvD: [number, number] = [0, edgeV]
+
+ pushTriangle(a, b, c, uvA, uvB, uvC)
+ pushTriangle(a, c, d, uvA, uvC, uvD)
+ pushTriangle(a, c, b, uvA, uvC, uvB)
+ pushTriangle(a, d, c, uvA, uvD, uvC)
+ }
+
+ const geometry = new BufferGeometry()
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
+ geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
+ geometry.setAttribute('uv2', new Float32BufferAttribute(uvs.slice(), 2))
+ geometry.computeVertexNormals()
+ return geometry
+ }, [depth, length, start, end, width])
+
+ if (!geometry) return null
+
+ return (
+
+
+
+
+ )
+}
+
+function AFrameSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const bottomSpread = Math.max(0.2, node.braceBottomSpread ?? Math.max(node.width * 3, 1.2))
+ const topSpread = clamp(node.braceTopSpread ?? 0.12, 0, bottomSpread)
+ const bottomY = 0
+ const topY = height
+ const leftBottom: VectorTuple = [-bottomSpread / 2, bottomY, 0]
+ const rightBottom: VectorTuple = [bottomSpread / 2, bottomY, 0]
+ const leftTop: VectorTuple = [-topSpread / 2, topY, 0]
+ const rightTop: VectorTuple = [topSpread / 2, topY, 0]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const footPlateWidth = braceWidth * 1.9
+ const footPlateDepth = braceDepth * 1.75
+ const topPlateWidth = Math.max(topSpread + braceWidth * 1.9, braceWidth * 2.2)
+ const topPlateDepth = braceDepth * 1.75
+
+ return (
+
+
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+
+
+
+ >
+ )}
+
+ )
+}
+
+function YFrameSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const topSpread = Math.max(0.2, node.braceTopSpread ?? 0.9)
+ const splitY = height * 0.56
+ const foot: VectorTuple = [0, 0, 0]
+ const split: VectorTuple = [0, splitY, 0]
+ const leftTop: VectorTuple = [-topSpread / 2, height, 0]
+ const rightTop: VectorTuple = [topSpread / 2, height, 0]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const footPlateWidth = braceWidth * 1.9
+ const footPlateDepth = braceDepth * 1.75
+ const topPlateWidth = topSpread + braceWidth * 1.9
+ const topPlateDepth = braceDepth * 1.75
+
+ return (
+
+
+
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+
+
+ >
+ )}
+
+ )
+}
+
+function VFrameSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const topSpread = Math.max(0.2, node.braceTopSpread ?? 1)
+ const foot: VectorTuple = [0, 0, 0]
+ const leftTop: VectorTuple = [-topSpread / 2, height, 0]
+ const rightTop: VectorTuple = [topSpread / 2, height, 0]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const footPlateWidth = braceWidth * 1.9
+ const footPlateDepth = braceDepth * 1.75
+ const topPlateWidth = topSpread + braceWidth * 1.9
+ const topPlateDepth = braceDepth * 1.75
+
+ return (
+
+
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+
+
+ >
+ )}
+
+ )
+}
+
+function XBraceSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const bottomSpread = Math.max(0.2, node.braceBottomSpread ?? 1)
+ const topSpread = Math.max(0.2, node.braceTopSpread ?? 1)
+ const leftBottom: VectorTuple = [-bottomSpread / 2, 0, 0]
+ const rightBottom: VectorTuple = [bottomSpread / 2, 0, 0]
+ const leftTop: VectorTuple = [-topSpread / 2, height, 0]
+ const rightTop: VectorTuple = [topSpread / 2, height, 0]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const footPlateWidth = braceWidth * 1.9
+ const footPlateDepth = braceDepth * 1.75
+ const topPlateWidth = braceWidth * 1.9
+ const topPlateDepth = braceDepth * 1.75
+
+ return (
+
+
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+function KBraceSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const spread = Math.max(0.2, Math.max(node.braceBottomSpread ?? 1, node.braceTopSpread ?? 1))
+ const leftBottom: VectorTuple = [-spread / 2, 0, 0]
+ const leftTop: VectorTuple = [-spread / 2, height, 0]
+ const centerBottom: VectorTuple = [0, 0, 0]
+ const centerMiddle: VectorTuple = [0, height / 2, 0]
+ const centerTop: VectorTuple = [0, height, 0]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const plateWidth = braceWidth * 1.9
+ const plateDepth = braceDepth * 1.75
+
+ return (
+
+
+
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+function SingleStrutSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const spread = Math.max(0.2, Math.max(node.braceBottomSpread ?? 1, node.braceTopSpread ?? 1))
+ const bottom: VectorTuple = [-spread / 2, 0, 0]
+ const top: VectorTuple = [spread / 2, height, 0]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const plateWidth = braceWidth * 1.9
+ const plateDepth = braceDepth * 1.75
+
+ return (
+
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+
+
+ >
+ )}
+
+ )
+}
+
+function TripodSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const width = Math.max(0.2, node.braceBottomSpread ?? 1.1)
+ const depth = Math.max(0.2, node.braceTopSpread ?? 1.1)
+ const top: VectorTuple = [0, height, 0]
+ const feet: VectorTuple[] = [
+ [0, 0, -depth / 2],
+ [-width / 2, 0, depth / 2],
+ [width / 2, 0, depth / 2],
+ ]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const plateWidth = braceWidth * 1.9
+ const plateDepth = braceDepth * 1.75
+
+ return (
+
+ {feet.map((foot, index) => (
+
+ ))}
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+ {feet.map((foot, index) => (
+
+ ))}
+
+ >
+ )}
+
+ )
+}
+
+function TrestleSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const width = Math.max(0.2, node.braceBottomSpread ?? 1.2)
+ const depth = Math.max(0.2, node.braceTopSpread ?? 1)
+ const zPositions = [-depth / 2, depth / 2]
+ const topPoints: VectorTuple[] = zPositions.map((z) => [0, height, z])
+ const footPoints: VectorTuple[] = zPositions.flatMap((z) => [
+ [-width / 2, 0, z] as VectorTuple,
+ [width / 2, 0, z] as VectorTuple,
+ ])
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const plateWidth = braceWidth * 1.9
+ const plateDepth = braceDepth * 1.75
+
+ return (
+
+ {zPositions.map((z, index) => {
+ const leftBottom: VectorTuple = [-width / 2, 0, z]
+ const rightBottom: VectorTuple = [width / 2, 0, z]
+ const top: VectorTuple = topPoints[index] ?? [0, height, z]
+ return (
+
+
+
+
+ )
+ })}
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+ {footPoints.map((foot, index) => (
+
+ ))}
+ {topPoints.map((top, index) => (
+
+ ))}
+ >
+ )}
+
+ )
+}
+
+function PortalFrameSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const width = Math.max(0.2, node.braceBottomSpread ?? 1.4)
+ const leftBottom: VectorTuple = [-width / 2, 0, 0]
+ const rightBottom: VectorTuple = [width / 2, 0, 0]
+ const leftTop: VectorTuple = [-width / 2, height, 0]
+ const rightTop: VectorTuple = [width / 2, height, 0]
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const plateWidth = braceWidth * 1.9
+ const plateDepth = braceDepth * 1.75
+
+ return (
+
+
+
+
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+ {[leftBottom, rightBottom].map((foot, index) => (
+
+ ))}
+ {[leftTop, rightTop].map((top, index) => (
+
+ ))}
+ >
+ )}
+
+ )
+}
+
+function BoxFrameSupport({ node }: { node: ColumnNode }) {
+ const height = Math.max(0.2, node.height)
+ const braceWidth = clamp(node.braceWidth ?? node.width, 0.04, 1.6)
+ const braceDepth = clamp(node.braceDepth ?? node.depth, 0.04, 1.6)
+ const width = Math.max(0.2, node.braceBottomSpread ?? 1.4)
+ const depth = Math.max(0.2, node.braceTopSpread ?? 1)
+ const corners: VectorTuple[] = [
+ [-width / 2, 0, -depth / 2],
+ [width / 2, 0, -depth / 2],
+ [width / 2, 0, depth / 2],
+ [-width / 2, 0, depth / 2],
+ ]
+ const topCorners = corners.map(([x, _y, z]) => [x, height, z] as VectorTuple)
+ const plateHeight = Math.max(0.035, Math.min(0.08, braceWidth * 0.45))
+ const plateWidth = braceWidth * 1.9
+ const plateDepth = braceDepth * 1.75
+
+ return (
+
+ {corners.map((corner, index) => (
+
+ ))}
+ {topCorners.map((corner, index) => (
+
+ ))}
+ {corners.map((corner, index) => (
+
+ ))}
+ {(node.bracePlateEnabled ?? true) && (
+ <>
+ {corners.map((corner, index) => (
+
+ ))}
+ {topCorners.map((corner, index) => (
+
+ ))}
+ >
+ )}
+
+ )
+}
+
function MappedCylinder({
height,
position,
@@ -244,7 +847,14 @@ function MappedTorus({
}) {
const geometry = useMemo(() => {
if (ringRadius <= 0 || tubeRadius <= 0) return null
- return createColumnTorusGeometry({ arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius })
+ return createColumnTorusGeometry({
+ arc,
+ ringRadius,
+ scaleX,
+ scaleY,
+ scaleZ,
+ tubeRadius,
+ })
}, [arc, ringRadius, scaleX, scaleY, scaleZ, tubeRadius])
if (!geometry) return null
@@ -1449,7 +2059,11 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
const handlers = useNodeEvents(node, 'column')
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const material = useMemo(
- () => createColumnMaterial({ material: node.material, materialPreset: node.materialPreset }),
+ () =>
+ createColumnMaterial({
+ material: node.material,
+ materialPreset: node.materialPreset,
+ }),
[
node.material,
node.material?.preset,
@@ -1479,41 +2093,73 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
visible={node.visible}
{...handlers}
>
-
-
-
-
-
-
-
-
-
-
-
+ {node.supportStyle === 'a-frame' ? (
+
+ ) : node.supportStyle === 'y-frame' ? (
+
+ ) : node.supportStyle === 'v-frame' ? (
+
+ ) : node.supportStyle === 'x-brace' ? (
+
+ ) : node.supportStyle === 'k-brace' ? (
+
+ ) : node.supportStyle === 'single-strut' ? (
+
+ ) : node.supportStyle === 'tripod' ? (
+
+ ) : node.supportStyle === 'trestle' ? (
+
+ ) : node.supportStyle === 'portal-frame' ? (
+
+ ) : node.supportStyle === 'box-frame' ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx
index 3931e0dac..8ac11b815 100644
--- a/packages/viewer/src/components/renderers/item/item-renderer.tsx
+++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx
@@ -156,9 +156,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const lightEffects =
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
+ // useGLTF caches scenes, and Clone shares child geometry/material references.
+ // Undo can unmount one item while another clone of the same asset still needs them.
return (
<>
{
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
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)
- }
+ for (const polygon of slabPolygons) {
+ if (polygon.length < 3) continue
+
+ const hole = new Path()
+ hole.moveTo(polygon[0]![0], -polygon[0]![1])
+ for (let i = 1; i < polygon.length; i++) {
+ hole.lineTo(polygon[i]![0], -polygon[i]![1])
}
+ hole.closePath()
+ shape.holes.push(hole)
}
return shape
diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx
index f59431e64..330c5d1b5 100644
--- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx
+++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx
@@ -11,6 +11,12 @@ import {
const slabMaterialCache = new Map()
+function createEmptyGeometry() {
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
+ return geometry
+}
+
function getSlabMaterial(
cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string },
@@ -47,11 +53,14 @@ function getSlabMaterial(
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef(null!)
+ const placeholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'slab', ref)
const handlers = useNodeEvents(node, 'slab')
+ useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
+
const material = useMemo(() => {
const resolvedMaterial = node.material
const resolvedMaterialPreset = node.materialPreset
@@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
return (
-
-
+ />
)
}
diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
index 9ff380069..09c0a17cf 100644
--- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
+++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx
@@ -1,12 +1,27 @@
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
-import { useLayoutEffect, useRef } from 'react'
-import type { Mesh } from 'three'
+import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
+import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
import { NodeRenderer } from '../node-renderer'
+function createEmptyWallGeometry() {
+ const geometry = new BufferGeometry()
+ geometry.setAttribute('position', new Float32BufferAttribute([], 3))
+ geometry.addGroup(0, 0, 0)
+ geometry.addGroup(0, 0, 1)
+ geometry.addGroup(0, 0, 2)
+ return geometry
+}
+
export const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef(null!)
+ const placeholderGeometry = useMemo(createEmptyWallGeometry, [])
+ const collisionPlaceholderGeometry = useMemo(() => {
+ const geometry = new BufferGeometry()
+ geometry.setAttribute('position', new Float32BufferAttribute([], 3))
+ return geometry
+ }, [])
useRegistry(node.id, 'wall', ref)
@@ -14,15 +29,31 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
useScene.getState().markDirty(node.id)
}, [node.id])
+ useEffect(() => {
+ return () => {
+ placeholderGeometry.dispose()
+ collisionPlaceholderGeometry.dispose()
+ }
+ }, [collisionPlaceholderGeometry, placeholderGeometry])
+
const handlers = useNodeEvents(node, 'wall')
const material = getVisibleWallMaterials(node)
return (
-
-
-
-
-
+
+
{node.children.map((childId) => (
diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx
index 54be8b51a..c90cafa67 100644
--- a/packages/viewer/src/components/viewer/ground-occluder.tsx
+++ b/packages/viewer/src/components/viewer/ground-occluder.tsx
@@ -1,5 +1,4 @@
import { type LevelNode, useScene } from '@pascal-app/core'
-import polygonClipping from 'polygon-clipping'
import { useMemo } from 'react'
import * as THREE from 'three'
import useViewer from '../../store/use-viewer'
@@ -63,33 +62,16 @@ export const GroundOccluder = () => {
polygons.push(node.polygon as [number, number][])
})
- if (polygons.length > 0) {
- // Format for polygon-clipping: [[[x, y], [x, y], ...]]
- const multiPolygons = polygons.map((pts) => {
- const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z)
- return [ring]
- })
+ for (const polygon of polygons) {
+ if (polygon.length < 3) continue
- // Union all polygons together to prevent artifacts from overlapping
- const unionedPolygons = polygonClipping.union(multiPolygons[0]!, ...multiPolygons.slice(1))
-
- // Add each resulting unioned polygon as a hole
- for (const geom of unionedPolygons) {
- // First ring in each geometry is the exterior ring
- if (geom.length > 0) {
- const ring = geom[0]!
- const hole = new THREE.Path()
-
- if (ring.length > 0) {
- 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()
- s.holes.push(hole)
- }
- }
+ const hole = new THREE.Path()
+ hole.moveTo(polygon[0]![0], -polygon[0]![1])
+ for (let i = 1; i < polygon.length; i++) {
+ hole.lineTo(polygon[i]![0], -polygon[i]![1])
}
+ hole.closePath()
+ s.holes.push(hole)
}
return s
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index 51e35c5c9..7ed731a05 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -1,6 +1,5 @@
'use client'
-import { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
@@ -28,6 +27,7 @@ import FrameLimiter from './frame-limiter'
import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor'
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
+import { SceneBvh } from './scene-bvh'
import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera'
@@ -219,9 +219,9 @@ const Viewer: React.FC = ({
/> */}
{useBvh ? (
-
+
-
+
) : (
)}
diff --git a/packages/viewer/src/components/viewer/scene-bvh.tsx b/packages/viewer/src/components/viewer/scene-bvh.tsx
new file mode 100644
index 000000000..c4517e6b5
--- /dev/null
+++ b/packages/viewer/src/components/viewer/scene-bvh.tsx
@@ -0,0 +1,137 @@
+import { useThree } from '@react-three/fiber'
+import {
+ type ReactNode,
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+} from 'react'
+import { Group, Mesh, type BufferGeometry } from 'three'
+import {
+ SAH,
+ acceleratedRaycast,
+ computeBoundsTree,
+ disposeBoundsTree,
+ type SplitStrategy,
+} from 'three-mesh-bvh'
+
+type SceneBvhProps = {
+ children?: ReactNode
+ enabled?: boolean
+ firstHitOnly?: boolean
+ strategy?: SplitStrategy
+ verbose?: boolean
+ setBoundingBox?: boolean
+ maxDepth?: number
+ maxLeafSize?: number
+ indirect?: boolean
+}
+
+const isMesh = (object: unknown): object is Mesh =>
+ !!object && typeof object === 'object' && (object as Mesh).isMesh === true
+
+const hasBvhCompatibleGeometry = (geometry?: BufferGeometry | null) => {
+ if (!geometry) return false
+
+ const position = geometry.getAttribute('position')
+ if (!position) return false
+
+ const vertexCount = geometry.getIndex()?.count ?? position.count
+ return vertexCount >= 3
+}
+
+export const SceneBvh = forwardRef(
+ (
+ {
+ children,
+ enabled = true,
+ firstHitOnly = false,
+ strategy = SAH,
+ verbose = false,
+ setBoundingBox = true,
+ maxDepth = 40,
+ maxLeafSize = 10,
+ indirect = false,
+ },
+ forwardedRef,
+ ) => {
+ const ref = useRef(null)
+ const raycaster = useThree((state) => state.raycaster)
+
+ useImperativeHandle(forwardedRef, () => ref.current!, [])
+
+ useEffect(() => {
+ if (!enabled || !ref.current) return
+
+ const options = {
+ strategy,
+ verbose,
+ setBoundingBox,
+ maxDepth,
+ maxLeafSize,
+ indirect,
+ }
+ const group = ref.current
+ const acceleratedMeshes = new Set()
+ const computedGeometries = new Set()
+
+ ;(raycaster as any).firstHitOnly = firstHitOnly
+
+ group.traverse((child) => {
+ if (!isMesh(child)) return
+
+ if (child.raycast === Mesh.prototype.raycast) {
+ child.raycast = acceleratedRaycast
+ acceleratedMeshes.add(child)
+ }
+
+ if (child.raycast !== acceleratedRaycast) return
+
+ const geometry = child.geometry
+ if (geometry.boundsTree || !hasBvhCompatibleGeometry(geometry)) return
+
+ try {
+ geometry.computeBoundsTree = computeBoundsTree
+ geometry.disposeBoundsTree = disposeBoundsTree
+ geometry.computeBoundsTree(options)
+ computedGeometries.add(geometry)
+ } catch (error) {
+ console.warn('[viewer] Skipping BVH for incompatible mesh geometry.', {
+ mesh: child.name || child.type,
+ error,
+ })
+ }
+ })
+
+ return () => {
+ delete (raycaster as any).firstHitOnly
+
+ for (const geometry of computedGeometries) {
+ if (geometry.boundsTree) {
+ geometry.disposeBoundsTree()
+ }
+ }
+
+ for (const mesh of acceleratedMeshes) {
+ if (mesh.raycast === acceleratedRaycast) {
+ mesh.raycast = Mesh.prototype.raycast
+ }
+ }
+ }
+ }, [
+ enabled,
+ firstHitOnly,
+ strategy,
+ verbose,
+ setBoundingBox,
+ maxDepth,
+ maxLeafSize,
+ indirect,
+ raycaster,
+ ])
+
+ return {children}
+ },
+)
+
+SceneBvh.displayName = 'SceneBvh'
diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts
index 10917121e..11f5653f5 100644
--- a/packages/viewer/src/lib/materials.ts
+++ b/packages/viewer/src/lib/materials.ts
@@ -282,16 +282,18 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
}
const map = getTexture(material)
-
- const threeMaterial = new THREE.MeshStandardMaterial({
+ const materialParams: THREE.MeshStandardMaterialParameters = {
color: props.color,
roughness: props.roughness,
metalness: props.metalness,
opacity: props.opacity,
transparent: props.transparent,
side: sideMap[props.side],
- map,
- })
+ }
+
+ if (map) materialParams.map = map
+
+ const threeMaterial = new THREE.MeshStandardMaterial(materialParams)
materialCache.set(cacheKey, threeMaterial)
return threeMaterial
diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx
index 82f06e797..c15d15258 100644
--- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx
+++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx
@@ -50,7 +50,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
if (gridMesh) {
gridMesh.geometry.dispose()
- gridMesh.geometry = newGeo
+ gridMesh.geometry = newGeo.clone()
}
// Position at the ceiling height
diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx
index 25c73ba07..42f7f0dc2 100644
--- a/packages/viewer/src/systems/fence/fence-system.tsx
+++ b/packages/viewer/src/systems/fence/fence-system.tsx
@@ -166,6 +166,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2)
const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005)
const isFloating = fence.baseStyle === 'floating'
+ const showInfill = fence.showInfill ?? true
const baseY = isFloating ? clearance : 0
const effectiveBaseHeight = baseHeight
const startInsetT = Math.min(0.499, edgeInset / length)
@@ -194,18 +195,18 @@ function createFenceParts(fence: FenceNode): FencePart[] {
)
}
- const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
+ const count = showInfill ? Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1) : 2
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
for (let index = 0; index < count; index += 1) {
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
const frame = getFencePointAt(fence, t)
const isEdgePost = index === 0 || index === count - 1
- const postHeight =
- isFloating && isEdgePost
- ? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
- : verticalHeight
- const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
+ const fullHeightPost = !showInfill || (isFloating && isEdgePost)
+ const postHeight = fullHeightPost
+ ? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
+ : verticalHeight
+ const postY = fullHeightPost ? postHeight / 2 : verticalY
parts.push({
position: [frame.point.x, postY, frame.point.y],
@@ -246,7 +247,9 @@ function generateFenceGeometry(fence: FenceNode) {
const geometries = parts.map(createFencePartGeometry)
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
- geometries.forEach((geometry) => geometry.dispose())
+ geometries.forEach((geometry) => {
+ geometry.dispose()
+ })
const mergedUv = merged.getAttribute('uv')
if (mergedUv) {
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx
index 919184eab..3d866445e 100644
--- a/packages/viewer/src/systems/slab/slab-system.tsx
+++ b/packages/viewer/src/systems/slab/slab-system.tsx
@@ -6,6 +6,7 @@ import {
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
+import { useEffect } from 'react'
import * as THREE from 'three'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -22,6 +23,16 @@ function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
export const SlabSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
+ const markDirty = useScene((state) => state.markDirty)
+
+ useEffect(() => {
+ const nodes = useScene.getState().nodes
+ for (const node of Object.values(nodes)) {
+ if (node.type === 'slab') {
+ markDirty(node.id)
+ }
+ }
+ }, [markDirty])
useFrame(() => {
if (dirtyNodes.size === 0) return