Merge pull request #89 from pascalorg/feat/polish-tools-and-perf

Feat/polish tools and perf
This commit is contained in:
Wassim SAMAD
2026-02-09 16:15:10 +09:00
committed by GitHub
20 changed files with 1063 additions and 969 deletions
+145
View File
@@ -0,0 +1,145 @@
'use client'
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh, Vector2 } from 'three'
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useGridEvents } from '@/hooks/use-grid-events'
export const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
cellColor = '#888888',
sectionSize = 1,
sectionThickness = 1,
sectionColor = '#000000',
fadeDistance = 100,
fadeStrength = 1,
revealRadius = 10,
}: {
cellSize?: number
cellThickness?: number
cellColor?: string
sectionSize?: number
sectionThickness?: number
sectionColor?: string
fadeDistance?: number
fadeStrength?: number
revealRadius?: number
}) => {
const cursorPositionRef = useRef(new Vector2(0, 0))
const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy
// Cursor position uniform
const cursorPos = uniform(cursorPositionRef.current)
// Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const r = pos.div(size)
const fw = fwidth(r)
// Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs()
// Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub(
grid.x
.div(fw.x)
.add(1 - thickness)
.min(1),
)
const lineY = float(1).sub(
grid.y
.div(fw.y)
.add(1 - thickness)
.min(1),
)
// Combine both axes - max gives us lines in both directions
return lineX.max(lineY)
}
const g1 = getGrid(cellSize, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center
const dist = pos.length()
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)
// Cursor reveal effect - distance from cursor
const cursorDist = pos.sub(cursorPos).length()
const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1)
// Mix colors based on section grid
const gridColor = mix(
color(cellColor),
color(sectionColor),
float(sectionThickness).mul(g2).min(1),
)
// Combined alpha with cursor fade
const alpha = g1.add(g2).mul(fade).mul(cursorFade)
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
depthWrite: false,
})
}, [
cellSize,
cellThickness,
cellColor,
sectionSize,
sectionThickness,
sectionColor,
fadeDistance,
fadeStrength,
revealRadius,
])
const gridRef = useRef<Mesh>(null!)
const [gridY, setGridY] = useState(0)
// Use custom raycasting for grid events (independent of mesh events)
useGridEvents(gridY)
// Update cursor position from grid:move events
useEffect(() => {
const onGridMove = (event: GridEvent) => {
cursorPositionRef.current.set(event.position[0], -event.position[2])
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [])
useFrame((_, delta) => {
const currentLevelId = useViewer.getState().selection.levelId
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
}
}
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
gridRef.current.position.y = newY
setGridY(newY)
})
return (
<mesh rotation-x={-Math.PI / 2} material={material} ref={gridRef}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
)
}
+3 -127
View File
@@ -1,19 +1,7 @@
'use client' 'use client'
import { import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
initSpaceDetectionSync, import { Viewer } from '@pascal-app/viewer'
initSpatialGridSync,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useGridEvents, useViewer, Viewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useMemo, useRef } from 'react'
import { MathUtils, type Mesh } from 'three'
import { color, float, fract, fwidth, mix, positionLocal } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useKeyboard } from '@/hooks/use-keyboard' import { useKeyboard } from '@/hooks/use-keyboard'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { ZoneSystem } from '../systems/zone/zone-system' import { ZoneSystem } from '../systems/zone/zone-system'
@@ -24,6 +12,7 @@ import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls' import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager' import { ExportManager } from './export-manager'
import { Grid } from './grid'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
useScene.getState().loadScene() useScene.getState().loadScene()
@@ -55,116 +44,3 @@ export default function Editor() {
</div> </div>
) )
} }
const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
cellColor = '#888888',
sectionSize = 1,
sectionThickness = 1,
sectionColor = '#000000',
fadeDistance = 100,
fadeStrength = 1,
}: {
cellSize?: number
cellThickness?: number
cellColor?: string
sectionSize?: number
sectionThickness?: number
sectionColor?: string
fadeDistance?: number
fadeStrength?: number
}) => {
const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy
// Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const r = pos.div(size)
const fw = fwidth(r)
// Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs()
// Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub(
grid.x
.div(fw.x)
.add(1 - thickness)
.min(1),
)
const lineY = float(1).sub(
grid.y
.div(fw.y)
.add(1 - thickness)
.min(1),
)
// Combine both axes - max gives us lines in both directions
return lineX.max(lineY)
}
const g1 = getGrid(cellSize, cellThickness)
const g2 = getGrid(sectionSize, sectionThickness)
// Distance fade from center
const dist = pos.length()
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength)
// Mix colors based on section grid
const gridColor = mix(
color(cellColor),
color(sectionColor),
float(sectionThickness).mul(g2).min(1),
)
// Combined alpha
const alpha = g1.add(g2).mul(fade)
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
depthWrite: false,
})
}, [
cellSize,
cellThickness,
cellColor,
sectionSize,
sectionThickness,
sectionColor,
fadeDistance,
fadeStrength,
])
const handlers = useGridEvents()
const gridRef = useRef<Mesh>(null!)
useFrame((_, delta) => {
const currentLevelId = useViewer.getState().selection.levelId
let targetY = 0
if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
if (levelMesh) {
targetY = levelMesh.position.y
} else {
// Fallback: compute from level node data when mesh isn't registered yet
const levelNode = useScene.getState().nodes[currentLevelId]
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode
const LEVEL_HEIGHT = 2.5
const EXPLODED_GAP = 5
targetY = ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0))
}
}
}
gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
})
return (
<mesh rotation-x={-Math.PI / 2} material={material} {...handlers} ref={gridRef}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
)
}
@@ -0,0 +1,42 @@
import { resolveLevelId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor'
interface CeilingBoundaryEditorProps {
ceilingId: CeilingNode['id']
}
/**
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
* Uses the generic PolygonEditor component
*/
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
const ceilingNode = useScene((state) => state.nodes[ceilingId])
const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(ceilingId, { polygon: newPolygon })
// Re-assert selection so the ceiling stays selected after the edit
setSelection({ selectedIds: [ceilingId] })
},
[ceilingId, updateNode, setSelection],
)
if (!ceiling || !ceiling.polygon || ceiling.polygon.length < 3) return null
return (
<PolygonEditor
polygon={ceiling.polygon}
color="#d4d4d4"
onPolygonChange={handlePolygonChange}
minVertices={3}
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
surfaceHeight={ceiling.height ?? 2.5}
/>
)
}
@@ -1,377 +1,233 @@
import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode, sceneRegistry } from "@pascal-app/core"; import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from '@pascal-app/viewer'
import { useFrame } from "@react-three/fiber"; import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from "react"; import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three"; import useEditor from '@/store/use-editor'
import useEditor from "@/store/use-editor";
const CEILING_HEIGHT = 2.52; // Slightly above default ceiling height const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02; // Small offset above floor level const GRID_OFFSET = 0.02
const UP = new Vector3(0, 1, 0);
/** /**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/ */
const calculateSnapPoint = ( const calculateSnapPoint = (
lastPoint: [number, number], lastPoint: [number, number],
currentPoint: [number, number] currentPoint: [number, number],
): [number, number] => { ): [number, number] => {
const [x1, y1] = lastPoint; const [x1, y1] = lastPoint
const [x, y] = currentPoint; const [x, y] = currentPoint
const dx = x - x1; const dx = x - x1
const dy = y - y1; const dy = y - y1
const absDx = Math.abs(dx); const absDx = Math.abs(dx)
const absDy = Math.abs(dy); const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines // Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy; const horizontalDist = absDy
const verticalDist = absDx; const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy); const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to // Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist); const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) { if (minDist === diagonalDist) {
// Snap to 45° diagonal // Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy); const diagonalLength = Math.min(absDx, absDy)
return [ return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
x1 + Math.sign(dx) * diagonalLength,
y1 + Math.sign(dy) * diagonalLength,
];
} else if (minDist === horizontalDist) { } else if (minDist === horizontalDist) {
// Snap to horizontal // Snap to horizontal
return [x, y1]; return [x, y1]
} else { } else {
// Snap to vertical // Snap to vertical
return [x1, y]; return [x1, y]
} }
}; }
/** /**
* Creates a ceiling with the given polygon points * Creates a ceiling with the given polygon points
*/ */
const commitCeilingDrawing = ( const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
levelId: LevelNode["id"], const { createNode, nodes } = useScene.getState()
points: Array<[number, number]>
) => {
const { createNode, nodes } = useScene.getState();
// Count existing ceilings for naming // Count existing ceilings for naming
const ceilingCount = Object.values(nodes).filter((n) => n.type === "ceiling").length; const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
const name = `Ceiling ${ceilingCount + 1}`; const name = `Ceiling ${ceilingCount + 1}`
const ceiling = CeilingNode.parse({ const ceiling = CeilingNode.parse({
name, name,
polygon: points, polygon: points,
}); })
createNode(ceiling, levelId); createNode(ceiling, levelId)
}; }
type PreviewState = {
points: Array<[number, number]>;
cursorPoint: [number, number] | null;
levelY: number;
};
// Helper to validate point values (no NaN or Infinity)
const isValidPoint = (
pt: [number, number] | null | undefined
): pt is [number, number] => {
if (!pt) return false;
return Number.isFinite(pt[0]) && Number.isFinite(pt[1]);
};
export const CeilingTool: React.FC = () => { export const CeilingTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null); const cursorRef = useRef<Mesh>(null)
const gridCursorRef = useRef<Mesh>(null); const gridCursorRef = useRef<Mesh>(null)
const mainLineRef = useRef<Line>(null!); const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!); const closingLineRef = useRef<Line>(null!)
const pointsRef = useRef<Array<[number, number]>>([]); const currentLevelId = useViewer((state) => state.selection.levelId)
const levelYRef = useRef(0); const setTool = useEditor((state) => state.setTool)
const cursorPositionRef = useRef<[number, number]>([0, 0]);
const lastDisplayRef = useRef<{ x: number; z: number } | null>(null);
const currentLevelId = useViewer((state) => state.selection.levelId);
const setTool = useEditor((state) => state.setTool);
// Reusable objects for raycasting (created once, avoid per-frame allocations) const [points, setPoints] = useState<Array<[number, number]>>([])
const raycaster = useMemo(() => new Raycaster(), []); const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const levelPlane = useMemo(() => new Plane(UP, 0), []); const [levelY, setLevelY] = useState(0)
const hitPoint = useMemo(() => new Vector3(), []);
// Preview state for reactive rendering (for shape and point markers) // Update cursor position and lines on grid move
const [preview, setPreview] = useState<PreviewState>({
points: [],
cursorPoint: null,
levelY: 0,
});
// Resolve the current level's Y position from scene registry or node data
const getLevelY = (): number => {
if (!currentLevelId) return 0;
const levelMesh = sceneRegistry.nodes.get(currentLevelId);
if (levelMesh) return levelMesh.position.y;
const levelNode = useScene.getState().nodes[currentLevelId];
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode;
const LEVEL_HEIGHT = 2.5;
const EXPLODED_GAP = 5;
return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0));
}
return 0;
};
// Imperatively update line geometries (called from useFrame)
const updateLines = () => {
if (!mainLineRef.current || !closingLineRef.current) return;
const points = pointsRef.current;
const cursorPosition = cursorPositionRef.current;
const ceilingY = levelYRef.current + CEILING_HEIGHT;
if (points.length === 0) {
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
return;
}
// Build main line points
const linePoints: Vector3[] = points.map(
([x, z]) => new Vector3(x, ceilingY, z)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], ceilingY, snapped[1]));
}
}
// Update main line geometry
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose();
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
mainLineRef.current.visible = true;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], ceilingY, snapped[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
];
closingLineRef.current.geometry.dispose();
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
closingLineRef.current.visible = true;
}
} else {
closingLineRef.current.visible = false;
}
};
// Per-frame cursor positioning via direct raycasting onto the level plane.
// This bypasses R3F's event propagation (grid:move), ensuring the cursor
// always tracks the pointer regardless of tool activation/deactivation state.
useFrame((state) => {
if (!cursorRef.current) return;
// Sync level Y from scene registry each frame
levelYRef.current = getLevelY();
// Raycast from camera through pointer onto horizontal plane at level Y
raycaster.setFromCamera(state.pointer, state.camera);
levelPlane.constant = -levelYRef.current;
const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint);
if (!hit) return;
// Snap to 0.5m grid
const gridX = Math.round(hit.x * 2) / 2;
const gridZ = Math.round(hit.z * 2) / 2;
cursorPositionRef.current = [gridX, gridZ];
const ceilingY = levelYRef.current + CEILING_HEIGHT;
const gridY = levelYRef.current + GRID_OFFSET;
// Apply axis snapping from last placed point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
let displayPoint: [number, number];
if (lastPoint) {
displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
} else {
displayPoint = [gridX, gridZ];
}
// Update cursor mesh positions imperatively
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]);
if (gridCursorRef.current) {
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]);
}
// Update line geometries imperatively
updateLines();
// Only trigger React state update when snapped display position changes
if (
!lastDisplayRef.current ||
lastDisplayRef.current.x !== displayPoint[0] ||
lastDisplayRef.current.z !== displayPoint[1]
) {
lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] };
setPreview({
points: [...pointsRef.current],
cursorPoint: displayPoint,
levelY: levelYRef.current,
});
}
});
// Click and double-click handlers for point placement
useEffect(() => { useEffect(() => {
if (!currentLevelId) return; if (!currentLevelId) return
// Initialize line geometries const onGridMove = (event: GridEvent) => {
if (mainLineRef.current) { if (!cursorRef.current || !gridCursorRef.current) return
mainLineRef.current.geometry = new BufferGeometry();
}
if (closingLineRef.current) {
closingLineRef.current.geometry = new BufferGeometry();
}
// Reset state on level change const gridX = Math.round(event.position[0] * 2) / 2
pointsRef.current = []; const gridZ = Math.round(event.position[2] * 2) / 2
lastDisplayRef.current = null; const gridPosition: [number, number] = [gridX, gridZ]
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
setCursorPosition(gridPosition)
setLevelY(event.position[1])
const ceilingY = event.position[1] + CEILING_HEIGHT
const gridY = event.position[1] + GRID_OFFSET
// Calculate snapped display position
const lastPoint = points[points.length - 1]
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
}
const onGridClick = (_event: GridEvent) => { const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return; if (!currentLevelId) return
// Use the cursor position tracked by useFrame (matches what user sees) // Calculate snapped click point
let clickPoint: [number, number] = [...cursorPositionRef.current]; const lastPoint = points[points.length - 1]
const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
// Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
if (lastPoint) {
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
}
// Check if clicking on the first point to close the shape // Check if clicking on the first point to close the shape
const firstPoint = pointsRef.current[0]; const firstPoint = points[0]
if ( if (
pointsRef.current.length >= 3 && points.length >= 3 &&
firstPoint && firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) { ) {
// Create the ceiling // Create the ceiling
commitCeilingDrawing(currentLevelId, pointsRef.current); commitCeilingDrawing(currentLevelId, points)
setPoints([])
// Reset state setTool(null)
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
// Deactivate tool
setTool(null);
} else { } else {
// Add point to polygon // Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint]; setPoints([...points, clickPoint])
lastDisplayRef.current = null; // Force preview update on next frame
} }
}; }
const onGridDoubleClick = (_event: GridEvent) => { const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return; if (!currentLevelId) return
// Need at least 3 points to form a polygon // Need at least 3 points to form a polygon
if (pointsRef.current.length >= 3) { if (points.length >= 3) {
commitCeilingDrawing(currentLevelId, pointsRef.current); commitCeilingDrawing(currentLevelId, points)
setPoints([])
// Reset state setTool(null)
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
// Deactivate tool
setTool(null);
} }
}; }
emitter.on("grid:click", onGridClick); emitter.on('grid:move', onGridMove)
emitter.on("grid:double-click", onGridDoubleClick); emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
return () => { return () => {
emitter.off("grid:click", onGridClick); emitter.off('grid:move', onGridMove)
emitter.off("grid:double-click", onGridDoubleClick); emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
}
}, [currentLevelId, points, cursorPosition, setTool])
// Reset state on unmount // Update line geometries when points change
pointsRef.current = []; useEffect(() => {
}; if (!mainLineRef.current || !closingLineRef.current) return
}, [currentLevelId, setTool]);
const { points, cursorPoint, levelY } = preview; if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const ceilingY = levelY + CEILING_HEIGHT
const lastPoint = points[points.length - 1]
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
}
}, [points, cursorPosition, levelY])
// Create preview shape when we have 3+ points // Create preview shape when we have 3+ points
const previewShape = useMemo(() => { const previewShape = useMemo(() => {
if (points.length < 3) return null; if (points.length < 3) return null
const allPoints = [...points]; const lastPoint = points[points.length - 1]
if (isValidPoint(cursorPoint)) { const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
allPoints.push(cursorPoint);
} const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X // - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation) // - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]; const firstPt = allPoints[0]
if (!isValidPoint(firstPt)) return null; if (!firstPt) return null
const shape = new Shape(); const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1]); shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) { for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]; const pt = allPoints[i]
if (isValidPoint(pt)) { if (pt) {
shape.lineTo(pt[0], -pt[1]); shape.lineTo(pt[0], -pt[1])
} }
} }
shape.closePath(); shape.closePath()
return shape; return shape
}, [points, cursorPoint]); }, [points, cursorPosition])
return ( return (
<group> <group>
{/* Cursor at ceiling height */} {/* Cursor at ceiling height */}
<mesh ref={cursorRef}> <mesh ref={cursorRef}>
<sphereGeometry args={[0.1, 16, 16]} /> <sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial <meshBasicMaterial color="#d4d4d4" depthTest={false} depthWrite={false} />
color="#d4d4d4"
depthTest={false}
depthWrite={false}
/>
</mesh> </mesh>
{/* Grid-level cursor indicator */} {/* Grid-level cursor indicator */}
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]}> <mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.15, 0.2, 32]} /> <ringGeometry args={[0.15, 0.2, 32]} />
<meshBasicMaterial <meshBasicMaterial color="#a3a3a3" side={DoubleSide} depthTest={false} depthWrite={false} />
color="#a3a3a3"
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh> </mesh>
{/* Preview fill */} {/* Preview fill */}
@@ -392,19 +248,14 @@ export const CeilingTool: React.FC = () => {
</mesh> </mesh>
)} )}
{/* Main line - uses native line element with TSL-compatible material */} {/* Main line */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial <lineBasicNodeMaterial color="#a3a3a3" linewidth={3} depthTest={false} depthWrite={false} />
color="#a3a3a3"
linewidth={3}
depthTest={false}
depthWrite={false}
/>
</line> </line>
{/* Closing line - uses native line element with TSL-compatible material */} {/* Closing line */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
@@ -419,18 +270,16 @@ export const CeilingTool: React.FC = () => {
</line> </line>
{/* Point markers */} {/* Point markers */}
{points.map(([x, z], index) => {points.map(([x, z], index) => (
isValidPoint([x, z]) ? ( <mesh key={index} position={[x, levelY + CEILING_HEIGHT + 0.01, z]}>
<mesh key={index} position={[x, levelY + CEILING_HEIGHT + 0.01, z]}> <sphereGeometry args={[0.1, 16, 16]} />
<sphereGeometry args={[0.1, 16, 16]} /> <meshBasicMaterial
<meshBasicMaterial color={index === 0 ? '#22c55e' : '#d4d4d4'}
color={index === 0 ? "#22c55e" : "#d4d4d4"} depthTest={false}
depthTest={false} depthWrite={false}
depthWrite={false} />
/> </mesh>
</mesh> ))}
) : null
)}
</group> </group>
); )
}; }
@@ -1,14 +1,7 @@
import { useThree } from '@react-three/fiber' import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
BufferGeometry,
Float32BufferAttribute,
type Mesh,
Plane,
Raycaster,
Vector2,
Vector3,
} from 'three'
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
@@ -24,7 +17,8 @@ export interface PolygonEditorProps {
color?: string color?: string
onPolygonChange: (polygon: Array<[number, number]>) => void onPolygonChange: (polygon: Array<[number, number]>) => void
minVertices?: number minVertices?: number
levelY?: number /** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
levelId?: string
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */ /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
surfaceHeight?: number surfaceHeight?: number
} }
@@ -40,24 +34,23 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
color = '#3b82f6', color = '#3b82f6',
onPolygonChange, onPolygonChange,
minVertices = 3, minVertices = 3,
levelY = 0, levelId,
surfaceHeight = 0, surfaceHeight = 0,
}) => { }) => {
const { gl, camera } = useThree() // Get level node from registry if levelId is provided
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
// Compute the editing plane height (level Y + small offset above floor) // When using portal, edit at Y_OFFSET (local to level)
const editY = levelY + Y_OFFSET // When not using portal, edit at world origin
const editY = levelNode ? Y_OFFSET : 0
// Local state for dragging // Local state for dragging
const [dragState, setDragState] = useState<DragState | null>(null) const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null) const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null) const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null) const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
// Refs for raycasting during drag
const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -editY))
dragPlane.current.constant = -editY
const raycaster = useRef(new Raycaster())
const lineRef = useRef<Mesh>(null!) const lineRef = useRef<Mesh>(null!)
// Track the last polygon prop to detect external changes (undo/redo) // Track the last polygon prop to detect external changes (undo/redo)
@@ -82,30 +75,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}) })
}, [displayPolygon]) }, [displayPolygon])
// Handle vertex drag // Update vertex position using grid cursor position
const handleVertexDrag = useCallback( const handleVertexDrag = useCallback(
(clientX: number, clientY: number, vertexIndex: number) => { (vertexIndex: number) => {
const canvas = gl.domElement const basePolygon = previewPolygon ?? polygon
const rect = canvas.getBoundingClientRect() const newPolygon = [...basePolygon]
const x = ((clientX - rect.left) / rect.width) * 2 - 1 newPolygon[vertexIndex] = cursorPosition
const y = -((clientY - rect.top) / rect.height) * 2 + 1 setPreviewPolygon(newPolygon)
raycaster.current.setFromCamera(new Vector2(x, y), camera)
const intersection = new Vector3()
raycaster.current.ray.intersectPlane(dragPlane.current, intersection)
if (intersection) {
// Snap to 0.5 grid
const gridX = Math.round(intersection.x * 2) / 2
const gridZ = Math.round(intersection.z * 2) / 2
const basePolygon = previewPolygon ?? polygon
const newPolygon = [...basePolygon]
newPolygon[vertexIndex] = [gridX, gridZ]
setPreviewPolygon(newPolygon)
}
}, },
[gl, camera, previewPolygon, polygon], [cursorPosition, previewPolygon, polygon],
) )
// Commit polygon changes // Commit polygon changes
@@ -146,58 +124,58 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
[polygon, previewPolygon, onPolygonChange, minVertices], [polygon, previewPolygon, onPolygonChange, minVertices],
) )
// Set up pointer move/up listeners for dragging with pointer capture // Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
setCursorPosition([gridX, gridZ])
// Update vertex position during drag
if (dragState?.isDragging) {
handleVertexDrag(dragState.vertexIndex)
}
}
emitter.on('grid:move', onGridMove)
return () => {
emitter.off('grid:move', onGridMove)
}
}, [dragState, handleVertexDrag])
// Set up pointer up listener for ending drag
useEffect(() => { useEffect(() => {
if (!dragState?.isDragging) return if (!dragState?.isDragging) return
const canvas = gl.domElement
const pointerId = dragState.pointerId
// Capture pointer to prevent R3F events from firing on other objects (like the grid)
canvas.setPointerCapture(pointerId)
const handlePointerMove = (e: PointerEvent) => {
handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex)
}
const handlePointerUp = (e: PointerEvent) => { const handlePointerUp = (e: PointerEvent) => {
// Stop the event from reaching R3F's handlers, which would otherwise // Only handle the specific pointer that started the drag
// fire a grid:click and deselect the node being edited. if (e.pointerId !== dragState.pointerId) return
// Stop the event from propagating to prevent grid click
e.stopImmediatePropagation() e.stopImmediatePropagation()
e.preventDefault() e.preventDefault()
// Release pointer capture
if (canvas.hasPointerCapture(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId)
}
// Suppress the follow-up click event that browsers fire after pointerup // Suppress the follow-up click event that browsers fire after pointerup
const suppressClick = (ce: MouseEvent) => { const suppressClick = (ce: MouseEvent) => {
ce.stopImmediatePropagation() ce.stopImmediatePropagation()
ce.preventDefault() ce.preventDefault()
canvas.removeEventListener('click', suppressClick, true) window.removeEventListener('click', suppressClick, true)
} }
canvas.addEventListener('click', suppressClick, true) window.addEventListener('click', suppressClick, true)
// Safety cleanup in case no click fires // Safety cleanup in case no click fires
requestAnimationFrame(() => { requestAnimationFrame(() => {
canvas.removeEventListener('click', suppressClick, true) window.removeEventListener('click', suppressClick, true)
}) })
commitPolygonChange() commitPolygonChange()
} }
canvas.addEventListener('pointermove', handlePointerMove) window.addEventListener('pointerup', handlePointerUp, true)
canvas.addEventListener('pointerup', handlePointerUp, true)
return () => { return () => {
// Release capture on cleanup window.removeEventListener('pointerup', handlePointerUp, true)
if (canvas.hasPointerCapture(pointerId)) {
canvas.releasePointerCapture(pointerId)
}
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerup', handlePointerUp, true)
} }
}, [dragState, gl, handleVertexDrag, commitPolygonChange]) }, [dragState, commitPolygonChange])
// Update line geometry when polygon changes // Update line geometry when polygon changes
useEffect(() => { useEffect(() => {
@@ -222,11 +200,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const canDelete = displayPolygon.length > minVertices const canDelete = displayPolygon.length > minVertices
return ( const editorContent = (
<group> <group>
{/* Border line */} {/* Border line */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={lineRef} frustumCulled={false} renderOrder={10}> <line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial <lineBasicNodeMaterial
color={color} color={color}
@@ -337,4 +315,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
})} })}
</group> </group>
) )
// Mount to level node if available, otherwise render at world origin
return levelNode ? createPortal(editorContent, levelNode) : editorContent
} }
@@ -1,4 +1,4 @@
import { useScene, type SiteNode } from '@pascal-app/core' import { type SiteNode, useScene } from '@pascal-app/core'
import { useCallback } from 'react' import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor' import { PolygonEditor } from '../shared/polygon-editor'
@@ -34,7 +34,7 @@ export const SiteBoundaryEditor: React.FC = () => {
return ( return (
<PolygonEditor <PolygonEditor
polygon={site.polygon.points} polygon={site.polygon.points}
color="#f59e0b" color="#10b981"
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
minVertices={3} minVertices={3}
/> />
@@ -1,52 +1,30 @@
import { sceneRegistry, useScene, type AnyNodeId, type SlabNode } from '@pascal-app/core' import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor' import { PolygonEditor } from '../shared/polygon-editor'
interface SlabBoundaryEditorProps {
slabId: SlabNode['id']
}
/** /**
* Slab boundary editor - allows editing slab polygon vertices when a slab is selected * Slab boundary editor - allows editing slab polygon vertices for a specific slab
* Uses the generic PolygonEditor component * Uses the generic PolygonEditor component
*/ */
export const SlabBoundaryEditor: React.FC = () => { export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
const selectedIds = useViewer((state) => state.selection.selectedIds) const slabNode = useScene((state) => state.nodes[slabId])
const levelId = useViewer((state) => state.selection.levelId)
const setSelection = useViewer((state) => state.setSelection)
const nodes = useScene((state) => state.nodes)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const setSelection = useViewer((state) => state.setSelection)
// Find the first selected slab const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const selectedSlabId =
selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') ?? null
const slab = selectedSlabId ? (nodes[selectedSlabId as AnyNodeId] as SlabNode) : null
// Get level Y position for the editing plane
let levelY = 0
if (levelId) {
const levelMesh = sceneRegistry.nodes.get(levelId)
if (levelMesh) {
levelY = levelMesh.position.y
} else {
const levelNode = nodes[levelId]
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode
const LEVEL_HEIGHT = 2.5
const EXPLODED_GAP = 5
levelY =
((levelNode as any).level || 0) *
(LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0))
}
}
}
const handlePolygonChange = useCallback( const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => { (newPolygon: Array<[number, number]>) => {
if (selectedSlabId) { updateNode(slabId, { polygon: newPolygon })
updateNode(selectedSlabId as SlabNode['id'], { polygon: newPolygon }) // Re-assert selection so the slab stays selected after the edit
// Re-assert selection so the slab stays selected after the edit setSelection({ selectedIds: [slabId] })
setSelection({ selectedIds: [selectedSlabId] })
}
}, },
[selectedSlabId, updateNode, setSelection], [slabId, updateNode, setSelection],
) )
if (!slab || !slab.polygon || slab.polygon.length < 3) return null if (!slab || !slab.polygon || slab.polygon.length < 3) return null
@@ -57,7 +35,7 @@ export const SlabBoundaryEditor: React.FC = () => {
color="#a3a3a3" color="#a3a3a3"
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
minVertices={3} minVertices={3}
levelY={levelY} levelId={resolveLevelId(slab, useScene.getState().nodes)}
surfaceHeight={slab.elevation ?? 0.05} surfaceHeight={slab.elevation ?? 0.05}
/> />
) )
+150 -293
View File
@@ -1,357 +1,221 @@
import { emitter, type GridEvent, useScene, SlabNode, type LevelNode, sceneRegistry } from "@pascal-app/core"; import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from '@pascal-app/viewer'
import { useFrame } from "@react-three/fiber"; import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from "react"; import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three";
import useEditor from "@/store/use-editor";
const Y_OFFSET = 0.02; // Small offset above floor level const Y_OFFSET = 0.02
const UP = new Vector3(0, 1, 0);
/** /**
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
*/ */
const calculateSnapPoint = ( const calculateSnapPoint = (
lastPoint: [number, number], lastPoint: [number, number],
currentPoint: [number, number] currentPoint: [number, number],
): [number, number] => { ): [number, number] => {
const [x1, y1] = lastPoint; const [x1, y1] = lastPoint
const [x, y] = currentPoint; const [x, y] = currentPoint
const dx = x - x1; const dx = x - x1
const dy = y - y1; const dy = y - y1
const absDx = Math.abs(dx); const absDx = Math.abs(dx)
const absDy = Math.abs(dy); const absDy = Math.abs(dy)
// Calculate distances to horizontal, vertical, and diagonal lines // Calculate distances to horizontal, vertical, and diagonal lines
const horizontalDist = absDy; const horizontalDist = absDy
const verticalDist = absDx; const verticalDist = absDx
const diagonalDist = Math.abs(absDx - absDy); const diagonalDist = Math.abs(absDx - absDy)
// Find the minimum distance to determine which axis to snap to // Find the minimum distance to determine which axis to snap to
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist); const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
if (minDist === diagonalDist) { if (minDist === diagonalDist) {
// Snap to 45° diagonal // Snap to 45° diagonal
const diagonalLength = Math.min(absDx, absDy); const diagonalLength = Math.min(absDx, absDy)
return [ return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
x1 + Math.sign(dx) * diagonalLength,
y1 + Math.sign(dy) * diagonalLength,
];
} else if (minDist === horizontalDist) { } else if (minDist === horizontalDist) {
// Snap to horizontal // Snap to horizontal
return [x, y1]; return [x, y1]
} else { } else {
// Snap to vertical // Snap to vertical
return [x1, y]; return [x1, y]
} }
}; }
/** /**
* Creates a slab with the given polygon points and returns its ID * Creates a slab with the given polygon points and returns its ID
*/ */
const commitSlabDrawing = ( const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
levelId: LevelNode["id"], const { createNode, nodes } = useScene.getState()
points: Array<[number, number]>
): string => {
const { createNode, nodes } = useScene.getState();
// Count existing slabs for naming // Count existing slabs for naming
const slabCount = Object.values(nodes).filter((n) => n.type === "slab").length; const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
const name = `Slab ${slabCount + 1}`; const name = `Slab ${slabCount + 1}`
const slab = SlabNode.parse({ const slab = SlabNode.parse({
name, name,
polygon: points, polygon: points,
}); })
createNode(slab, levelId); createNode(slab, levelId)
return slab.id; return slab.id
}; }
type PreviewState = {
points: Array<[number, number]>;
cursorPoint: [number, number] | null;
levelY: number;
};
// Helper to validate point values (no NaN or Infinity)
const isValidPoint = (
pt: [number, number] | null | undefined
): pt is [number, number] => {
if (!pt) return false;
return Number.isFinite(pt[0]) && Number.isFinite(pt[1]);
};
export const SlabTool: React.FC = () => { export const SlabTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null); const cursorRef = useRef<Mesh>(null)
const mainLineRef = useRef<Line>(null!); const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!); const closingLineRef = useRef<Line>(null!)
const pointsRef = useRef<Array<[number, number]>>([]); const currentLevelId = useViewer((state) => state.selection.levelId)
const levelYRef = useRef(0); const setSelection = useViewer((state) => state.setSelection)
const cursorPositionRef = useRef<[number, number]>([0, 0]);
const lastDisplayRef = useRef<{ x: number; z: number } | null>(null);
const currentLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const setTool = useEditor((state) => state.setTool);
// Reusable objects for raycasting (created once, avoid per-frame allocations) const [points, setPoints] = useState<Array<[number, number]>>([])
const raycaster = useMemo(() => new Raycaster(), []); const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const levelPlane = useMemo(() => new Plane(UP, 0), []); const [levelY, setLevelY] = useState(0)
const hitPoint = useMemo(() => new Vector3(), []);
// Preview state for reactive rendering (for shape and point markers) // Update cursor position and lines on grid move
const [preview, setPreview] = useState<PreviewState>({
points: [],
cursorPoint: null,
levelY: 0,
});
// Resolve the current level's Y position from scene registry or node data
const getLevelY = (): number => {
if (!currentLevelId) return 0;
const levelMesh = sceneRegistry.nodes.get(currentLevelId);
if (levelMesh) return levelMesh.position.y;
const levelNode = useScene.getState().nodes[currentLevelId];
if (levelNode && 'level' in levelNode) {
const levelMode = useViewer.getState().levelMode;
const LEVEL_HEIGHT = 2.5;
const EXPLODED_GAP = 5;
return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0));
}
return 0;
};
// Imperatively update line geometries (called from useFrame)
const updateLines = () => {
if (!mainLineRef.current || !closingLineRef.current) return;
const points = pointsRef.current;
const cursorPosition = cursorPositionRef.current;
const y = levelYRef.current + Y_OFFSET;
if (points.length === 0) {
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
return;
}
// Build main line points
const linePoints: Vector3[] = points.map(
([x, z]) => new Vector3(x, y, z)
);
// Add cursor point
const lastPoint = points[points.length - 1];
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
}
}
// Update main line geometry
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose();
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
mainLineRef.current.visible = true;
} else {
mainLineRef.current.visible = false;
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0];
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], y, snapped[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
];
closingLineRef.current.geometry.dispose();
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
closingLineRef.current.visible = true;
}
} else {
closingLineRef.current.visible = false;
}
};
// Per-frame cursor positioning via direct raycasting onto the level plane.
// This bypasses R3F's event propagation (grid:move), ensuring the cursor
// always tracks the pointer regardless of tool activation/deactivation state.
useFrame((state) => {
if (!cursorRef.current) return;
// Sync level Y from scene registry each frame
levelYRef.current = getLevelY();
// Raycast from camera through pointer onto horizontal plane at level Y
raycaster.setFromCamera(state.pointer, state.camera);
levelPlane.constant = -levelYRef.current;
const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint);
if (!hit) return;
// Snap to 0.5m grid
const gridX = Math.round(hit.x * 2) / 2;
const gridZ = Math.round(hit.z * 2) / 2;
cursorPositionRef.current = [gridX, gridZ];
// Apply axis snapping from last placed point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
let displayPoint: [number, number];
if (lastPoint) {
displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
} else {
displayPoint = [gridX, gridZ];
}
// Update cursor mesh position imperatively
cursorRef.current.position.set(displayPoint[0], levelYRef.current, displayPoint[1]);
// Update line geometries imperatively
updateLines();
// Only trigger React state update when snapped display position changes
if (
!lastDisplayRef.current ||
lastDisplayRef.current.x !== displayPoint[0] ||
lastDisplayRef.current.z !== displayPoint[1]
) {
lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] };
setPreview({
points: [...pointsRef.current],
cursorPoint: displayPoint,
levelY: levelYRef.current,
});
}
});
// Click and double-click handlers for point placement
useEffect(() => { useEffect(() => {
if (!currentLevelId) return; if (!currentLevelId) return
// Initialize line geometries const onGridMove = (event: GridEvent) => {
if (mainLineRef.current) { if (!cursorRef.current) return
mainLineRef.current.geometry = new BufferGeometry();
}
if (closingLineRef.current) {
closingLineRef.current.geometry = new BufferGeometry();
}
// Reset state on level change const gridX = Math.round(event.position[0] * 2) / 2
pointsRef.current = []; const gridZ = Math.round(event.position[2] * 2) / 2
lastDisplayRef.current = null; const gridPosition: [number, number] = [gridX, gridZ]
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
setCursorPosition(gridPosition)
setLevelY(event.position[1])
// Calculate snapped display position
const lastPoint = points[points.length - 1]
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
}
const onGridClick = (_event: GridEvent) => { const onGridClick = (_event: GridEvent) => {
if (!currentLevelId) return; if (!currentLevelId) return
// Use the cursor position tracked by useFrame (matches what user sees) // Calculate snapped click point
let clickPoint: [number, number] = [...cursorPositionRef.current]; const lastPoint = points[points.length - 1]
const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
// Snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
if (lastPoint) {
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
}
// Check if clicking on the first point to close the shape // Check if clicking on the first point to close the shape
const firstPoint = pointsRef.current[0]; const firstPoint = points[0]
if ( if (
pointsRef.current.length >= 3 && points.length >= 3 &&
firstPoint && firstPoint &&
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) { ) {
// Create the slab and select it // Create the slab and select it
const slabId = commitSlabDrawing(currentLevelId, pointsRef.current); const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] }); setSelection({ selectedIds: [slabId] })
setPoints([])
// Reset state
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
} else { } else {
// Add point to polygon // Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint]; setPoints([...points, clickPoint])
lastDisplayRef.current = null; // Force preview update on next frame
} }
}; }
const onGridDoubleClick = (_event: GridEvent) => { const onGridDoubleClick = (_event: GridEvent) => {
if (!currentLevelId) return; if (!currentLevelId) return
// Need at least 3 points to form a polygon // Need at least 3 points to form a polygon
if (pointsRef.current.length >= 3) { if (points.length >= 3) {
// Create the slab and select it const slabId = commitSlabDrawing(currentLevelId, points)
const slabId = commitSlabDrawing(currentLevelId, pointsRef.current); setSelection({ selectedIds: [slabId] })
setSelection({ selectedIds: [slabId] }); setPoints([])
// Reset state
pointsRef.current = [];
lastDisplayRef.current = null;
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
if (mainLineRef.current) mainLineRef.current.visible = false;
if (closingLineRef.current) closingLineRef.current.visible = false;
} }
}; }
emitter.on("grid:click", onGridClick); emitter.on('grid:move', onGridMove)
emitter.on("grid:double-click", onGridDoubleClick); emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick)
return () => { return () => {
emitter.off("grid:click", onGridClick); emitter.off('grid:move', onGridMove)
emitter.off("grid:double-click", onGridDoubleClick); emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick)
}
}, [currentLevelId, points, cursorPosition, setSelection])
// Reset state on unmount // Update line geometries when points change
pointsRef.current = []; useEffect(() => {
}; if (!mainLineRef.current || !closingLineRef.current) return
}, [currentLevelId, setSelection]);
const { points, cursorPoint, levelY } = preview; if (points.length === 0) {
mainLineRef.current.visible = false
closingLineRef.current.visible = false
return
}
const y = levelY + Y_OFFSET
const lastPoint = points[points.length - 1]
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
// Build main line points
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
// Update main line
if (linePoints.length >= 2) {
mainLineRef.current.geometry.dispose()
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
mainLineRef.current.visible = true
} else {
mainLineRef.current.visible = false
}
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && firstPoint) {
const closingPoints = [
new Vector3(snappedCursor[0], y, snappedCursor[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
}
}, [points, cursorPosition, levelY])
// Create preview shape when we have 3+ points // Create preview shape when we have 3+ points
const previewShape = useMemo(() => { const previewShape = useMemo(() => {
if (points.length < 3) return null; if (points.length < 3) return null
const allPoints = [...points]; const lastPoint = points[points.length - 1]
if (isValidPoint(cursorPoint)) { const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
allPoints.push(cursorPoint);
} const allPoints = [...points, snappedCursor]
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
// - Shape X -> World X // - Shape X -> World X
// - Shape Y -> World -Z (so we negate Z to get correct orientation) // - Shape Y -> World -Z (so we negate Z to get correct orientation)
const firstPt = allPoints[0]; const firstPt = allPoints[0]
if (!isValidPoint(firstPt)) return null; if (!firstPt) return null
const shape = new Shape(); const shape = new Shape()
shape.moveTo(firstPt[0], -firstPt[1]); shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < allPoints.length; i++) { for (let i = 1; i < allPoints.length; i++) {
const pt = allPoints[i]; const pt = allPoints[i]
if (isValidPoint(pt)) { if (pt) {
shape.lineTo(pt[0], -pt[1]); shape.lineTo(pt[0], -pt[1])
} }
} }
shape.closePath(); shape.closePath()
return shape; return shape
}, [points, cursorPoint]); }, [points, cursorPosition])
return ( return (
<group> <group>
{/* Cursor */} {/* Cursor */}
<mesh ref={cursorRef}> <mesh ref={cursorRef}>
<sphereGeometry args={[0.1, 16, 16]} /> <sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial <meshBasicMaterial color="#a3a3a3" depthTest={false} depthWrite={false} />
color="#a3a3a3"
depthTest={false}
depthWrite={false}
/>
</mesh> </mesh>
{/* Preview fill */} {/* Preview fill */}
@@ -372,19 +236,14 @@ export const SlabTool: React.FC = () => {
</mesh> </mesh>
)} )}
{/* Main line - uses native line element with TSL-compatible material */} {/* Main line */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
<lineBasicNodeMaterial <lineBasicNodeMaterial color="#737373" linewidth={3} depthTest={false} depthWrite={false} />
color="#737373"
linewidth={3}
depthTest={false}
depthWrite={false}
/>
</line> </line>
{/* Closing line - uses native line element with TSL-compatible material */} {/* Closing line */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}> <line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry /> <bufferGeometry />
@@ -399,18 +258,16 @@ export const SlabTool: React.FC = () => {
</line> </line>
{/* Point markers */} {/* Point markers */}
{points.map(([x, z], index) => {points.map(([x, z], index) => (
isValidPoint([x, z]) ? ( <mesh key={index} position={[x, levelY + Y_OFFSET + 0.01, z]}>
<mesh key={index} position={[x, levelY + Y_OFFSET + 0.01, z]}> <sphereGeometry args={[0.1, 16, 16]} />
<sphereGeometry args={[0.1, 16, 16]} /> <meshBasicMaterial
<meshBasicMaterial color={index === 0 ? '#22c55e' : '#a3a3a3'}
color={index === 0 ? "#22c55e" : "#a3a3a3"} depthTest={false}
depthTest={false} depthWrite={false}
depthWrite={false} />
/> </mesh>
</mesh> ))}
) : null
)}
</group> </group>
); )
}; }
+52 -33
View File
@@ -1,20 +1,21 @@
import useEditor, { type Phase, type Tool } from "@/store/use-editor"; import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
import { useScene, type AnyNodeId } from "@pascal-app/core"; import { useViewer } from '@pascal-app/viewer'
import { useViewer } from "@pascal-app/viewer"; import useEditor, { type Phase, type Tool } from '@/store/use-editor'
import { CeilingTool } from "./ceiling/ceiling-tool"; import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { ItemTool } from "./item/item-tool"; import { CeilingTool } from './ceiling/ceiling-tool'
import { MoveTool } from "./item/move-tool"; import { ItemTool } from './item/item-tool'
import { RoofTool } from "./roof/roof-tool"; import { MoveTool } from './item/move-tool'
import { SiteBoundaryEditor } from "./site/site-boundary-editor"; import { RoofTool } from './roof/roof-tool'
import { SlabBoundaryEditor } from "./slab/slab-boundary-editor"; import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { SlabTool } from "./slab/slab-tool"; import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { WallTool } from "./wall/wall-tool"; import { SlabTool } from './slab/slab-tool'
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor"; import { WallTool } from './wall/wall-tool'
import { ZoneTool } from "./zone/zone-tool"; import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool'
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = { const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
site: { site: {
"property-line": SiteBoundaryEditor, 'property-line': SiteBoundaryEditor,
}, },
structure: { structure: {
wall: WallTool, wall: WallTool,
@@ -27,44 +28,62 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
furnish: { furnish: {
item: ItemTool, item: ItemTool,
}, },
}; }
export const ToolManager: React.FC = () => { export const ToolManager: React.FC = () => {
const phase = useEditor((state) => state.phase); const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode); const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool); const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode); const movingNode = useEditor((state) => state.movingNode)
const selectedZoneId = useViewer((state) => state.selection.zoneId); const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds); const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes); const nodes = useScene((state) => state.nodes)
// Check if a slab is selected // Check if a slab is selected
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === "slab") ?? null; const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
| SlabNode['id']
| undefined
// Check if a ceiling is selected
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
| CeilingNode['id']
| undefined
// Show site boundary editor when in site phase and edit mode // Show site boundary editor when in site phase and edit mode
const showSiteBoundaryEditor = phase === "site" && mode === "edit"; const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
// Show slab boundary editor when in structure/select mode with a slab selected // Show slab boundary editor when in structure/select mode with a slab selected
const showSlabBoundaryEditor = const showSlabBoundaryEditor =
phase === "structure" && mode === "select" && selectedSlabId !== null; phase === 'structure' && mode === 'select' && selectedSlabId !== undefined
// Show ceiling boundary editor when in structure/select mode with a ceiling selected
const showCeilingBoundaryEditor =
phase === 'structure' && mode === 'select' && selectedCeilingId !== undefined
// Show zone boundary editor when in structure/select mode with a zone selected // Show zone boundary editor when in structure/select mode with a zone selected
// Hide when editing a slab to avoid overlapping handles // Hide when editing a slab or ceiling to avoid overlapping handles
const showZoneBoundaryEditor = const showZoneBoundaryEditor =
phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor; phase === 'structure' &&
mode === 'select' &&
selectedZoneId !== null &&
!showSlabBoundaryEditor &&
!showCeilingBoundaryEditor
// Show build tools when in build mode // Show build tools when in build mode
const showBuildTool = mode === "build" && tool !== null; const showBuildTool = mode === 'build' && tool !== null
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null; const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
return ( return (
<> <>
{showSiteBoundaryEditor && <SiteBoundaryEditor />} {showSiteBoundaryEditor && <SiteBoundaryEditor />}
{showZoneBoundaryEditor && <ZoneBoundaryEditor />} {showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
{showSlabBoundaryEditor && <SlabBoundaryEditor />} {showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
{showCeilingBoundaryEditor && selectedCeilingId && (
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
)}
{movingNode && <MoveTool />} {movingNode && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</> </>
); )
}; }
@@ -1,25 +1,26 @@
import { useScene, type ZoneNode } from '@pascal-app/core' import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback } from 'react'
import { PolygonEditor } from '../shared/polygon-editor' import { PolygonEditor } from '../shared/polygon-editor'
interface ZoneBoundaryEditorProps {
zoneId: ZoneNode['id']
}
/** /**
* Zone boundary editor - allows editing zone polygon vertices when a zone is selected * Zone boundary editor - allows editing zone polygon vertices for a specific zone
* Uses the generic PolygonEditor component * Uses the generic PolygonEditor component
*/ */
export const ZoneBoundaryEditor: React.FC = () => { export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }) => {
const selectedZoneId = useViewer((state) => state.selection.zoneId) const zoneNode = useScene((state) => state.nodes[zoneId])
const zoneNode = useScene((state) => (selectedZoneId ? state.nodes[selectedZoneId] : null))
const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
const handlePolygonChange = useCallback( const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => { (newPolygon: Array<[number, number]>) => {
if (selectedZoneId) { updateNode(zoneId, { polygon: newPolygon })
updateNode(selectedZoneId, { polygon: newPolygon })
}
}, },
[selectedZoneId, updateNode], [zoneId, updateNode],
) )
if (!zone || !zone.polygon || zone.polygon.length < 3) return null if (!zone || !zone.polygon || zone.polygon.length < 3) return null
@@ -32,6 +33,7 @@ export const ZoneBoundaryEditor: React.FC = () => {
color={zoneColor} color={zoneColor}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
minVertices={3} minVertices={3}
levelId={resolveLevelId(zone, useScene.getState().nodes)}
/> />
) )
} }
@@ -2,6 +2,7 @@ import {
type BuildingNode, type BuildingNode,
emitter, emitter,
LevelNode, LevelNode,
type SiteNode,
useScene, useScene,
type ZoneNode, type ZoneNode,
} from "@pascal-app/core"; } from "@pascal-app/core";
@@ -11,7 +12,9 @@ import {
Camera, Camera,
ChevronDown, ChevronDown,
Layers, Layers,
MapPin,
MoreHorizontal, MoreHorizontal,
Pencil,
Plus, Plus,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -40,7 +43,186 @@ const PRESET_COLORS = [
]; ];
// ============================================================================ // ============================================================================
// SITE PHASE VIEW - Simple building buttons // PROPERTY LINE SECTION
// ============================================================================
function calculatePerimeter(points: Array<[number, number]>): number {
if (points.length < 2) return 0;
let perimeter = 0;
for (let i = 0; i < points.length; i++) {
const [x1, z1] = points[i]!;
const [x2, z2] = points[(i + 1) % points.length]!;
perimeter += Math.sqrt((x2 - x1) ** 2 + (z2 - z1) ** 2);
}
return perimeter;
}
function calculatePolygonArea(polygon: Array<[number, number]>): number {
if (polygon.length < 3) return 0;
let area = 0;
const n = polygon.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += polygon[i]![0] * polygon[j]![1];
area -= polygon[j]![0] * polygon[i]![1];
}
return Math.abs(area) / 2;
}
function useSiteNode(): SiteNode | null {
const siteId = useScene((state) => {
for (const id of state.rootNodeIds) {
if (state.nodes[id]?.type === "site") return id;
}
return null;
});
return useScene((state) =>
siteId ? ((state.nodes[siteId] as SiteNode | undefined) ?? null) : null
);
}
function PropertyLineSection() {
const siteNode = useSiteNode();
const updateNode = useScene((state) => state.updateNode);
const mode = useEditor((state) => state.mode);
const setMode = useEditor((state) => state.setMode);
if (!siteNode) return null;
const points = siteNode.polygon?.points ?? [];
const area = calculatePolygonArea(points);
const perimeter = calculatePerimeter(points);
const isEditing = mode === "edit";
const handleToggleEdit = () => {
setMode(isEditing ? "select" : "edit");
};
const handlePointChange = (index: number, axis: 0 | 1, value: number) => {
const newPoints = [...points.map((p) => [...p] as [number, number])];
newPoints[index]![axis] = value;
updateNode(siteNode.id, {
polygon: { type: "polygon" as const, points: newPoints },
});
};
const handleAddPoint = () => {
const lastPoint = points[points.length - 1];
const firstPoint = points[0];
if (!lastPoint || !firstPoint) return;
const newPoint: [number, number] = [
(lastPoint[0] + firstPoint[0]) / 2,
(lastPoint[1] + firstPoint[1]) / 2,
];
const newPoints = [...points, newPoint];
updateNode(siteNode.id, {
polygon: { type: "polygon" as const, points: newPoints },
});
};
const handleDeletePoint = (index: number) => {
if (points.length <= 3) return;
const newPoints = points.filter((_, i) => i !== index);
updateNode(siteNode.id, {
polygon: { type: "polygon" as const, points: newPoints },
});
};
return (
<div className="border-b border-border/50">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Property Line</span>
</div>
<button
className={cn(
"w-6 h-6 flex items-center justify-center rounded cursor-pointer transition-colors",
isEditing
? "bg-orange-500/20 text-orange-400"
: "hover:bg-accent text-muted-foreground"
)}
onClick={handleToggleEdit}
>
<Pencil className="w-3.5 h-3.5" />
</button>
</div>
{/* Measurements */}
<div className="flex gap-3 px-3 pb-2">
<div className="text-xs text-muted-foreground">
Area: <span className="text-foreground">{area.toFixed(1)} m²</span>
</div>
<div className="text-xs text-muted-foreground">
Perimeter:{" "}
<span className="text-foreground">{perimeter.toFixed(1)} m</span>
</div>
</div>
{/* Vertex list (shown when editing) */}
{isEditing && (
<div className="px-3 pb-2">
<div className="flex flex-col gap-1">
{points.map((point, index) => (
<div
key={index}
className="flex items-center gap-1.5 text-xs"
>
<span className="w-4 text-muted-foreground text-right shrink-0">
{index + 1}
</span>
<label className="text-muted-foreground shrink-0">X</label>
<input
type="number"
value={point[0]}
onChange={(e) =>
handlePointChange(index, 0, parseFloat(e.target.value) || 0)
}
step={0.5}
className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary"
/>
<label className="text-muted-foreground shrink-0">Z</label>
<input
type="number"
value={point[1]}
onChange={(e) =>
handlePointChange(index, 1, parseFloat(e.target.value) || 0)
}
step={0.5}
className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary"
/>
<button
className={cn(
"w-5 h-5 flex items-center justify-center rounded cursor-pointer shrink-0",
points.length > 3
? "hover:bg-red-500/20 text-muted-foreground hover:text-red-400"
: "text-muted-foreground/30 cursor-not-allowed"
)}
onClick={() => handleDeletePoint(index)}
disabled={points.length <= 3}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
<button
className="flex items-center gap-1 mt-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded cursor-pointer transition-colors"
onClick={handleAddPoint}
>
<Plus className="w-3 h-3" />
Add point
</button>
</div>
)}
</div>
);
}
// ============================================================================
// SITE PHASE VIEW - Property line + building buttons
// ============================================================================ // ============================================================================
function SitePhaseView() { function SitePhaseView() {
@@ -55,31 +237,32 @@ function SitePhaseView() {
.map((child) => typeof child === 'string' ? nodes[child] : child) .map((child) => typeof child === 'string' ? nodes[child] : child)
.filter((node): node is BuildingNode => node?.type === "building"); .filter((node): node is BuildingNode => node?.type === "building");
if (buildings.length === 0) {
return (
<div className="px-3 py-4 text-sm text-muted-foreground">
No buildings yet
</div>
);
}
return ( return (
<div className="flex flex-col gap-1 p-2"> <div className="flex flex-col h-full">
{buildings.map((building) => ( <PropertyLineSection />
<button {buildings.length === 0 ? (
key={building.id} <div className="px-3 py-4 text-sm text-muted-foreground">
className={cn( No buildings yet
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer", </div>
selectedBuildingId === building.id ) : (
? "bg-primary text-primary-foreground" <div className="flex flex-col gap-1 p-2">
: "bg-accent/50 hover:bg-accent text-foreground" {buildings.map((building) => (
)} <button
onClick={() => setSelection({ buildingId: building.id })} key={building.id}
> className={cn(
<Building2 className="w-4 h-4 shrink-0" /> "flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer",
<span className="truncate">{building.name || "Building"}</span> selectedBuildingId === building.id
</button> ? "bg-primary text-primary-foreground"
))} : "bg-accent/50 hover:bg-accent text-foreground"
)}
onClick={() => setSelection({ buildingId: building.id })}
>
<Building2 className="w-4 h-4 shrink-0" />
<span className="truncate">{building.name || "Building"}</span>
</button>
))}
</div>
)}
</div> </div>
); );
} }
@@ -367,18 +550,6 @@ function LayerToggle() {
); );
} }
function calculatePolygonArea(polygon: Array<[number, number]>): number {
if (polygon.length < 3) return 0;
let area = 0;
const n = polygon.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += polygon[i]![0] * polygon[j]![1];
area -= polygon[j]![0] * polygon[i]![1];
}
return Math.abs(area) / 2;
}
function ZoneItem({ zone }: { zone: ZoneNode }) { function ZoneItem({ zone }: { zone: ZoneNode }) {
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false); const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
+99
View File
@@ -0,0 +1,99 @@
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { Plane, Raycaster, Vector2, Vector3 } from 'three'
/**
* Custom grid events hook that uses manual raycasting instead of mesh events.
* This ensures grid events work even when other meshes block pointer events with stopPropagation.
*/
export function useGridEvents(gridY: number) {
const { camera, gl } = useThree()
const raycaster = useRef(new Raycaster())
const pointer = useRef(new Vector2())
const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
const intersectionPoint = useRef(new Vector3())
// Update ground plane when grid Y changes
useEffect(() => {
groundPlane.current.constant = -gridY
}, [gridY])
useEffect(() => {
const canvas = gl.domElement
const getIntersection = (nativeEvent: MouseEvent | PointerEvent): Vector3 | null => {
// Convert mouse position to normalized device coordinates (-1 to +1)
const rect = canvas.getBoundingClientRect()
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
// Update raycaster
raycaster.current.setFromCamera(pointer.current, camera)
// Intersect with ground plane
if (raycaster.current.ray.intersectPlane(groundPlane.current, intersectionPoint.current)) {
return intersectionPoint.current.clone()
}
return null
}
const emit = (suffix: EventSuffix, nativeEvent: MouseEvent | PointerEvent) => {
const point = getIntersection(nativeEvent)
if (!point) return
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`
const payload: GridEvent = {
position: [point.x, point.y, point.z],
nativeEvent: nativeEvent as any, // Type compatibility with ThreeEvent
}
emitter.emit(eventKey, payload)
}
const handlePointerDown = (e: PointerEvent) => {
if (e.button !== 0) return
emit('pointerdown', e)
}
const handlePointerUp = (e: PointerEvent) => {
if (e.button !== 0) return
emit('pointerup', e)
}
const handleClick = (e: PointerEvent) => {
if (e.button !== 0) return
emit('click', e)
}
const handlePointerMove = (e: PointerEvent) => {
emit('move', e)
}
const handleDoubleClick = (e: MouseEvent) => {
emit('double-click', e)
}
const handleContextMenu = (e: MouseEvent) => {
emit('context-menu', e)
}
// Attach listeners to canvas
canvas.addEventListener('pointerdown', handlePointerDown)
canvas.addEventListener('pointerup', handlePointerUp)
canvas.addEventListener('click', handleClick)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('dblclick', handleDoubleClick)
canvas.addEventListener('contextmenu', handleContextMenu)
return () => {
canvas.removeEventListener('pointerdown', handlePointerDown)
canvas.removeEventListener('pointerup', handlePointerUp)
canvas.removeEventListener('click', handleClick)
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('dblclick', handleDoubleClick)
canvas.removeEventListener('contextmenu', handleContextMenu)
}
}, [camera, gl, gridY])
}
@@ -38,7 +38,6 @@ export const WallSystem = () => {
const node = nodes[id] const node = nodes[id]
if (!node || node.type !== 'wall') return if (!node || node.type !== 'wall') return
console.log('wall front/back', node.frontSide, node.backSide)
const levelId = node.parentId const levelId = node.parentId
if (!levelId) return if (!levelId) return
@@ -1,4 +1,5 @@
import { type SiteNode, useRegistry } from '@pascal-app/core' import { type SiteNode, useRegistry } from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -60,6 +61,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
return createBoundaryLineGeometry(node.polygon.points) return createBoundaryLineGeometry(node.polygon.points)
}, [node?.polygon?.points]) }, [node?.polygon?.points])
// Edge distances for labels
const edges = useMemo(() => {
const polygon = node?.polygon?.points ?? []
if (polygon.length < 2) return []
return polygon.map(([x1, z1], i) => {
const [x2, z2] = polygon[(i + 1) % polygon.length]!
const midX = (x1! + x2) / 2
const midZ = (z1! + z2) / 2
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
return { midX, midZ, dist }
})
}, [node?.polygon?.points])
const handlers = useNodeEvents(node, 'site') const handlers = useNodeEvents(node, 'site')
if (!node || !floorShape || !lineGeometry) { if (!node || !floorShape || !lineGeometry) {
@@ -98,6 +112,21 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
opacity={0.6} opacity={0.6}
/> />
</line> </line>
{/* Edge distance labels */}
{edges.map((edge, i) => (
<Html
center
key={`edge-${i}`}
position={[edge.midX, 0.5, edge.midZ]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[10, 0]}
>
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
{edge.dist.toFixed(2)}m
</div>
</Html>
))}
</group> </group>
) )
} }
@@ -2,7 +2,7 @@ import { useRegistry, type ZoneNode } from '@pascal-app/core'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
import { color, float, uv } from 'three/tsl' import { color, float, uniform, uv } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -19,16 +19,20 @@ const createWallGradientMaterial = (zoneColor: string) => {
// Use UV y coordinate for vertical gradient (0 at bottom, 1 at top) // Use UV y coordinate for vertical gradient (0 at bottom, 1 at top)
const gradientT = uv().y const gradientT = uv().y
const opacity = uniform(0);
// Fade opacity from 0.6 at bottom to 0 at top // Fade opacity from 0.6 at bottom to 0 at top
const opacity = float(0.6).mul(float(1).sub(gradientT)) const finalOpacity = float(0.6).mul(float(1).sub(gradientT)).mul(opacity);
return new MeshBasicNodeMaterial({ return new MeshBasicNodeMaterial({
transparent: true, transparent: true,
colorNode: baseColor, colorNode: baseColor,
opacityNode: opacity, opacityNode: finalOpacity,
side: DoubleSide, side: DoubleSide,
depthWrite: false, depthWrite: false,
depthTest: false, depthTest: false,
userData: {
uOpacity: opacity,
}
}) })
} }
@@ -37,13 +41,15 @@ const createWallGradientMaterial = (zoneColor: string) => {
*/ */
const createFloorMaterial = (zoneColor: string) => { const createFloorMaterial = (zoneColor: string) => {
const baseColor = color(new Color(zoneColor)) const baseColor = color(new Color(zoneColor))
const opacity = uniform(0)
return new MeshBasicNodeMaterial({ return new MeshBasicNodeMaterial({
transparent: true, transparent: true,
colorNode: baseColor, colorNode: baseColor,
opacityNode: float(0.15), opacityNode: float(0.25).mul(opacity),
side: DoubleSide, side: DoubleSide,
depthWrite: false, depthWrite: false,
depthTest: false,
userData: { uOpacity: opacity}
}) })
} }
@@ -174,7 +180,8 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
<group ref={ref} {...handlers}> <group ref={ref} {...handlers}>
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{ <Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
pointerEvents: 'none' pointerEvents: 'none'
}}> }}
zIndexRange={[10, 0]}>
<div style={{ <div style={{
transform: 'translate3d(-50%, -50%, 0)', transform: 'translate3d(-50%, -50%, 0)',
width: 'max-content', width: 'max-content',
@@ -187,12 +194,12 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
{node.name}</div> {node.name}</div>
</Html> </Html>
{/* Floor fill */} {/* Floor fill */}
<mesh position={[0, Y_OFFSET, 0]} rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial}> <mesh position={[0, Y_OFFSET, 0]} rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial} name="floor">
<shapeGeometry args={[floorShape]} /> <shapeGeometry args={[floorShape]} />
</mesh> </mesh>
{/* Wall borders with gradient */} {/* Wall borders with gradient */}
<mesh geometry={wallGeometry} material={wallMaterial} /> <mesh geometry={wallGeometry} material={wallMaterial} name="walls" />
</group> </group>
) )
} }
@@ -8,6 +8,7 @@ import { GuideSystem } from '../../systems/guide/guide-system'
import { LevelSystem } from '../../systems/level/level-system' import { LevelSystem } from '../../systems/level/level-system'
import { ScanSystem } from '../../systems/scan/scan-system' import { ScanSystem } from '../../systems/scan/scan-system'
import { WallCutout } from '../../systems/wall/wall-cutout' import { WallCutout } from '../../systems/wall/wall-cutout'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { SceneRenderer } from '../renderers/scene-renderer' import { SceneRenderer } from '../renderers/scene-renderer'
import { Lights } from './lights' import { Lights } from './lights'
import PostProcessing from './post-processing' import PostProcessing from './post-processing'
@@ -28,6 +29,7 @@ interface ViewerProps {
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => { const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => {
return ( return (
<Canvas <Canvas
dpr={[1, 1.5]}
className={'bg-[#fafafa]'} className={'bg-[#fafafa]'}
gl={async (props) => { gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any) const renderer = new THREE.WebGPURenderer(props as any)
@@ -63,6 +65,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
<RoofSystem /> <RoofSystem />
<SlabSystem /> <SlabSystem />
<WallSystem /> <WallSystem />
<ZoneSystem />
<PostProcessing /> <PostProcessing />
{selectionManager === 'default' && <SelectionManager />} {selectionManager === 'default' && <SelectionManager />}
@@ -26,10 +26,10 @@ import useViewer from '../../store/use-viewer'
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
export const SSGI_PARAMS = { export const SSGI_PARAMS = {
enabled: true, enabled: true,
sliceCount: 2, sliceCount: 1,
stepCount: 8, stepCount: 8,
radius: 2, radius: 1,
expFactor: 2, expFactor: 1.5,
thickness: 0.5, thickness: 0.5,
backfaceLighting: 0.5, backfaceLighting: 0.5,
aoIntensity: 1.5, aoIntensity: 1.5,
@@ -80,6 +80,8 @@ const PostProcessingPasses = () => {
// SSGI Pass (cast to PerspectiveCamera for SSGI) // SSGI Pass (cast to PerspectiveCamera for SSGI)
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
giPass.sliceCount.value = SSGI_PARAMS.sliceCount giPass.sliceCount.value = SSGI_PARAMS.sliceCount
giPass.stepCount.value = SSGI_PARAMS.stepCount giPass.stepCount.value = SSGI_PARAMS.stepCount
giPass.radius.value = SSGI_PARAMS.radius giPass.radius.value = SSGI_PARAMS.radius
@@ -1,34 +0,0 @@
import { type EventSuffix, emitter, type GridEvent } from "@pascal-app/core";
import type { ThreeEvent } from "@react-three/fiber";
export function useGridEvents() {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`;
const payload: GridEvent = {
position: [e.point.x, e.point.y, e.point.z],
nativeEvent: e,
};
emitter.emit(eventKey, payload);
};
return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerdown", e);
},
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerup", e);
},
onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("click", e);
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
};
}
-1
View File
@@ -1,4 +1,3 @@
export { default as Viewer } from './components/viewer' export { default as Viewer } from './components/viewer'
export { useGridEvents } from './hooks/use-grid-events'
export { default as useViewer } from './store/use-viewer' export { default as useViewer } from './store/use-viewer'
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
@@ -0,0 +1,70 @@
import { sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu'
import useViewer from '../../store/use-viewer'
const TRANSITION_DURATION = 400 // ms
export const ZoneSystem = () => {
const lastHighlightedZoneRef = useRef<string | null>(null)
const lastChangeTimeRef = useRef(0)
const isTransitioningRef = useRef(false)
useFrame(({clock}, delta) => {
const hoveredId = useViewer.getState().hoveredId
let highlightedZone: string | null = null
if (hoveredId) {
const hoveredNode = useScene.getState().nodes[hoveredId]
if (hoveredNode?.type === 'zone') {
highlightedZone = hoveredId
}
}
// Detect zone change
if (highlightedZone !== lastHighlightedZoneRef.current) {
lastHighlightedZoneRef.current = highlightedZone
lastChangeTimeRef.current = clock.elapsedTime * 1000
isTransitioningRef.current = true
}
// Skip frame if not transitioning
if (!isTransitioningRef.current) return
const elapsed = clock.elapsedTime * 1000 - lastChangeTimeRef.current
// Stop transitioning after duration
if (elapsed >= TRANSITION_DURATION) {
isTransitioningRef.current = false
}
// Lerp speed: complete transition in ~400ms
const lerpSpeed = 10 * delta
sceneRegistry.byType.zone.forEach((zoneId) => {
const zone = sceneRegistry.nodes.get(zoneId)
if (!zone) return
const isHighlighted = zoneId === highlightedZone
const targetOpacity = isHighlighted ? 1 : 0
const walls = (zone as Group).getObjectByName('walls') as Mesh | undefined
if (walls) {
const material = walls.material as MeshBasicNodeMaterial
const currentOpacity = material.userData.uOpacity.value
material.userData.uOpacity.value = MathUtils.lerp(currentOpacity, targetOpacity, lerpSpeed)
}
const floor = (zone as Group).getObjectByName('floor') as Mesh | undefined
if (floor) {
const material = floor.material as MeshBasicNodeMaterial
const currentOpacity = material.userData.uOpacity.value
material.userData.uOpacity.value = MathUtils.lerp(currentOpacity, targetOpacity, lerpSpeed)
}
})
})
return null
}