Merge pull request #89 from pascalorg/feat/polish-tools-and-perf
Feat/polish tools and perf
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user