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'
|
||||
|
||||
import {
|
||||
initSpaceDetectionSync,
|
||||
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 { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { Viewer } from '@pascal-app/viewer'
|
||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||
import useEditor from '@/store/use-editor'
|
||||
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 { CustomCameraControls } from './custom-camera-controls'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { Grid } from './grid'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
|
||||
useScene.getState().loadScene()
|
||||
@@ -55,116 +44,3 @@ export default function Editor() {
|
||||
</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 { useViewer } from "@pascal-app/viewer";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
||||
import useEditor from '@/store/use-editor'
|
||||
|
||||
const CEILING_HEIGHT = 2.52; // Slightly above default ceiling height
|
||||
const GRID_OFFSET = 0.02; // Small offset above floor level
|
||||
const UP = new Vector3(0, 1, 0);
|
||||
const CEILING_HEIGHT = 2.52
|
||||
const GRID_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number]
|
||||
currentPoint: [number, number],
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint;
|
||||
const [x, y] = currentPoint;
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
|
||||
const dx = x - x1;
|
||||
const dy = y - y1;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy;
|
||||
const verticalDist = absDx;
|
||||
const diagonalDist = Math.abs(absDx - absDy);
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
|
||||
// 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) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy);
|
||||
return [
|
||||
x1 + Math.sign(dx) * diagonalLength,
|
||||
y1 + Math.sign(dy) * diagonalLength,
|
||||
];
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
} else if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1];
|
||||
return [x, y1]
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y];
|
||||
return [x1, y]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ceiling with the given polygon points
|
||||
*/
|
||||
const commitCeilingDrawing = (
|
||||
levelId: LevelNode["id"],
|
||||
points: Array<[number, number]>
|
||||
) => {
|
||||
const { createNode, nodes } = useScene.getState();
|
||||
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing ceilings for naming
|
||||
const ceilingCount = Object.values(nodes).filter((n) => n.type === "ceiling").length;
|
||||
const name = `Ceiling ${ceilingCount + 1}`;
|
||||
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
||||
const name = `Ceiling ${ceilingCount + 1}`
|
||||
|
||||
const ceiling = CeilingNode.parse({
|
||||
name,
|
||||
polygon: points,
|
||||
});
|
||||
})
|
||||
|
||||
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]);
|
||||
};
|
||||
createNode(ceiling, levelId)
|
||||
}
|
||||
|
||||
export const CeilingTool: React.FC = () => {
|
||||
const cursorRef = useRef<Mesh>(null);
|
||||
const gridCursorRef = useRef<Mesh>(null);
|
||||
const mainLineRef = useRef<Line>(null!);
|
||||
const closingLineRef = useRef<Line>(null!);
|
||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||
const levelYRef = useRef(0);
|
||||
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);
|
||||
const cursorRef = useRef<Mesh>(null)
|
||||
const gridCursorRef = useRef<Mesh>(null)
|
||||
const mainLineRef = useRef<Line>(null!)
|
||||
const closingLineRef = useRef<Line>(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 raycaster = useMemo(() => new Raycaster(), []);
|
||||
const levelPlane = useMemo(() => new Plane(UP, 0), []);
|
||||
const hitPoint = useMemo(() => new Vector3(), []);
|
||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||
const [levelY, setLevelY] = useState(0)
|
||||
|
||||
// Preview state for reactive rendering (for shape and point markers)
|
||||
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
|
||||
// Update cursor position and lines on grid move
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Initialize line geometries
|
||||
if (mainLineRef.current) {
|
||||
mainLineRef.current.geometry = new BufferGeometry();
|
||||
}
|
||||
if (closingLineRef.current) {
|
||||
closingLineRef.current.geometry = new BufferGeometry();
|
||||
}
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current || !gridCursorRef.current) return
|
||||
|
||||
// Reset state on level change
|
||||
pointsRef.current = [];
|
||||
lastDisplayRef.current = null;
|
||||
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
|
||||
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) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Use the cursor position tracked by useFrame (matches what user sees)
|
||||
let clickPoint: [number, number] = [...cursorPositionRef.current];
|
||||
|
||||
// Snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
if (lastPoint) {
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
|
||||
}
|
||||
// Calculate snapped click point
|
||||
const lastPoint = points[points.length - 1]
|
||||
const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = pointsRef.current[0];
|
||||
const firstPoint = points[0]
|
||||
if (
|
||||
pointsRef.current.length >= 3 &&
|
||||
points.length >= 3 &&
|
||||
firstPoint &&
|
||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the ceiling
|
||||
commitCeilingDrawing(currentLevelId, pointsRef.current);
|
||||
|
||||
// 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;
|
||||
|
||||
// Deactivate tool
|
||||
setTool(null);
|
||||
commitCeilingDrawing(currentLevelId, points)
|
||||
setPoints([])
|
||||
setTool(null)
|
||||
} else {
|
||||
// Add point to polygon
|
||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||
lastDisplayRef.current = null; // Force preview update on next frame
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
if (pointsRef.current.length >= 3) {
|
||||
commitCeilingDrawing(currentLevelId, pointsRef.current);
|
||||
|
||||
// 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;
|
||||
|
||||
// Deactivate tool
|
||||
setTool(null);
|
||||
if (points.length >= 3) {
|
||||
commitCeilingDrawing(currentLevelId, points)
|
||||
setPoints([])
|
||||
setTool(null)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
emitter.on("grid:click", onGridClick);
|
||||
emitter.on("grid:double-click", onGridDoubleClick);
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('grid:double-click', onGridDoubleClick)
|
||||
|
||||
return () => {
|
||||
emitter.off("grid:click", onGridClick);
|
||||
emitter.off("grid:double-click", onGridDoubleClick);
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
}
|
||||
}, [currentLevelId, points, cursorPosition, setTool])
|
||||
|
||||
// Reset state on unmount
|
||||
pointsRef.current = [];
|
||||
};
|
||||
}, [currentLevelId, setTool]);
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!mainLineRef.current || !closingLineRef.current) return
|
||||
|
||||
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
|
||||
const previewShape = useMemo(() => {
|
||||
if (points.length < 3) return null;
|
||||
if (points.length < 3) return null
|
||||
|
||||
const allPoints = [...points];
|
||||
if (isValidPoint(cursorPoint)) {
|
||||
allPoints.push(cursorPoint);
|
||||
}
|
||||
const lastPoint = points[points.length - 1]
|
||||
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
||||
|
||||
const allPoints = [...points, snappedCursor]
|
||||
|
||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
||||
// - Shape X -> World X
|
||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
||||
const firstPt = allPoints[0];
|
||||
if (!isValidPoint(firstPt)) return null;
|
||||
const firstPt = allPoints[0]
|
||||
if (!firstPt) return null
|
||||
|
||||
const shape = new Shape();
|
||||
shape.moveTo(firstPt[0], -firstPt[1]);
|
||||
const shape = new Shape()
|
||||
shape.moveTo(firstPt[0], -firstPt[1])
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const pt = allPoints[i];
|
||||
if (isValidPoint(pt)) {
|
||||
shape.lineTo(pt[0], -pt[1]);
|
||||
const pt = allPoints[i]
|
||||
if (pt) {
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
}
|
||||
shape.closePath();
|
||||
shape.closePath()
|
||||
|
||||
return shape;
|
||||
}, [points, cursorPoint]);
|
||||
return shape
|
||||
}, [points, cursorPosition])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor at ceiling height */}
|
||||
<mesh ref={cursorRef}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial
|
||||
color="#d4d4d4"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
<meshBasicMaterial color="#d4d4d4" depthTest={false} depthWrite={false} />
|
||||
</mesh>
|
||||
|
||||
{/* Grid-level cursor indicator */}
|
||||
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.15, 0.2, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#a3a3a3"
|
||||
side={DoubleSide}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
<meshBasicMaterial color="#a3a3a3" side={DoubleSide} depthTest={false} depthWrite={false} />
|
||||
</mesh>
|
||||
|
||||
{/* Preview fill */}
|
||||
@@ -392,19 +248,14 @@ export const CeilingTool: React.FC = () => {
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Main line - uses native line element with TSL-compatible material */}
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#a3a3a3"
|
||||
linewidth={3}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
<lineBasicNodeMaterial color="#a3a3a3" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
</line>
|
||||
|
||||
{/* Closing line - uses native line element with TSL-compatible material */}
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<bufferGeometry />
|
||||
@@ -419,18 +270,16 @@ export const CeilingTool: React.FC = () => {
|
||||
</line>
|
||||
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) =>
|
||||
isValidPoint([x, z]) ? (
|
||||
{points.map(([x, z], index) => (
|
||||
<mesh key={index} position={[x, levelY + CEILING_HEIGHT + 0.01, z]}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial
|
||||
color={index === 0 ? "#22c55e" : "#d4d4d4"}
|
||||
color={index === 0 ? '#22c55e' : '#d4d4d4'}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
) : null
|
||||
)}
|
||||
))}
|
||||
</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 {
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
type Mesh,
|
||||
Plane,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
|
||||
@@ -24,7 +17,8 @@ export interface PolygonEditorProps {
|
||||
color?: string
|
||||
onPolygonChange: (polygon: Array<[number, number]>) => void
|
||||
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. */
|
||||
surfaceHeight?: number
|
||||
}
|
||||
@@ -40,24 +34,23 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
color = '#3b82f6',
|
||||
onPolygonChange,
|
||||
minVertices = 3,
|
||||
levelY = 0,
|
||||
levelId,
|
||||
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)
|
||||
const editY = levelY + Y_OFFSET
|
||||
// When using portal, edit at Y_OFFSET (local to level)
|
||||
// When not using portal, edit at world origin
|
||||
const editY = levelNode ? Y_OFFSET : 0
|
||||
|
||||
// Local state for dragging
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||
const [hoveredVertex, setHoveredVertex] = 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!)
|
||||
|
||||
// Track the last polygon prop to detect external changes (undo/redo)
|
||||
@@ -82,30 +75,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
})
|
||||
}, [displayPolygon])
|
||||
|
||||
// Handle vertex drag
|
||||
// Update vertex position using grid cursor position
|
||||
const handleVertexDrag = useCallback(
|
||||
(clientX: number, clientY: number, vertexIndex: number) => {
|
||||
const canvas = gl.domElement
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = ((clientX - rect.left) / rect.width) * 2 - 1
|
||||
const y = -((clientY - rect.top) / rect.height) * 2 + 1
|
||||
|
||||
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
|
||||
|
||||
(vertexIndex: number) => {
|
||||
const basePolygon = previewPolygon ?? polygon
|
||||
const newPolygon = [...basePolygon]
|
||||
newPolygon[vertexIndex] = [gridX, gridZ]
|
||||
newPolygon[vertexIndex] = cursorPosition
|
||||
setPreviewPolygon(newPolygon)
|
||||
}
|
||||
},
|
||||
[gl, camera, previewPolygon, polygon],
|
||||
[cursorPosition, previewPolygon, polygon],
|
||||
)
|
||||
|
||||
// Commit polygon changes
|
||||
@@ -146,58 +124,58 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
[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(() => {
|
||||
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) => {
|
||||
// Stop the event from reaching R3F's handlers, which would otherwise
|
||||
// fire a grid:click and deselect the node being edited.
|
||||
// Only handle the specific pointer that started the drag
|
||||
if (e.pointerId !== dragState.pointerId) return
|
||||
|
||||
// Stop the event from propagating to prevent grid click
|
||||
e.stopImmediatePropagation()
|
||||
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
|
||||
const suppressClick = (ce: MouseEvent) => {
|
||||
ce.stopImmediatePropagation()
|
||||
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
|
||||
requestAnimationFrame(() => {
|
||||
canvas.removeEventListener('click', suppressClick, true)
|
||||
window.removeEventListener('click', suppressClick, true)
|
||||
})
|
||||
|
||||
commitPolygonChange()
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointermove', handlePointerMove)
|
||||
canvas.addEventListener('pointerup', handlePointerUp, true)
|
||||
|
||||
window.addEventListener('pointerup', handlePointerUp, true)
|
||||
return () => {
|
||||
// Release capture on cleanup
|
||||
if (canvas.hasPointerCapture(pointerId)) {
|
||||
canvas.releasePointerCapture(pointerId)
|
||||
window.removeEventListener('pointerup', handlePointerUp, true)
|
||||
}
|
||||
canvas.removeEventListener('pointermove', handlePointerMove)
|
||||
canvas.removeEventListener('pointerup', handlePointerUp, true)
|
||||
}
|
||||
}, [dragState, gl, handleVertexDrag, commitPolygonChange])
|
||||
}, [dragState, commitPolygonChange])
|
||||
|
||||
// Update line geometry when polygon changes
|
||||
useEffect(() => {
|
||||
@@ -222,11 +200,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
const canDelete = displayPolygon.length > minVertices
|
||||
|
||||
return (
|
||||
const editorContent = (
|
||||
<group>
|
||||
{/* Border line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={lineRef} frustumCulled={false} renderOrder={10}>
|
||||
<line ref={lineRef} frustumCulled={false} renderOrder={10} raycast={() => {}}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color={color}
|
||||
@@ -337,4 +315,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
})}
|
||||
</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 { PolygonEditor } from '../shared/polygon-editor'
|
||||
|
||||
@@ -34,7 +34,7 @@ export const SiteBoundaryEditor: React.FC = () => {
|
||||
return (
|
||||
<PolygonEditor
|
||||
polygon={site.polygon.points}
|
||||
color="#f59e0b"
|
||||
color="#10b981"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
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 { useCallback } from 'react'
|
||||
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
|
||||
*/
|
||||
export const SlabBoundaryEditor: React.FC = () => {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const levelId = useViewer((state) => state.selection.levelId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
|
||||
const slabNode = useScene((state) => state.nodes[slabId])
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
// Find the first selected slab
|
||||
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 slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
if (selectedSlabId) {
|
||||
updateNode(selectedSlabId as SlabNode['id'], { polygon: newPolygon })
|
||||
updateNode(slabId, { polygon: newPolygon })
|
||||
// Re-assert selection so the slab stays selected after the edit
|
||||
setSelection({ selectedIds: [selectedSlabId] })
|
||||
}
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
},
|
||||
[selectedSlabId, updateNode, setSelection],
|
||||
[slabId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!slab || !slab.polygon || slab.polygon.length < 3) return null
|
||||
@@ -57,7 +35,7 @@ export const SlabBoundaryEditor: React.FC = () => {
|
||||
color="#a3a3a3"
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelY={levelY}
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,357 +1,221 @@
|
||||
import { emitter, type GridEvent, useScene, SlabNode, type LevelNode, 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 { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
||||
|
||||
const Y_OFFSET = 0.02; // Small offset above floor level
|
||||
const UP = new Vector3(0, 1, 0);
|
||||
const Y_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number]
|
||||
currentPoint: [number, number],
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint;
|
||||
const [x, y] = currentPoint;
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
|
||||
const dx = x - x1;
|
||||
const dy = y - y1;
|
||||
const absDx = Math.abs(dx);
|
||||
const absDy = Math.abs(dy);
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy;
|
||||
const verticalDist = absDx;
|
||||
const diagonalDist = Math.abs(absDx - absDy);
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
|
||||
// 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) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy);
|
||||
return [
|
||||
x1 + Math.sign(dx) * diagonalLength,
|
||||
y1 + Math.sign(dy) * diagonalLength,
|
||||
];
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
} else if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1];
|
||||
return [x, y1]
|
||||
} else {
|
||||
// Snap to vertical
|
||||
return [x1, y];
|
||||
return [x1, y]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a slab with the given polygon points and returns its ID
|
||||
*/
|
||||
const commitSlabDrawing = (
|
||||
levelId: LevelNode["id"],
|
||||
points: Array<[number, number]>
|
||||
): string => {
|
||||
const { createNode, nodes } = useScene.getState();
|
||||
const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing slabs for naming
|
||||
const slabCount = Object.values(nodes).filter((n) => n.type === "slab").length;
|
||||
const name = `Slab ${slabCount + 1}`;
|
||||
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
|
||||
const name = `Slab ${slabCount + 1}`
|
||||
|
||||
const slab = SlabNode.parse({
|
||||
name,
|
||||
polygon: points,
|
||||
});
|
||||
})
|
||||
|
||||
createNode(slab, levelId);
|
||||
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]);
|
||||
};
|
||||
createNode(slab, levelId)
|
||||
return slab.id
|
||||
}
|
||||
|
||||
export const SlabTool: React.FC = () => {
|
||||
const cursorRef = useRef<Mesh>(null);
|
||||
const mainLineRef = useRef<Line>(null!);
|
||||
const closingLineRef = useRef<Line>(null!);
|
||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||
const levelYRef = useRef(0);
|
||||
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);
|
||||
const cursorRef = useRef<Mesh>(null)
|
||||
const mainLineRef = useRef<Line>(null!)
|
||||
const closingLineRef = useRef<Line>(null!)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
// Reusable objects for raycasting (created once, avoid per-frame allocations)
|
||||
const raycaster = useMemo(() => new Raycaster(), []);
|
||||
const levelPlane = useMemo(() => new Plane(UP, 0), []);
|
||||
const hitPoint = useMemo(() => new Vector3(), []);
|
||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||
const [levelY, setLevelY] = useState(0)
|
||||
|
||||
// Preview state for reactive rendering (for shape and point markers)
|
||||
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
|
||||
// Update cursor position and lines on grid move
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Initialize line geometries
|
||||
if (mainLineRef.current) {
|
||||
mainLineRef.current.geometry = new BufferGeometry();
|
||||
}
|
||||
if (closingLineRef.current) {
|
||||
closingLineRef.current.geometry = new BufferGeometry();
|
||||
}
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
// Reset state on level change
|
||||
pointsRef.current = [];
|
||||
lastDisplayRef.current = null;
|
||||
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
|
||||
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) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Use the cursor position tracked by useFrame (matches what user sees)
|
||||
let clickPoint: [number, number] = [...cursorPositionRef.current];
|
||||
|
||||
// Snap to axis from last point
|
||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||
if (lastPoint) {
|
||||
clickPoint = calculateSnapPoint(lastPoint, clickPoint);
|
||||
}
|
||||
// Calculate snapped click point
|
||||
const lastPoint = points[points.length - 1]
|
||||
const clickPoint = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = pointsRef.current[0];
|
||||
const firstPoint = points[0]
|
||||
if (
|
||||
pointsRef.current.length >= 3 &&
|
||||
points.length >= 3 &&
|
||||
firstPoint &&
|
||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the slab and select it
|
||||
const slabId = commitSlabDrawing(currentLevelId, pointsRef.current);
|
||||
setSelection({ selectedIds: [slabId] });
|
||||
|
||||
// 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;
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
setPoints([])
|
||||
} else {
|
||||
// Add point to polygon
|
||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||
lastDisplayRef.current = null; // Force preview update on next frame
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return;
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
if (pointsRef.current.length >= 3) {
|
||||
// Create the slab and select it
|
||||
const slabId = commitSlabDrawing(currentLevelId, pointsRef.current);
|
||||
setSelection({ selectedIds: [slabId] });
|
||||
|
||||
// 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;
|
||||
if (points.length >= 3) {
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
setPoints([])
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
emitter.on("grid:click", onGridClick);
|
||||
emitter.on("grid:double-click", onGridDoubleClick);
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('grid:double-click', onGridDoubleClick)
|
||||
|
||||
return () => {
|
||||
emitter.off("grid:click", onGridClick);
|
||||
emitter.off("grid:double-click", onGridDoubleClick);
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
}
|
||||
}, [currentLevelId, points, cursorPosition, setSelection])
|
||||
|
||||
// Reset state on unmount
|
||||
pointsRef.current = [];
|
||||
};
|
||||
}, [currentLevelId, setSelection]);
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!mainLineRef.current || !closingLineRef.current) return
|
||||
|
||||
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
|
||||
const previewShape = useMemo(() => {
|
||||
if (points.length < 3) return null;
|
||||
if (points.length < 3) return null
|
||||
|
||||
const allPoints = [...points];
|
||||
if (isValidPoint(cursorPoint)) {
|
||||
allPoints.push(cursorPoint);
|
||||
}
|
||||
const lastPoint = points[points.length - 1]
|
||||
const snappedCursor = lastPoint ? calculateSnapPoint(lastPoint, cursorPosition) : cursorPosition
|
||||
|
||||
const allPoints = [...points, snappedCursor]
|
||||
|
||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
||||
// - Shape X -> World X
|
||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
||||
const firstPt = allPoints[0];
|
||||
if (!isValidPoint(firstPt)) return null;
|
||||
const firstPt = allPoints[0]
|
||||
if (!firstPt) return null
|
||||
|
||||
const shape = new Shape();
|
||||
shape.moveTo(firstPt[0], -firstPt[1]);
|
||||
const shape = new Shape()
|
||||
shape.moveTo(firstPt[0], -firstPt[1])
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const pt = allPoints[i];
|
||||
if (isValidPoint(pt)) {
|
||||
shape.lineTo(pt[0], -pt[1]);
|
||||
const pt = allPoints[i]
|
||||
if (pt) {
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
}
|
||||
shape.closePath();
|
||||
shape.closePath()
|
||||
|
||||
return shape;
|
||||
}, [points, cursorPoint]);
|
||||
return shape
|
||||
}, [points, cursorPosition])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor */}
|
||||
<mesh ref={cursorRef}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial
|
||||
color="#a3a3a3"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
<meshBasicMaterial color="#a3a3a3" depthTest={false} depthWrite={false} />
|
||||
</mesh>
|
||||
|
||||
{/* Preview fill */}
|
||||
@@ -372,19 +236,14 @@ export const SlabTool: React.FC = () => {
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Main line - uses native line element with TSL-compatible material */}
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#737373"
|
||||
linewidth={3}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
<lineBasicNodeMaterial color="#737373" linewidth={3} depthTest={false} depthWrite={false} />
|
||||
</line>
|
||||
|
||||
{/* Closing line - uses native line element with TSL-compatible material */}
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
||||
<bufferGeometry />
|
||||
@@ -399,18 +258,16 @@ export const SlabTool: React.FC = () => {
|
||||
</line>
|
||||
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) =>
|
||||
isValidPoint([x, z]) ? (
|
||||
{points.map(([x, z], index) => (
|
||||
<mesh key={index} position={[x, levelY + Y_OFFSET + 0.01, z]}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial
|
||||
color={index === 0 ? "#22c55e" : "#a3a3a3"}
|
||||
color={index === 0 ? '#22c55e' : '#a3a3a3'}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
) : null
|
||||
)}
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import useEditor, { type Phase, type Tool } from "@/store/use-editor";
|
||||
import { useScene, type AnyNodeId } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { CeilingTool } from "./ceiling/ceiling-tool";
|
||||
import { ItemTool } from "./item/item-tool";
|
||||
import { MoveTool } from "./item/move-tool";
|
||||
import { RoofTool } from "./roof/roof-tool";
|
||||
import { SiteBoundaryEditor } from "./site/site-boundary-editor";
|
||||
import { SlabBoundaryEditor } from "./slab/slab-boundary-editor";
|
||||
import { SlabTool } from "./slab/slab-tool";
|
||||
import { WallTool } from "./wall/wall-tool";
|
||||
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
|
||||
import { ZoneTool } from "./zone/zone-tool";
|
||||
import { type AnyNodeId, type CeilingNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import useEditor, { type Phase, type Tool } from '@/store/use-editor'
|
||||
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||
import { ItemTool } from './item/item-tool'
|
||||
import { MoveTool } from './item/move-tool'
|
||||
import { RoofTool } from './roof/roof-tool'
|
||||
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
||||
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
||||
import { SlabTool } from './slab/slab-tool'
|
||||
import { WallTool } from './wall/wall-tool'
|
||||
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
||||
import { ZoneTool } from './zone/zone-tool'
|
||||
|
||||
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
site: {
|
||||
"property-line": SiteBoundaryEditor,
|
||||
'property-line': SiteBoundaryEditor,
|
||||
},
|
||||
structure: {
|
||||
wall: WallTool,
|
||||
@@ -27,44 +28,62 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
furnish: {
|
||||
item: ItemTool,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ToolManager: React.FC = () => {
|
||||
const phase = useEditor((state) => state.phase);
|
||||
const mode = useEditor((state) => state.mode);
|
||||
const tool = useEditor((state) => state.tool);
|
||||
const movingNode = useEditor((state) => state.movingNode);
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||
const nodes = useScene((state) => state.nodes);
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
|
||||
// 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
|
||||
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
|
||||
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
|
||||
// Hide when editing a slab to avoid overlapping handles
|
||||
// Hide when editing a slab or ceiling to avoid overlapping handles
|
||||
const showZoneBoundaryEditor =
|
||||
phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor;
|
||||
phase === 'structure' &&
|
||||
mode === 'select' &&
|
||||
selectedZoneId !== null &&
|
||||
!showSlabBoundaryEditor &&
|
||||
!showCeilingBoundaryEditor
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||
{showZoneBoundaryEditor && <ZoneBoundaryEditor />}
|
||||
{showSlabBoundaryEditor && <SlabBoundaryEditor />}
|
||||
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
||||
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
||||
{showCeilingBoundaryEditor && selectedCeilingId && (
|
||||
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||
)}
|
||||
{movingNode && <MoveTool />}
|
||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { useScene, type ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { resolveLevelId, useScene, type ZoneNode } from '@pascal-app/core'
|
||||
import { useCallback } from 'react'
|
||||
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
|
||||
*/
|
||||
export const ZoneBoundaryEditor: React.FC = () => {
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||
const zoneNode = useScene((state) => (selectedZoneId ? state.nodes[selectedZoneId] : null))
|
||||
const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
|
||||
export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }) => {
|
||||
const zoneNode = useScene((state) => state.nodes[zoneId])
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
|
||||
const zone = zoneNode?.type === 'zone' ? (zoneNode as ZoneNode) : null
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
if (selectedZoneId) {
|
||||
updateNode(selectedZoneId, { polygon: newPolygon })
|
||||
}
|
||||
updateNode(zoneId, { polygon: newPolygon })
|
||||
},
|
||||
[selectedZoneId, updateNode],
|
||||
[zoneId, updateNode],
|
||||
)
|
||||
|
||||
if (!zone || !zone.polygon || zone.polygon.length < 3) return null
|
||||
@@ -32,6 +33,7 @@ export const ZoneBoundaryEditor: React.FC = () => {
|
||||
color={zoneColor}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
minVertices={3}
|
||||
levelId={resolveLevelId(zone, useScene.getState().nodes)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type BuildingNode,
|
||||
emitter,
|
||||
LevelNode,
|
||||
type SiteNode,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from "@pascal-app/core";
|
||||
@@ -11,7 +12,9 @@ import {
|
||||
Camera,
|
||||
ChevronDown,
|
||||
Layers,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} 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() {
|
||||
@@ -55,15 +237,14 @@ function SitePhaseView() {
|
||||
.map((child) => typeof child === 'string' ? nodes[child] : child)
|
||||
.filter((node): node is BuildingNode => node?.type === "building");
|
||||
|
||||
if (buildings.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PropertyLineSection />
|
||||
{buildings.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No buildings yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
{buildings.map((building) => (
|
||||
<button
|
||||
@@ -81,6 +262,8 @@ function SitePhaseView() {
|
||||
</button>
|
||||
))}
|
||||
</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 }) {
|
||||
const [renameOpen, setRenameOpen] = 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]
|
||||
if (!node || node.type !== 'wall') return
|
||||
|
||||
console.log('wall front/back', node.frontSide, node.backSide)
|
||||
const levelId = node.parentId
|
||||
if (!levelId) return
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type SiteNode, useRegistry } from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -60,6 +61,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
return createBoundaryLineGeometry(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')
|
||||
|
||||
if (!node || !floorShape || !lineGeometry) {
|
||||
@@ -98,6 +112,21 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
opacity={0.6}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRegistry, type ZoneNode } from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useMemo, useRef } from 'react'
|
||||
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 { 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)
|
||||
const gradientT = uv().y
|
||||
|
||||
const opacity = uniform(0);
|
||||
// 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({
|
||||
transparent: true,
|
||||
colorNode: baseColor,
|
||||
opacityNode: opacity,
|
||||
opacityNode: finalOpacity,
|
||||
side: DoubleSide,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
userData: {
|
||||
uOpacity: opacity,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,13 +41,15 @@ const createWallGradientMaterial = (zoneColor: string) => {
|
||||
*/
|
||||
const createFloorMaterial = (zoneColor: string) => {
|
||||
const baseColor = color(new Color(zoneColor))
|
||||
|
||||
const opacity = uniform(0)
|
||||
return new MeshBasicNodeMaterial({
|
||||
transparent: true,
|
||||
colorNode: baseColor,
|
||||
opacityNode: float(0.15),
|
||||
opacityNode: float(0.25).mul(opacity),
|
||||
side: DoubleSide,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
userData: { uOpacity: opacity}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,7 +180,8 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
||||
<group ref={ref} {...handlers}>
|
||||
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
}}
|
||||
zIndexRange={[10, 0]}>
|
||||
<div style={{
|
||||
transform: 'translate3d(-50%, -50%, 0)',
|
||||
width: 'max-content',
|
||||
@@ -187,12 +194,12 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
||||
{node.name}</div>
|
||||
</Html>
|
||||
{/* 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]} />
|
||||
</mesh>
|
||||
|
||||
{/* Wall borders with gradient */}
|
||||
<mesh geometry={wallGeometry} material={wallMaterial} />
|
||||
<mesh geometry={wallGeometry} material={wallMaterial} name="walls" />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
import { LevelSystem } from '../../systems/level/level-system'
|
||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import { Lights } from './lights'
|
||||
import PostProcessing from './post-processing'
|
||||
@@ -28,6 +29,7 @@ interface ViewerProps {
|
||||
const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default' }) => {
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, 1.5]}
|
||||
className={'bg-[#fafafa]'}
|
||||
gl={async (props) => {
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
@@ -63,6 +65,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
|
||||
<RoofSystem />
|
||||
<SlabSystem />
|
||||
<WallSystem />
|
||||
<ZoneSystem />
|
||||
<PostProcessing />
|
||||
|
||||
{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
|
||||
export const SSGI_PARAMS = {
|
||||
enabled: true,
|
||||
sliceCount: 2,
|
||||
sliceCount: 1,
|
||||
stepCount: 8,
|
||||
radius: 2,
|
||||
expFactor: 2,
|
||||
radius: 1,
|
||||
expFactor: 1.5,
|
||||
thickness: 0.5,
|
||||
backfaceLighting: 0.5,
|
||||
aoIntensity: 1.5,
|
||||
@@ -80,6 +80,8 @@ const PostProcessingPasses = () => {
|
||||
|
||||
// SSGI Pass (cast to PerspectiveCamera for SSGI)
|
||||
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
|
||||
|
||||
|
||||
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
|
||||
giPass.stepCount.value = SSGI_PARAMS.stepCount
|
||||
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 { useGridEvents } from './hooks/use-grid-events'
|
||||
export { default as useViewer } from './store/use-viewer'
|
||||
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