Merge pull request #88 from pascalorg/feat/floor-editing
Feat/floor editing
This commit is contained in:
@@ -148,6 +148,15 @@ const Grid = ({
|
|||||||
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId)
|
||||||
if (levelMesh) {
|
if (levelMesh) {
|
||||||
targetY = levelMesh.position.y
|
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)
|
gridRef.current.position.y = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode } from "@pascal-app/core";
|
import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode, sceneRegistry } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
|
|
||||||
const CEILING_HEIGHT = 2.52; // Slightly above default ceiling height
|
const CEILING_HEIGHT = 2.52; // Slightly above default ceiling height
|
||||||
const GRID_OFFSET = 0.02; // Small offset above floor level
|
const GRID_OFFSET = 0.02; // Small offset above floor level
|
||||||
|
const UP = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||||
@@ -87,10 +89,17 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const mainLineRef = useRef<Line>(null!);
|
const mainLineRef = useRef<Line>(null!);
|
||||||
const closingLineRef = useRef<Line>(null!);
|
const closingLineRef = useRef<Line>(null!);
|
||||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||||
const levelYRef = useRef(0); // Track current level Y position
|
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 currentLevelId = useViewer((state) => state.selection.levelId);
|
||||||
const setTool = useEditor((state) => state.setTool);
|
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(), []);
|
||||||
|
|
||||||
// Preview state for reactive rendering (for shape and point markers)
|
// Preview state for reactive rendering (for shape and point markers)
|
||||||
const [preview, setPreview] = useState<PreviewState>({
|
const [preview, setPreview] = useState<PreviewState>({
|
||||||
points: [],
|
points: [],
|
||||||
@@ -98,118 +107,154 @@ export const CeilingTool: React.FC = () => {
|
|||||||
levelY: 0,
|
levelY: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve the current level's Y position from scene registry or node data
|
||||||
|
const getLevelY = (): number => {
|
||||||
|
if (!currentLevelId) return 0;
|
||||||
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId);
|
||||||
|
if (levelMesh) return levelMesh.position.y;
|
||||||
|
const levelNode = useScene.getState().nodes[currentLevelId];
|
||||||
|
if (levelNode && 'level' in levelNode) {
|
||||||
|
const levelMode = useViewer.getState().levelMode;
|
||||||
|
const LEVEL_HEIGHT = 2.5;
|
||||||
|
const EXPLODED_GAP = 5;
|
||||||
|
return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Imperatively update line geometries (called from useFrame)
|
||||||
|
const updateLines = () => {
|
||||||
|
if (!mainLineRef.current || !closingLineRef.current) return;
|
||||||
|
|
||||||
|
const points = pointsRef.current;
|
||||||
|
const cursorPosition = cursorPositionRef.current;
|
||||||
|
const ceilingY = levelYRef.current + CEILING_HEIGHT;
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
mainLineRef.current.visible = false;
|
||||||
|
closingLineRef.current.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build main line points
|
||||||
|
const linePoints: Vector3[] = points.map(
|
||||||
|
([x, z]) => new Vector3(x, ceilingY, z)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add cursor point
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
if (lastPoint) {
|
||||||
|
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||||
|
if (isValidPoint(snapped)) {
|
||||||
|
linePoints.push(new Vector3(snapped[0], ceilingY, snapped[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update main line geometry
|
||||||
|
if (linePoints.length >= 2) {
|
||||||
|
mainLineRef.current.geometry.dispose();
|
||||||
|
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
|
||||||
|
mainLineRef.current.visible = true;
|
||||||
|
} else {
|
||||||
|
mainLineRef.current.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update closing line (from cursor back to first point)
|
||||||
|
const firstPoint = points[0];
|
||||||
|
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
||||||
|
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||||
|
if (isValidPoint(snapped)) {
|
||||||
|
const closingPoints = [
|
||||||
|
new Vector3(snapped[0], ceilingY, snapped[1]),
|
||||||
|
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
|
||||||
|
];
|
||||||
|
closingLineRef.current.geometry.dispose();
|
||||||
|
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
|
||||||
|
closingLineRef.current.visible = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
closingLineRef.current.visible = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-frame cursor positioning via direct raycasting onto the level plane.
|
||||||
|
// This bypasses R3F's event propagation (grid:move), ensuring the cursor
|
||||||
|
// always tracks the pointer regardless of tool activation/deactivation state.
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!cursorRef.current) return;
|
||||||
|
|
||||||
|
// Sync level Y from scene registry each frame
|
||||||
|
levelYRef.current = getLevelY();
|
||||||
|
|
||||||
|
// Raycast from camera through pointer onto horizontal plane at level Y
|
||||||
|
raycaster.setFromCamera(state.pointer, state.camera);
|
||||||
|
levelPlane.constant = -levelYRef.current;
|
||||||
|
const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint);
|
||||||
|
if (!hit) return;
|
||||||
|
|
||||||
|
// Snap to 0.5m grid
|
||||||
|
const gridX = Math.round(hit.x * 2) / 2;
|
||||||
|
const gridZ = Math.round(hit.z * 2) / 2;
|
||||||
|
cursorPositionRef.current = [gridX, gridZ];
|
||||||
|
|
||||||
|
const ceilingY = levelYRef.current + CEILING_HEIGHT;
|
||||||
|
const gridY = levelYRef.current + GRID_OFFSET;
|
||||||
|
|
||||||
|
// Apply axis snapping from last placed point
|
||||||
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||||
|
let displayPoint: [number, number];
|
||||||
|
if (lastPoint) {
|
||||||
|
displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
|
||||||
|
} else {
|
||||||
|
displayPoint = [gridX, gridZ];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor mesh positions imperatively
|
||||||
|
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]);
|
||||||
|
if (gridCursorRef.current) {
|
||||||
|
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update line geometries imperatively
|
||||||
|
updateLines();
|
||||||
|
|
||||||
|
// Only trigger React state update when snapped display position changes
|
||||||
|
if (
|
||||||
|
!lastDisplayRef.current ||
|
||||||
|
lastDisplayRef.current.x !== displayPoint[0] ||
|
||||||
|
lastDisplayRef.current.z !== displayPoint[1]
|
||||||
|
) {
|
||||||
|
lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] };
|
||||||
|
setPreview({
|
||||||
|
points: [...pointsRef.current],
|
||||||
|
cursorPoint: displayPoint,
|
||||||
|
levelY: levelYRef.current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click and double-click handlers for point placement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentLevelId) return;
|
if (!currentLevelId) return;
|
||||||
|
|
||||||
let cursorPosition: [number, number] = [0, 0];
|
|
||||||
|
|
||||||
// Initialize line geometries
|
// Initialize line geometries
|
||||||
mainLineRef.current.geometry = new BufferGeometry();
|
if (mainLineRef.current) {
|
||||||
closingLineRef.current.geometry = new BufferGeometry();
|
mainLineRef.current.geometry = new BufferGeometry();
|
||||||
|
}
|
||||||
|
if (closingLineRef.current) {
|
||||||
|
closingLineRef.current.geometry = new BufferGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
const updateLines = () => {
|
// Reset state on level change
|
||||||
const points = pointsRef.current;
|
pointsRef.current = [];
|
||||||
const ceilingY = levelYRef.current + CEILING_HEIGHT;
|
lastDisplayRef.current = null;
|
||||||
|
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
|
||||||
|
|
||||||
if (points.length === 0) {
|
const onGridClick = (_event: GridEvent) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePreview = () => {
|
|
||||||
const points = pointsRef.current;
|
|
||||||
const lastPoint = points[points.length - 1];
|
|
||||||
|
|
||||||
let cursorPt: [number, number] | null = null;
|
|
||||||
if (lastPoint) {
|
|
||||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
} else if (points.length === 0) {
|
|
||||||
cursorPt = cursorPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
|
|
||||||
updateLines();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
|
||||||
if (!cursorRef.current) return;
|
|
||||||
|
|
||||||
// Snap to 0.5 grid
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
|
||||||
cursorPosition = [gridX, gridZ];
|
|
||||||
levelYRef.current = event.position[1];
|
|
||||||
|
|
||||||
const ceilingY = event.position[1] + CEILING_HEIGHT;
|
|
||||||
const gridY = event.position[1] + GRID_OFFSET;
|
|
||||||
|
|
||||||
// If we have points, snap to axis from last point
|
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
|
||||||
if (lastPoint) {
|
|
||||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
cursorRef.current.position.set(snapped[0], ceilingY, snapped[1]);
|
|
||||||
// Also update grid-level cursor
|
|
||||||
if (gridCursorRef.current) {
|
|
||||||
gridCursorRef.current.position.set(snapped[0], gridY, snapped[1]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cursorRef.current.position.set(gridX, ceilingY, gridZ);
|
|
||||||
if (gridCursorRef.current) {
|
|
||||||
gridCursorRef.current.position.set(gridX, gridY, gridZ);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
|
||||||
if (!currentLevelId) return;
|
if (!currentLevelId) return;
|
||||||
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
// Use the cursor position tracked by useFrame (matches what user sees)
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
let clickPoint: [number, number] = [...cursorPositionRef.current];
|
||||||
let clickPoint: [number, number] = [gridX, gridZ];
|
|
||||||
|
|
||||||
// Snap to axis from last point
|
// Snap to axis from last point
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||||
@@ -230,16 +275,17 @@ export const CeilingTool: React.FC = () => {
|
|||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
pointsRef.current = [];
|
pointsRef.current = [];
|
||||||
|
lastDisplayRef.current = null;
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||||
mainLineRef.current.visible = false;
|
if (mainLineRef.current) mainLineRef.current.visible = false;
|
||||||
closingLineRef.current.visible = false;
|
if (closingLineRef.current) closingLineRef.current.visible = false;
|
||||||
|
|
||||||
// Deactivate tool
|
// Deactivate tool
|
||||||
setTool(null);
|
setTool(null);
|
||||||
} else {
|
} else {
|
||||||
// Add point to polygon
|
// Add point to polygon
|
||||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||||
updatePreview();
|
lastDisplayRef.current = null; // Force preview update on next frame
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,22 +298,20 @@ export const CeilingTool: React.FC = () => {
|
|||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
pointsRef.current = [];
|
pointsRef.current = [];
|
||||||
|
lastDisplayRef.current = null;
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||||
mainLineRef.current.visible = false;
|
if (mainLineRef.current) mainLineRef.current.visible = false;
|
||||||
closingLineRef.current.visible = false;
|
if (closingLineRef.current) closingLineRef.current.visible = false;
|
||||||
|
|
||||||
// Deactivate tool
|
// Deactivate tool
|
||||||
setTool(null);
|
setTool(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscribe to events
|
|
||||||
emitter.on("grid:move", onGridMove);
|
|
||||||
emitter.on("grid:click", onGridClick);
|
emitter.on("grid:click", onGridClick);
|
||||||
emitter.on("grid:double-click", onGridDoubleClick);
|
emitter.on("grid:double-click", onGridDoubleClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off("grid:move", onGridMove);
|
|
||||||
emitter.off("grid:click", onGridClick);
|
emitter.off("grid:click", onGridClick);
|
||||||
emitter.off("grid:double-click", onGridDoubleClick);
|
emitter.off("grid:double-click", onGridDoubleClick);
|
||||||
|
|
||||||
|
|||||||
@@ -24,20 +24,30 @@ export interface PolygonEditorProps {
|
|||||||
color?: string
|
color?: string
|
||||||
onPolygonChange: (polygon: Array<[number, number]>) => void
|
onPolygonChange: (polygon: Array<[number, number]>) => void
|
||||||
minVertices?: number
|
minVertices?: number
|
||||||
|
levelY?: number
|
||||||
|
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
|
||||||
|
surfaceHeight?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic polygon editor component for editing polygon vertices
|
* Generic polygon editor component for editing polygon vertices
|
||||||
* Used by zone and site boundary editors
|
* Used by zone and site boundary editors
|
||||||
*/
|
*/
|
||||||
|
const MIN_HANDLE_HEIGHT = 0.15
|
||||||
|
|
||||||
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||||
polygon,
|
polygon,
|
||||||
color = '#3b82f6',
|
color = '#3b82f6',
|
||||||
onPolygonChange,
|
onPolygonChange,
|
||||||
minVertices = 3,
|
minVertices = 3,
|
||||||
|
levelY = 0,
|
||||||
|
surfaceHeight = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { gl, camera } = useThree()
|
const { gl, camera } = useThree()
|
||||||
|
|
||||||
|
// Compute the editing plane height (level Y + small offset above floor)
|
||||||
|
const editY = levelY + Y_OFFSET
|
||||||
|
|
||||||
// Local state for dragging
|
// Local state for dragging
|
||||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||||
@@ -45,10 +55,20 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
||||||
|
|
||||||
// Refs for raycasting during drag
|
// Refs for raycasting during drag
|
||||||
const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET))
|
const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -editY))
|
||||||
|
dragPlane.current.constant = -editY
|
||||||
const raycaster = useRef(new Raycaster())
|
const raycaster = useRef(new Raycaster())
|
||||||
const lineRef = useRef<Mesh>(null!)
|
const lineRef = useRef<Mesh>(null!)
|
||||||
|
|
||||||
|
// Track the last polygon prop to detect external changes (undo/redo)
|
||||||
|
const lastPolygonRef = useRef(polygon)
|
||||||
|
if (polygon !== lastPolygonRef.current) {
|
||||||
|
lastPolygonRef.current = polygon
|
||||||
|
// External change (e.g. undo/redo) — clear any stale preview/drag state
|
||||||
|
if (previewPolygon) setPreviewPolygon(null)
|
||||||
|
if (dragState) setDragState(null)
|
||||||
|
}
|
||||||
|
|
||||||
// The polygon to display (preview during drag, or actual polygon)
|
// The polygon to display (preview during drag, or actual polygon)
|
||||||
const displayPolygon = previewPolygon ?? polygon
|
const displayPolygon = previewPolygon ?? polygon
|
||||||
|
|
||||||
@@ -141,15 +161,33 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePointerUp = (e: PointerEvent) => {
|
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.
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
// Release pointer capture
|
// Release pointer capture
|
||||||
if (canvas.hasPointerCapture(e.pointerId)) {
|
if (canvas.hasPointerCapture(e.pointerId)) {
|
||||||
canvas.releasePointerCapture(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)
|
||||||
|
}
|
||||||
|
canvas.addEventListener('click', suppressClick, true)
|
||||||
|
// Safety cleanup in case no click fires
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
canvas.removeEventListener('click', suppressClick, true)
|
||||||
|
})
|
||||||
|
|
||||||
commitPolygonChange()
|
commitPolygonChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('pointermove', handlePointerMove)
|
canvas.addEventListener('pointermove', handlePointerMove)
|
||||||
canvas.addEventListener('pointerup', handlePointerUp)
|
canvas.addEventListener('pointerup', handlePointerUp, true)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Release capture on cleanup
|
// Release capture on cleanup
|
||||||
@@ -157,7 +195,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
canvas.releasePointerCapture(pointerId)
|
canvas.releasePointerCapture(pointerId)
|
||||||
}
|
}
|
||||||
canvas.removeEventListener('pointermove', handlePointerMove)
|
canvas.removeEventListener('pointermove', handlePointerMove)
|
||||||
canvas.removeEventListener('pointerup', handlePointerUp)
|
canvas.removeEventListener('pointerup', handlePointerUp, true)
|
||||||
}
|
}
|
||||||
}, [dragState, gl, handleVertexDrag, commitPolygonChange])
|
}, [dragState, gl, handleVertexDrag, commitPolygonChange])
|
||||||
|
|
||||||
@@ -167,18 +205,18 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
|
|
||||||
const positions: number[] = []
|
const positions: number[] = []
|
||||||
for (const [x, z] of displayPolygon) {
|
for (const [x, z] of displayPolygon) {
|
||||||
positions.push(x!, Y_OFFSET + 0.01, z!)
|
positions.push(x!, editY + 0.01, z!)
|
||||||
}
|
}
|
||||||
// Close the loop
|
// Close the loop
|
||||||
const first = displayPolygon[0]!
|
const first = displayPolygon[0]!
|
||||||
positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!)
|
positions.push(first[0]!, editY + 0.01, first[1]!)
|
||||||
|
|
||||||
const geometry = new BufferGeometry()
|
const geometry = new BufferGeometry()
|
||||||
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||||
|
|
||||||
lineRef.current.geometry.dispose()
|
lineRef.current.geometry.dispose()
|
||||||
lineRef.current.geometry = geometry
|
lineRef.current.geometry = geometry
|
||||||
}, [displayPolygon])
|
}, [displayPolygon, editY])
|
||||||
|
|
||||||
if (displayPolygon.length < minVertices) return null
|
if (displayPolygon.length < minVertices) return null
|
||||||
|
|
||||||
@@ -200,15 +238,18 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
</line>
|
</line>
|
||||||
|
|
||||||
{/* Vertex handles */}
|
{/* Vertex handles - blue cylinders that match surface height */}
|
||||||
{displayPolygon.map(([x, z], index) => {
|
{displayPolygon.map(([x, z], index) => {
|
||||||
const isHovered = hoveredVertex === index
|
const isHovered = hoveredVertex === index
|
||||||
const isDragging = dragState?.vertexIndex === index
|
const isDragging = dragState?.vertexIndex === index
|
||||||
|
const radius = 0.1
|
||||||
|
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
key={`vertex-${index}`}
|
key={`vertex-${index}`}
|
||||||
position={[x!, Y_OFFSET, z!]}
|
position={[x!, editY + height / 2, z!]}
|
||||||
|
castShadow
|
||||||
onPointerEnter={(e) => {
|
onPointerEnter={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setHoveredVertex(index)
|
setHoveredVertex(index)
|
||||||
@@ -218,6 +259,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
setHoveredVertex(null)
|
setHoveredVertex(null)
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
@@ -227,36 +269,36 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
handleDeleteVertex(index)
|
handleDeleteVertex(index)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<sphereGeometry args={[isHovered || isDragging ? 0.3 : 0.25, 16, 16]} />
|
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||||
<meshBasicMaterial
|
<meshStandardMaterial
|
||||||
color={
|
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
||||||
isDragging ? '#22c55e' : isHovered ? (canDelete ? '#ef4444' : '#ffffff') : color
|
|
||||||
}
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Midpoint handles for adding vertices (hidden while dragging) */}
|
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
||||||
{!dragState &&
|
{!dragState &&
|
||||||
midpoints.map(([x, z], index) => {
|
midpoints.map(([x, z], index) => {
|
||||||
const isHovered = hoveredMidpoint === index
|
const isHovered = hoveredMidpoint === index
|
||||||
|
const radius = 0.06
|
||||||
|
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
key={`midpoint-${index}`}
|
key={`midpoint-${index}`}
|
||||||
position={[x!, Y_OFFSET, z!]}
|
position={[x!, editY + height / 2, z!]}
|
||||||
onPointerEnter={(e) => {
|
onPointerEnter={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setHoveredMidpoint(index)
|
setHoveredMidpoint(index)
|
||||||
@@ -266,6 +308,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
setHoveredMidpoint(null)
|
setHoveredMidpoint(null)
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const newVertexIndex = handleAddVertex(index, [x!, z!])
|
const newVertexIndex = handleAddVertex(index, [x!, z!])
|
||||||
if (newVertexIndex >= 0) {
|
if (newVertexIndex >= 0) {
|
||||||
@@ -279,16 +322,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<sphereGeometry args={[isHovered ? 0.3 : 0.25, 16, 16]} />
|
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||||
<meshBasicMaterial
|
<meshStandardMaterial
|
||||||
color={isHovered ? '#22c55e' : color}
|
color={isHovered ? '#4ade80' : '#22c55e'}
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
transparent
|
transparent
|
||||||
opacity={isHovered ? 1 : 0.6}
|
opacity={isHovered ? 1 : 0.7}
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { sceneRegistry, useScene, type AnyNodeId, type SlabNode } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { PolygonEditor } from '../shared/polygon-editor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slab boundary editor - allows editing slab polygon vertices when a slab is selected
|
||||||
|
* 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)
|
||||||
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
|
|
||||||
|
// 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 handlePolygonChange = useCallback(
|
||||||
|
(newPolygon: Array<[number, number]>) => {
|
||||||
|
if (selectedSlabId) {
|
||||||
|
updateNode(selectedSlabId as SlabNode['id'], { polygon: newPolygon })
|
||||||
|
// Re-assert selection so the slab stays selected after the edit
|
||||||
|
setSelection({ selectedIds: [selectedSlabId] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedSlabId, updateNode, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!slab || !slab.polygon || slab.polygon.length < 3) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PolygonEditor
|
||||||
|
polygon={slab.polygon}
|
||||||
|
color="#a3a3a3"
|
||||||
|
onPolygonChange={handlePolygonChange}
|
||||||
|
minVertices={3}
|
||||||
|
levelY={levelY}
|
||||||
|
surfaceHeight={slab.elevation ?? 0.05}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { emitter, type GridEvent, useScene, SlabNode, type LevelNode } from "@pascal-app/core";
|
import { emitter, type GridEvent, useScene, SlabNode, type LevelNode, sceneRegistry } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Plane, Raycaster, Shape, Vector3 } from "three";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
|
|
||||||
const Y_OFFSET = 0.02; // Small offset above floor level
|
const Y_OFFSET = 0.02; // Small offset above floor level
|
||||||
|
const UP = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||||
@@ -86,11 +88,18 @@ export const SlabTool: React.FC = () => {
|
|||||||
const mainLineRef = useRef<Line>(null!);
|
const mainLineRef = useRef<Line>(null!);
|
||||||
const closingLineRef = useRef<Line>(null!);
|
const closingLineRef = useRef<Line>(null!);
|
||||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||||
const levelYRef = useRef(0); // Track current level Y position
|
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 currentLevelId = useViewer((state) => state.selection.levelId);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
const setTool = useEditor((state) => state.setTool);
|
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(), []);
|
||||||
|
|
||||||
// Preview state for reactive rendering (for shape and point markers)
|
// Preview state for reactive rendering (for shape and point markers)
|
||||||
const [preview, setPreview] = useState<PreviewState>({
|
const [preview, setPreview] = useState<PreviewState>({
|
||||||
points: [],
|
points: [],
|
||||||
@@ -98,108 +107,148 @@ export const SlabTool: React.FC = () => {
|
|||||||
levelY: 0,
|
levelY: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve the current level's Y position from scene registry or node data
|
||||||
|
const getLevelY = (): number => {
|
||||||
|
if (!currentLevelId) return 0;
|
||||||
|
const levelMesh = sceneRegistry.nodes.get(currentLevelId);
|
||||||
|
if (levelMesh) return levelMesh.position.y;
|
||||||
|
const levelNode = useScene.getState().nodes[currentLevelId];
|
||||||
|
if (levelNode && 'level' in levelNode) {
|
||||||
|
const levelMode = useViewer.getState().levelMode;
|
||||||
|
const LEVEL_HEIGHT = 2.5;
|
||||||
|
const EXPLODED_GAP = 5;
|
||||||
|
return ((levelNode as any).level || 0) * (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP : 0));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Imperatively update line geometries (called from useFrame)
|
||||||
|
const updateLines = () => {
|
||||||
|
if (!mainLineRef.current || !closingLineRef.current) return;
|
||||||
|
|
||||||
|
const points = pointsRef.current;
|
||||||
|
const cursorPosition = cursorPositionRef.current;
|
||||||
|
const y = levelYRef.current + Y_OFFSET;
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
mainLineRef.current.visible = false;
|
||||||
|
closingLineRef.current.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build main line points
|
||||||
|
const linePoints: Vector3[] = points.map(
|
||||||
|
([x, z]) => new Vector3(x, y, z)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add cursor point
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
if (lastPoint) {
|
||||||
|
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||||
|
if (isValidPoint(snapped)) {
|
||||||
|
linePoints.push(new Vector3(snapped[0], y, snapped[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update main line geometry
|
||||||
|
if (linePoints.length >= 2) {
|
||||||
|
mainLineRef.current.geometry.dispose();
|
||||||
|
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints);
|
||||||
|
mainLineRef.current.visible = true;
|
||||||
|
} else {
|
||||||
|
mainLineRef.current.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update closing line (from cursor back to first point)
|
||||||
|
const firstPoint = points[0];
|
||||||
|
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
|
||||||
|
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
||||||
|
if (isValidPoint(snapped)) {
|
||||||
|
const closingPoints = [
|
||||||
|
new Vector3(snapped[0], y, snapped[1]),
|
||||||
|
new Vector3(firstPoint[0], y, firstPoint[1]),
|
||||||
|
];
|
||||||
|
closingLineRef.current.geometry.dispose();
|
||||||
|
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints);
|
||||||
|
closingLineRef.current.visible = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
closingLineRef.current.visible = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-frame cursor positioning via direct raycasting onto the level plane.
|
||||||
|
// This bypasses R3F's event propagation (grid:move), ensuring the cursor
|
||||||
|
// always tracks the pointer regardless of tool activation/deactivation state.
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!cursorRef.current) return;
|
||||||
|
|
||||||
|
// Sync level Y from scene registry each frame
|
||||||
|
levelYRef.current = getLevelY();
|
||||||
|
|
||||||
|
// Raycast from camera through pointer onto horizontal plane at level Y
|
||||||
|
raycaster.setFromCamera(state.pointer, state.camera);
|
||||||
|
levelPlane.constant = -levelYRef.current;
|
||||||
|
const hit = raycaster.ray.intersectPlane(levelPlane, hitPoint);
|
||||||
|
if (!hit) return;
|
||||||
|
|
||||||
|
// Snap to 0.5m grid
|
||||||
|
const gridX = Math.round(hit.x * 2) / 2;
|
||||||
|
const gridZ = Math.round(hit.z * 2) / 2;
|
||||||
|
cursorPositionRef.current = [gridX, gridZ];
|
||||||
|
|
||||||
|
// Apply axis snapping from last placed point
|
||||||
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||||
|
let displayPoint: [number, number];
|
||||||
|
if (lastPoint) {
|
||||||
|
displayPoint = calculateSnapPoint(lastPoint, cursorPositionRef.current);
|
||||||
|
} else {
|
||||||
|
displayPoint = [gridX, gridZ];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor mesh position imperatively
|
||||||
|
cursorRef.current.position.set(displayPoint[0], levelYRef.current, displayPoint[1]);
|
||||||
|
|
||||||
|
// Update line geometries imperatively
|
||||||
|
updateLines();
|
||||||
|
|
||||||
|
// Only trigger React state update when snapped display position changes
|
||||||
|
if (
|
||||||
|
!lastDisplayRef.current ||
|
||||||
|
lastDisplayRef.current.x !== displayPoint[0] ||
|
||||||
|
lastDisplayRef.current.z !== displayPoint[1]
|
||||||
|
) {
|
||||||
|
lastDisplayRef.current = { x: displayPoint[0], z: displayPoint[1] };
|
||||||
|
setPreview({
|
||||||
|
points: [...pointsRef.current],
|
||||||
|
cursorPoint: displayPoint,
|
||||||
|
levelY: levelYRef.current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click and double-click handlers for point placement
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentLevelId) return;
|
if (!currentLevelId) return;
|
||||||
|
|
||||||
let cursorPosition: [number, number] = [0, 0];
|
|
||||||
|
|
||||||
// Initialize line geometries
|
// Initialize line geometries
|
||||||
mainLineRef.current.geometry = new BufferGeometry();
|
if (mainLineRef.current) {
|
||||||
closingLineRef.current.geometry = new BufferGeometry();
|
mainLineRef.current.geometry = new BufferGeometry();
|
||||||
|
}
|
||||||
|
if (closingLineRef.current) {
|
||||||
|
closingLineRef.current.geometry = new BufferGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
const updateLines = () => {
|
// Reset state on level change
|
||||||
const points = pointsRef.current;
|
pointsRef.current = [];
|
||||||
const y = levelYRef.current + Y_OFFSET;
|
lastDisplayRef.current = null;
|
||||||
|
setPreview({ points: [], cursorPoint: null, levelY: getLevelY() });
|
||||||
|
|
||||||
if (points.length === 0) {
|
const onGridClick = (_event: GridEvent) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePreview = () => {
|
|
||||||
const points = pointsRef.current;
|
|
||||||
const lastPoint = points[points.length - 1];
|
|
||||||
|
|
||||||
let cursorPt: [number, number] | null = null;
|
|
||||||
if (lastPoint) {
|
|
||||||
cursorPt = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
} else if (points.length === 0) {
|
|
||||||
cursorPt = cursorPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current });
|
|
||||||
updateLines();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
|
||||||
if (!cursorRef.current) return;
|
|
||||||
|
|
||||||
// Snap to 0.5 grid
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
|
||||||
cursorPosition = [gridX, gridZ];
|
|
||||||
levelYRef.current = event.position[1];
|
|
||||||
|
|
||||||
// If we have points, snap to axis from last point
|
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
|
||||||
if (lastPoint) {
|
|
||||||
const snapped = calculateSnapPoint(lastPoint, cursorPosition);
|
|
||||||
cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]);
|
|
||||||
} else {
|
|
||||||
cursorRef.current.position.set(gridX, event.position[1], gridZ);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
|
||||||
if (!currentLevelId) return;
|
if (!currentLevelId) return;
|
||||||
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2;
|
// Use the cursor position tracked by useFrame (matches what user sees)
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2;
|
let clickPoint: [number, number] = [...cursorPositionRef.current];
|
||||||
let clickPoint: [number, number] = [gridX, gridZ];
|
|
||||||
|
|
||||||
// Snap to axis from last point
|
// Snap to axis from last point
|
||||||
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
const lastPoint = pointsRef.current[pointsRef.current.length - 1];
|
||||||
@@ -221,13 +270,14 @@ export const SlabTool: React.FC = () => {
|
|||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
pointsRef.current = [];
|
pointsRef.current = [];
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
lastDisplayRef.current = null;
|
||||||
mainLineRef.current.visible = false;
|
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||||
closingLineRef.current.visible = false;
|
if (mainLineRef.current) mainLineRef.current.visible = false;
|
||||||
|
if (closingLineRef.current) closingLineRef.current.visible = false;
|
||||||
} else {
|
} else {
|
||||||
// Add point to polygon
|
// Add point to polygon
|
||||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||||
updatePreview();
|
lastDisplayRef.current = null; // Force preview update on next frame
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,19 +292,17 @@ export const SlabTool: React.FC = () => {
|
|||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
pointsRef.current = [];
|
pointsRef.current = [];
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
lastDisplayRef.current = null;
|
||||||
mainLineRef.current.visible = false;
|
setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current });
|
||||||
closingLineRef.current.visible = false;
|
if (mainLineRef.current) mainLineRef.current.visible = false;
|
||||||
|
if (closingLineRef.current) closingLineRef.current.visible = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscribe to events
|
|
||||||
emitter.on("grid:move", onGridMove);
|
|
||||||
emitter.on("grid:click", onGridClick);
|
emitter.on("grid:click", onGridClick);
|
||||||
emitter.on("grid:double-click", onGridDoubleClick);
|
emitter.on("grid:double-click", onGridDoubleClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off("grid:move", onGridMove);
|
|
||||||
emitter.off("grid:click", onGridClick);
|
emitter.off("grid:click", onGridClick);
|
||||||
emitter.off("grid:double-click", onGridDoubleClick);
|
emitter.off("grid:double-click", onGridDoubleClick);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import useEditor, { type Phase, type Tool } from "@/store/use-editor";
|
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 { useViewer } from "@pascal-app/viewer";
|
||||||
import { CeilingTool } from "./ceiling/ceiling-tool";
|
import { CeilingTool } from "./ceiling/ceiling-tool";
|
||||||
import { ItemTool } from "./item/item-tool";
|
import { ItemTool } from "./item/item-tool";
|
||||||
import { MoveTool } from "./item/move-tool";
|
import { MoveTool } from "./item/move-tool";
|
||||||
import { RoofTool } from "./roof/roof-tool";
|
import { RoofTool } from "./roof/roof-tool";
|
||||||
import { SiteBoundaryEditor } from "./site/site-boundary-editor";
|
import { SiteBoundaryEditor } from "./site/site-boundary-editor";
|
||||||
|
import { SlabBoundaryEditor } from "./slab/slab-boundary-editor";
|
||||||
import { SlabTool } from "./slab/slab-tool";
|
import { SlabTool } from "./slab/slab-tool";
|
||||||
import { WallTool } from "./wall/wall-tool";
|
import { WallTool } from "./wall/wall-tool";
|
||||||
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
|
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
|
||||||
@@ -33,13 +35,23 @@ export const ToolManager: React.FC = () => {
|
|||||||
const tool = useEditor((state) => state.tool);
|
const tool = useEditor((state) => state.tool);
|
||||||
const movingNode = useEditor((state) => state.movingNode);
|
const movingNode = useEditor((state) => state.movingNode);
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
||||||
|
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||||
|
const nodes = useScene((state) => state.nodes);
|
||||||
|
|
||||||
|
// Check if a slab is selected
|
||||||
|
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === "slab") ?? null;
|
||||||
|
|
||||||
// Show site boundary editor when in site phase and edit mode
|
// Show site boundary editor when in site phase and edit mode
|
||||||
const showSiteBoundaryEditor = phase === "site" && mode === "edit";
|
const showSiteBoundaryEditor = phase === "site" && mode === "edit";
|
||||||
|
|
||||||
|
// Show slab boundary editor when in structure/select mode with a slab selected
|
||||||
|
const showSlabBoundaryEditor =
|
||||||
|
phase === "structure" && mode === "select" && selectedSlabId !== null;
|
||||||
|
|
||||||
// Show zone boundary editor when in structure/select mode with a zone selected
|
// Show zone boundary editor when in structure/select mode with a zone selected
|
||||||
|
// Hide when editing a slab to avoid overlapping handles
|
||||||
const showZoneBoundaryEditor =
|
const showZoneBoundaryEditor =
|
||||||
phase === "structure" && mode === "select" && selectedZoneId !== null;
|
phase === "structure" && mode === "select" && selectedZoneId !== null && !showSlabBoundaryEditor;
|
||||||
|
|
||||||
// Show build tools when in build mode
|
// Show build tools when in build mode
|
||||||
const showBuildTool = mode === "build" && tool !== null;
|
const showBuildTool = mode === "build" && tool !== null;
|
||||||
@@ -50,6 +62,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{showZoneBoundaryEditor && <ZoneBoundaryEditor />}
|
{showZoneBoundaryEditor && <ZoneBoundaryEditor />}
|
||||||
|
{showSlabBoundaryEditor && <SlabBoundaryEditor />}
|
||||||
{movingNode && <MoveTool />}
|
{movingNode && <MoveTool />}
|
||||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user