Add column hit testing and align column defaults

This commit is contained in:
sudhir
2026-05-08 14:31:58 +05:30
parent 53393b7def
commit 0514363136
13 changed files with 532 additions and 83 deletions
+16 -16
View File
@@ -88,7 +88,7 @@ export const ColumnNode = BaseNode.extend({
rotation: z.number().default(0), rotation: z.number().default(0),
style: ColumnStyle.default('plain'), style: ColumnStyle.default('plain'),
crossSection: ColumnCrossSection.default('round'), crossSection: ColumnCrossSection.default('round'),
height: z.number().positive().default(2.8), height: z.number().positive().default(2.5),
radius: z.number().positive().default(0.22), radius: z.number().positive().default(0.22),
width: z.number().positive().default(0.44), width: z.number().positive().default(0.44),
depth: z.number().positive().default(0.44), depth: z.number().positive().default(0.44),
@@ -179,7 +179,7 @@ export const COLUMN_PRESETS = {
label: 'Straight Round', label: 'Straight Round',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 2.9, height: 2.5,
radius: 0.22, radius: 0.22,
width: 0.44, width: 0.44,
depth: 0.44, depth: 0.44,
@@ -229,7 +229,7 @@ export const COLUMN_PRESETS = {
label: 'Square Block', label: 'Square Block',
style: 'faceted', style: 'faceted',
crossSection: 'square', crossSection: 'square',
height: 2.9, height: 2.5,
radius: 0.24, radius: 0.24,
width: 0.48, width: 0.48,
depth: 0.48, depth: 0.48,
@@ -279,7 +279,7 @@ export const COLUMN_PRESETS = {
label: 'Tapered Round', label: 'Tapered Round',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 3, height: 2.5,
radius: 0.23, radius: 0.23,
width: 0.46, width: 0.46,
depth: 0.46, depth: 0.46,
@@ -329,7 +329,7 @@ export const COLUMN_PRESETS = {
label: 'Soft Bulged', label: 'Soft Bulged',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 2.9, height: 2.5,
radius: 0.22, radius: 0.22,
width: 0.44, width: 0.44,
depth: 0.44, depth: 0.44,
@@ -379,7 +379,7 @@ export const COLUMN_PRESETS = {
label: 'Hourglass', label: 'Hourglass',
style: 'plain', style: 'plain',
crossSection: 'round', crossSection: 'round',
height: 2.9, height: 2.5,
radius: 0.22, radius: 0.22,
width: 0.44, width: 0.44,
depth: 0.44, depth: 0.44,
@@ -430,7 +430,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'a-frame', supportStyle: 'a-frame',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.08, radius: 0.08,
width: 0.16, width: 0.16,
depth: 0.16, depth: 0.16,
@@ -486,7 +486,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'y-frame', supportStyle: 'y-frame',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.7, height: 2.5,
radius: 0.08, radius: 0.08,
width: 0.16, width: 0.16,
depth: 0.16, depth: 0.16,
@@ -542,7 +542,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'v-frame', supportStyle: 'v-frame',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.08, radius: 0.08,
width: 0.16, width: 0.16,
depth: 0.16, depth: 0.16,
@@ -598,7 +598,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'x-brace', supportStyle: 'x-brace',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.07, radius: 0.07,
width: 0.14, width: 0.14,
depth: 0.14, depth: 0.14,
@@ -654,7 +654,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'k-brace', supportStyle: 'k-brace',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.07, radius: 0.07,
width: 0.14, width: 0.14,
depth: 0.14, depth: 0.14,
@@ -710,7 +710,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'single-strut', supportStyle: 'single-strut',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.07, radius: 0.07,
width: 0.14, width: 0.14,
depth: 0.14, depth: 0.14,
@@ -766,7 +766,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'tripod', supportStyle: 'tripod',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.07, radius: 0.07,
width: 0.14, width: 0.14,
depth: 0.14, depth: 0.14,
@@ -822,7 +822,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'trestle', supportStyle: 'trestle',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.07, radius: 0.07,
width: 0.14, width: 0.14,
depth: 0.14, depth: 0.14,
@@ -878,7 +878,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'portal-frame', supportStyle: 'portal-frame',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.08, radius: 0.08,
width: 0.16, width: 0.16,
depth: 0.16, depth: 0.16,
@@ -934,7 +934,7 @@ export const COLUMN_PRESETS = {
supportStyle: 'box-frame', supportStyle: 'box-frame',
style: 'faceted', style: 'faceted',
crossSection: 'rectangular', crossSection: 'rectangular',
height: 2.6, height: 2.5,
radius: 0.07, radius: 0.07,
width: 0.14, width: 0.14,
depth: 0.14, depth: 0.14,
@@ -625,6 +625,12 @@ type FloorplanSpawnEntry = {
rotation: number rotation: number
} }
type FloorplanColumnEntry = {
column: ColumnNode
points: string
polygon: Point2D[]
}
type ReferenceFloorData = { type ReferenceFloorData = {
ceilingPolygons: CeilingPolygonEntry[] ceilingPolygons: CeilingPolygonEntry[]
columnEntries: ReferenceFloorColumnEntry[] columnEntries: ReferenceFloorColumnEntry[]
@@ -8189,6 +8195,28 @@ export function FloorplanPanel() {
: entry, : entry,
) )
}, [zoneBoundaryDraft, zonePolygons]) }, [zoneBoundaryDraft, zonePolygons])
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(
() =>
levelDescendantNodes.flatMap((node) => {
if (!(node.type === 'column' && node.visible !== false)) {
return []
}
const polygon = getColumnPlanFootprint(node)
if (polygon.length < 3) {
return []
}
return [
{
column: node,
points: formatPolygonPoints(polygon),
polygon,
},
]
}),
[levelDescendantNodes],
)
const levelDescendantNodeById = useMemo( const levelDescendantNodeById = useMemo(
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
[levelDescendantNodes], [levelDescendantNodes],
@@ -13016,6 +13044,7 @@ export function FloorplanPanel() {
) )
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
ceilingPolygons: displayCeilingPolygons, ceilingPolygons: displayCeilingPolygons,
columnPolygons: floorplanColumnEntries,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -3,6 +3,7 @@
import type { import type {
AnyNode, AnyNode,
CeilingNode, CeilingNode,
ColumnNode,
DoorNode, DoorNode,
ItemNode, ItemNode,
Point2D, Point2D,
@@ -46,6 +47,11 @@ type CeilingPolygonEntry = {
holes: Point2D[][] holes: Point2D[][]
} }
type ColumnPolygonEntry = {
column: ColumnNode
polygon: Point2D[]
}
type FloorplanRoofEntry = { type FloorplanRoofEntry = {
roof: RoofNode roof: RoofNode
segments: Array<{ segments: Array<{
@@ -72,6 +78,7 @@ type FloorplanStairEntry = {
type UseFloorplanHitTestingArgs = { type UseFloorplanHitTestingArgs = {
ceilingPolygons: CeilingPolygonEntry[] ceilingPolygons: CeilingPolygonEntry[]
columnPolygons: ColumnPolygonEntry[]
displaySlabPolygons: SlabPolygonEntry[] displaySlabPolygons: SlabPolygonEntry[]
displayWallPolygons: WallPolygonEntry[] displayWallPolygons: WallPolygonEntry[]
floorplanItemEntries: FloorplanItemEntry[] floorplanItemEntries: FloorplanItemEntry[]
@@ -88,6 +95,7 @@ type UseFloorplanHitTestingArgs = {
export function useFloorplanHitTesting({ export function useFloorplanHitTesting({
ceilingPolygons, ceilingPolygons,
columnPolygons,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -117,11 +125,13 @@ export function useFloorplanHitTesting({
slabs: displaySlabPolygons, slabs: displaySlabPolygons,
openingHitTolerance: floorplanOpeningHitTolerance, openingHitTolerance: floorplanOpeningHitTolerance,
wallHitTolerance: floorplanWallHitTolerance, wallHitTolerance: floorplanWallHitTolerance,
columns: columnPolygons,
getOpeningCenterLine, getOpeningCenterLine,
}) })
}, },
[ [
ceilingPolygons, ceilingPolygons,
columnPolygons,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -149,10 +159,12 @@ export function useFloorplanHitTesting({
openings: openingsPolygons, openings: openingsPolygons,
roofs: floorplanRoofEntries, roofs: floorplanRoofEntries,
slabs: displaySlabPolygons, slabs: displaySlabPolygons,
columns: columnPolygons,
stairs: floorplanStairEntries, stairs: floorplanStairEntries,
}), }),
[ [
ceilingPolygons, ceilingPolygons,
columnPolygons,
displaySlabPolygons, displaySlabPolygons,
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
@@ -13,7 +13,6 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Group } from 'three' import type { Group } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
const COLUMN_ICON = ( const COLUMN_ICON = (
@@ -70,8 +69,6 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
useScene.getState().createNode(column, currentLevelId) useScene.getState().createNode(column, currentLevelId)
onPlaced?.(column.id) onPlaced?.(column.id)
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
} }
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
@@ -88,7 +85,7 @@ export const ColumnTool: React.FC<ColumnToolProps> = ({ currentLevelId, onPlaced
return ( return (
<CursorSphere <CursorSphere
color="#a78bfa" color="#a78bfa"
height={2.8} height={2.5}
ref={cursorRef} ref={cursorRef}
showTooltip showTooltip
tooltipContent={COLUMN_ICON} tooltipContent={COLUMN_ICON}
@@ -20,6 +20,9 @@ import {
export type FencePlanPoint = WallPlanPoint export type FencePlanPoint = WallPlanPoint
const FENCE_CORNER_SNAP_RADIUS = 0.28
const FENCE_SPAN_SNAP_RADIUS = 0.16
type SegmentNode = { type SegmentNode = {
start: FencePlanPoint start: FencePlanPoint
end: FencePlanPoint end: FencePlanPoint
@@ -57,46 +60,68 @@ function findFenceSnapTarget(
fences: FenceNode[], fences: FenceNode[],
ignoreFenceIds: string[] = [], ignoreFenceIds: string[] = [],
): FencePlanPoint | null { ): FencePlanPoint | null {
const radiusSquared = 0.35 ** 2 const cornerRadiusSquared = FENCE_CORNER_SNAP_RADIUS ** 2
const spanRadiusSquared = FENCE_SPAN_SNAP_RADIUS ** 2
const ignoredFenceIds = new Set(ignoreFenceIds) const ignoredFenceIds = new Set(ignoreFenceIds)
let bestTarget: FencePlanPoint | null = null let bestCornerTarget: FencePlanPoint | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY let bestCornerDistanceSquared = Number.POSITIVE_INFINITY
let bestSpanTarget: FencePlanPoint | null = null
let bestSpanDistanceSquared = Number.POSITIVE_INFINITY
for (const fence of fences) { for (const fence of fences) {
if (ignoredFenceIds.has(fence.id)) { if (ignoredFenceIds.has(fence.id)) {
continue continue
} }
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end] for (const candidate of [fence.start, fence.end]) {
if (isCurvedWall(fence)) { const candidateDistanceSquared = distanceSquared(point, candidate)
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3)) if (
for (let index = 0; index <= sampleCount; index += 1) { candidateDistanceSquared > cornerRadiusSquared ||
const frame = getWallCurveFrameAt(fence, index / sampleCount) candidateDistanceSquared >= bestCornerDistanceSquared
candidates.push([frame.point.x, frame.point.y]) ) {
} continue
} else {
candidates.push(projectPointOntoSegment(point, fence))
} }
for (const candidate of candidates) { bestCornerTarget = candidate
bestCornerDistanceSquared = candidateDistanceSquared
}
if (isCurvedWall(fence)) {
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
for (let index = 1; index < sampleCount; index += 1) {
const frame = getWallCurveFrameAt(fence, index / sampleCount)
const candidate: FencePlanPoint = [frame.point.x, frame.point.y]
const candidateDistanceSquared = distanceSquared(point, candidate)
if (
candidateDistanceSquared > spanRadiusSquared ||
candidateDistanceSquared >= bestSpanDistanceSquared
) {
continue
}
bestSpanTarget = candidate
bestSpanDistanceSquared = candidateDistanceSquared
}
} else {
const candidate = projectPointOntoSegment(point, fence)
if (!candidate) { if (!candidate) {
continue continue
} }
const candidateDistanceSquared = distanceSquared(point, candidate) const candidateDistanceSquared = distanceSquared(point, candidate)
if ( if (
candidateDistanceSquared > radiusSquared || candidateDistanceSquared > spanRadiusSquared ||
candidateDistanceSquared >= bestDistanceSquared candidateDistanceSquared >= bestSpanDistanceSquared
) { ) {
continue continue
} }
bestTarget = candidate bestSpanTarget = candidate
bestDistanceSquared = candidateDistanceSquared bestSpanDistanceSquared = candidateDistanceSquared
} }
} }
return bestTarget return bestCornerTarget ?? bestSpanTarget
} }
export function snapFenceDraftPoint(args: { export function snapFenceDraftPoint(args: {
@@ -25,8 +25,13 @@ import {
import { isWallLongEnough } from '../wall/wall-drafting' import { isWallLongEnough } from '../wall/wall-drafting'
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting' import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
function samePoint(a: FencePlanPoint, b: FencePlanPoint) { function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1] return (
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
)
} }
type SegmentLike = { type SegmentLike = {
@@ -114,10 +119,9 @@ type LinkedFenceSnapshot = {
function getLinkedFenceSnapshots(args: { function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id'] fenceId: FenceNode['id']
fenceParentId: string | null fenceParentId: string | null
originalStart: FencePlanPoint linkedPoint: FencePlanPoint
originalEnd: FencePlanPoint
}) { }) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args const { fenceId, fenceParentId, linkedPoint } = args
const { nodes } = useScene.getState() const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = [] const snapshots: LinkedFenceSnapshot[] = []
@@ -130,12 +134,7 @@ function getLinkedFenceSnapshots(args: {
continue continue
} }
if ( if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) {
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
) {
continue continue
} }
@@ -152,24 +151,14 @@ function getLinkedFenceSnapshots(args: {
function getLinkedFenceUpdates( function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[], linkedFences: LinkedFenceSnapshot[],
originalStart: FencePlanPoint, linkedPoint: FencePlanPoint,
originalEnd: FencePlanPoint, nextLinkedPoint: FencePlanPoint,
nextStart: FencePlanPoint,
nextEnd: FencePlanPoint,
) { ) {
return linkedFences.map((fence) => ({ return linkedFences.map((fence) => ({
id: fence.id, id: fence.id,
curveOffset: fence.curveOffset, curveOffset: fence.curveOffset,
start: samePoint(fence.start, originalStart) start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start,
? nextStart end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end,
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
})) }))
} }
@@ -181,6 +170,11 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const nodeIdRef = useRef(target.fence.id) const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint) const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint) const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const originalMovingPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.start] as FencePlanPoint)
: ([...target.fence.end] as FencePlanPoint),
)
const fixedPointRef = useRef<FencePlanPoint>( const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start' target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint) ? ([...target.fence.end] as FencePlanPoint)
@@ -190,8 +184,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
getLinkedFenceSnapshots({ getLinkedFenceSnapshots({
fenceId: target.fence.id, fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null, fenceParentId: target.fence.parentId ?? null,
originalStart: target.fence.start, linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end,
originalEnd: target.fence.end,
}), }),
) )
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null) const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
@@ -211,6 +204,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const nodeId = nodeIdRef.current const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current const originalEnd = originalEndRef.current
const originalMovingPoint = originalMovingPointRef.current
const fixedPoint = fixedPointRef.current const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes) const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter( const levelWalls = siblings.filter(
@@ -244,13 +238,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
const linkedUpdates = detachLinkedFences const linkedUpdates = detachLinkedFences
? [] ? []
: getLinkedFenceUpdates( : getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint)
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)
previewRef.current = { start: nextStart, end: nextEnd } previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]]) setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
setAngleLabel( setAngleLabel(
@@ -321,10 +309,8 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
? [] ? []
: getLinkedFenceUpdates( : getLinkedFenceUpdates(
linkedOriginalsRef.current, linkedOriginalsRef.current,
originalStart, originalMovingPoint,
originalEnd, target.endpoint === 'start' ? preview.start : preview.end,
preview.start,
preview.end,
)), )),
]) ])
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
@@ -1,7 +1,10 @@
import '../../../three-types'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { import {
type AnyNodeId, type AnyNodeId,
type CeilingNode, type CeilingNode,
type ColumnNode,
emitter, emitter,
type GridEvent, type GridEvent,
type ItemNode, type ItemNode,
@@ -14,6 +17,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import type { ThreeElements } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import {
Box3, Box3,
@@ -34,6 +38,12 @@ 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'
declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements extends ThreeElements {}
}
}
/** /**
* Module-level flag to prevent the SelectionManager from deselecting * Module-level flag to prevent the SelectionManager from deselecting
* on the grid:click that fires right after a box-select drag completes. * on the grid:click that fires right after a box-select drag completes.
@@ -240,6 +250,11 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (objectBoundsIntersectsBounds(node.id, bounds)) { if (objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.id) result.push(node.id)
} }
} else if (node.type === 'column') {
const column = node as ColumnNode
if (objectBoundsIntersectsBounds(column.id, bounds)) {
result.push(column.id)
}
} }
} }
} else if (phase === 'structure' && structureLayer === 'zones') { } else if (phase === 'structure' && structureLayer === 'zones') {
@@ -26,7 +26,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react' import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { import {
type ButtonHTMLAttributes, type ButtonHTMLAttributes,
type CSSProperties, type CSSProperties,
@@ -34,6 +34,7 @@ import {
useEffect, useEffect,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react' } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { import {
@@ -41,6 +42,12 @@ import {
type LevelDuplicatePreset, type LevelDuplicatePreset,
} from '../../lib/level-duplication' } from '../../lib/level-duplication'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import {
getEditorClipboardSnapshot,
pasteEditorClipboardToLevel,
subscribeEditorClipboard,
} from '../../lib/scene-clipboard'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { LevelDuplicateDialog } from './level-duplicate-dialog' import { LevelDuplicateDialog } from './level-duplicate-dialog'
import { import {
@@ -126,6 +133,7 @@ function LevelRow({
dragHandleRef, dragHandleRef,
onSelect, onSelect,
onDuplicate, onDuplicate,
onPaste,
onRequestDelete, onRequestDelete,
}: { }: {
level: LevelNode level: LevelNode
@@ -135,6 +143,7 @@ function LevelRow({
dragHandleRef?: (element: HTMLButtonElement | null) => void dragHandleRef?: (element: HTMLButtonElement | null) => void
onSelect: () => void onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void onDuplicate: (preset?: LevelDuplicatePreset) => void
onPaste?: () => void
onRequestDelete: () => void onRequestDelete: () => void
}) { }) {
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
@@ -223,6 +232,19 @@ function LevelRow({
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
Duplicate with options... Duplicate with options...
</button> </button>
{onPaste && (
<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-foreground"
onClick={(e) => {
e.stopPropagation()
onPaste()
}}
type="button"
>
<ClipboardPaste className="h-3 w-3" />
Paste copied selection
</button>
)}
<button <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" 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) => { onClick={(e) => {
@@ -256,12 +278,14 @@ function SortableLevelRow({
isSelected, isSelected,
onSelect, onSelect,
onDuplicate, onDuplicate,
onPaste,
onRequestDelete, onRequestDelete,
}: { }: {
level: LevelNode level: LevelNode
isSelected: boolean isSelected: boolean
onSelect: () => void onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void onDuplicate: (preset?: LevelDuplicatePreset) => void
onPaste?: () => void
onRequestDelete: () => void onRequestDelete: () => void
}) { }) {
const { const {
@@ -291,6 +315,7 @@ function SortableLevelRow({
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
onPaste={onPaste}
onRequestDelete={onRequestDelete} onRequestDelete={onRequestDelete}
onSelect={onSelect} onSelect={onSelect}
/> />
@@ -310,6 +335,11 @@ export function FloatingLevelSelector() {
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null) const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null) const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
const clipboardSnapshot = useSyncExternalStore(
subscribeEditorClipboard,
getEditorClipboardSnapshot,
getEditorClipboardSnapshot,
)
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 4 }, activationConstraint: { distance: 4 },
@@ -424,6 +454,13 @@ export function FloatingLevelSelector() {
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes], [createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
) )
const handlePasteToLevel = useCallback((level: LevelNode) => {
const result = pasteEditorClipboardToLevel(level.id)
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
}, [])
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingLevelId(String(event.active.id)) setDraggingLevelId(String(event.active.id))
}, []) }, [])
@@ -523,6 +560,9 @@ export function FloatingLevelSelector() {
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)} onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onPaste={
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
}
onRequestDelete={() => setDeletingLevel(level)} onRequestDelete={() => setDeletingLevel(level)}
onSelect={() => onSelect={() =>
setSelection( setSelection(
+15
View File
@@ -3,6 +3,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction' import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import {
copySelectedNodesToEditorClipboard,
pasteEditorClipboardToLevel,
} from '../lib/scene-clipboard'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction' import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
@@ -105,6 +109,17 @@ export const useKeyboard = ({
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements') useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('material-paint') 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)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
@@ -1,5 +1,6 @@
import type { import type {
CeilingNode, CeilingNode,
ColumnNode,
DoorNode, DoorNode,
ItemNode, ItemNode,
Point2D, Point2D,
@@ -53,6 +54,11 @@ type CeilingEntry = {
holes: Point2D[][] holes: Point2D[][]
} }
type ColumnEntry = {
column: ColumnNode
polygon: Point2D[]
}
type RoofEntry = { type RoofEntry = {
roof: RoofNode roof: RoofNode
segments: Array<{ segments: Array<{
@@ -71,6 +77,7 @@ type FloorplanSelectionToolContext = {
walls: WallEntry[] walls: WallEntry[]
slabs: SlabEntry[] slabs: SlabEntry[]
ceilings: CeilingEntry[] ceilings: CeilingEntry[]
columns: ColumnEntry[]
roofs: RoofEntry[] roofs: RoofEntry[]
openingHitTolerance: number openingHitTolerance: number
wallHitTolerance: number wallHitTolerance: number
@@ -123,6 +130,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
return stairHit.stair.id 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( const wallHit = context.walls.find(
({ wall, polygon }) => ({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) || isPointInsidePolygon(context.point, polygon) ||
@@ -166,6 +180,7 @@ type FloorplanSelectionBoundsContext = {
openings: OpeningPolygonEntry[] openings: OpeningPolygonEntry[]
slabs: SlabEntry[] slabs: SlabEntry[]
ceilings: CeilingEntry[] ceilings: CeilingEntry[]
columns: ColumnEntry[]
stairs: StairEntry[] stairs: StairEntry[]
roofs: RoofEntry[] roofs: RoofEntry[]
} }
@@ -179,6 +194,7 @@ export function getFloorplanSelectionIdsInBounds({
openings, openings,
slabs, slabs,
ceilings, ceilings,
columns,
stairs, stairs,
roofs, roofs,
}: FloorplanSelectionBoundsContext) { }: FloorplanSelectionBoundsContext) {
@@ -204,6 +220,9 @@ export function getFloorplanSelectionIdsInBounds({
const ceilingIds = ceilings const ceilingIds = ceilings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ ceiling }) => ceiling.id) .map(({ ceiling }) => ceiling.id)
const columnIds = columns
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ column }) => column.id)
const stairIds = stairs const stairIds = stairs
.filter((stair) => .filter((stair) =>
getStairHitPolygons(stair).some((polygon) => getStairHitPolygons(stair).some((polygon) =>
@@ -224,6 +243,7 @@ export function getFloorplanSelectionIdsInBounds({
...openingIds, ...openingIds,
...slabIds, ...slabIds,
...ceilingIds, ...ceilingIds,
...columnIds,
...stairIds, ...stairIds,
...roofIds, ...roofIds,
]), ]),
+267
View File
@@ -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<AnyNode['type']>([
'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<AnyNodeId, AnyNode>,
rootId: AnyNodeId,
ids: Set<AnyNodeId>,
) {
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<AnyNodeId, AnyNode>,
id: AnyNodeId,
selectedIds: Set<AnyNodeId>,
) {
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<AnyNodeId, AnyNode>, 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<AnyNodeId, AnyNode>) {
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<AnyNodeId, AnyNodeId>,
rootIds: Set<AnyNodeId>,
nodes: Record<AnyNodeId, AnyNode>,
) {
const clone = JSON.parse(JSON.stringify(node)) as AnyNode
;(clone as Record<string, unknown>).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<string, unknown>).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<string, unknown>).wallId = nextWallId
} else {
delete (clone as Record<string, unknown>).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<string, unknown>) }
: {}
delete metadata.isNew
delete metadata.isTransient
;(clone as Record<string, unknown>).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<AnyNodeId>()
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<AnyNodeId, AnyNodeId>()
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,
}
}
@@ -181,23 +181,55 @@ function FlatEndedBeam({
[3, 7, 4, 0], [3, 7, 4, 0],
]; ];
const positions: number[] = []; const positions: number[] = [];
const pushTriangle = (a: number, b: number, c: 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 va = vertices[a];
const vb = vertices[b]; const vb = vertices[b];
const vc = vertices[c]; const vc = vertices[c];
if (!va || !vb || !vc) return; if (!va || !vb || !vc) return;
positions.push(...va, ...vb, ...vc); pushVertex(a, uvA);
pushVertex(b, uvB);
pushVertex(c, uvC);
}; };
for (const [a, b, c, d] of faceQuads) { for (const [a, b, c, d] of faceQuads) {
pushTriangle(a, b, c); const va = vertices[a];
pushTriangle(a, c, d); const vb = vertices[b];
pushTriangle(a, c, b); const vc = vertices[c];
pushTriangle(a, d, 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(); const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
geometry.setAttribute("uv2", new Float32BufferAttribute(uvs.slice(), 2));
geometry.computeVertexNormals(); geometry.computeVertexNormals();
return geometry; return geometry;
}, [depth, length, start, end, width]); }, [depth, length, start, end, width]);
@@ -1,11 +1,12 @@
import { useFrame } from '@react-three/fiber'
import { import {
type AnyNodeId, type AnyNodeId,
getRenderableSlabPolygon, getRenderableSlabPolygon,
sceneRegistry,
type SlabNode, type SlabNode,
sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
@@ -22,6 +23,16 @@ function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
export const SlabSystem = () => { export const SlabSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) 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(() => { useFrame(() => {
if (dirtyNodes.size === 0) return if (dirtyNodes.size === 0) return