diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 33db7f78..4d3aeca8 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -21,7 +21,7 @@ const barlow = Barlow({ }) export const metadata: Metadata = { - title: 'Pascal Editor', + title: 'Pascal Editor Live', description: 'Standalone building editor', } diff --git a/apps/editor/public/icons/measure.svg b/apps/editor/public/icons/measure.svg new file mode 100644 index 00000000..9dfaebab --- /dev/null +++ b/apps/editor/public/icons/measure.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx index 45184b49..86a0efe1 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -19,6 +19,14 @@ const _quaternion = new THREE.Quaternion() const _scale = new THREE.Vector3(1, 1, 1) const _yAxis = new THREE.Vector3(0, 1, 0) +function createEmptyRoofGeometry() { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.computeBoundsTree = computeBoundsTree + geometry.computeBoundsTree({ maxLeafSize: 10 }) + return geometry +} + // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() const MAX_ROOFS_PER_FRAME = 1 @@ -68,11 +76,7 @@ export const RoofSystem = () => { // so MeshBVH hits groups[4].materialIndex → undefined.side → crash. if (mesh.geometry.type === 'BoxGeometry') { mesh.geometry.dispose() - const placeholder = new THREE.BufferGeometry() - placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) - placeholder.computeBoundsTree = computeBoundsTree - placeholder.computeBoundsTree({ maxLeafSize: 10 }) - mesh.geometry = placeholder + mesh.geometry = createEmptyRoofGeometry() } mesh.position.set(node.position[0], node.position[1], node.position[2]) mesh.rotation.y = node.rotation @@ -145,8 +149,7 @@ function updateMergedRoofGeometry( if (children.length === 0) { mergedMesh.geometry.dispose() - // Keep a valid position attribute so Drei's BVH can index safely. - mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0) + mergedMesh.geometry = createEmptyRoofGeometry() return } diff --git a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx b/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx index 1d296f28..2c8e48a5 100644 --- a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx +++ b/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx @@ -1,11 +1,33 @@ -import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' +import { + CeilingNode, + emitter, + type GridEvent, + type LevelNode, + useScene, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { mix, positionLocal } from 'three/tsl' import { EDITOR_LAYER } from '../../../lib/constants' +import { formatLengthInputValue, getLengthInputUnitLabel } from '../../../lib/measurements' import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + CLOSE_LOOP_TOLERANCE, + formatDistance, + getPlanDistance, + getPlanMidpoint, + getSegmentSnapPoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapToGrid, +} from '../shared/drawing-utils' const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 @@ -13,10 +35,7 @@ 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], -): [number, number] => { +const calculateSnapPoint = (lastPoint: PlanPoint, currentPoint: PlanPoint): PlanPoint => { const [x1, y1] = lastPoint const [x, y] = currentPoint @@ -25,37 +44,27 @@ const calculateSnapPoint = ( 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) - - // Find the minimum distance to determine which axis to snap to 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] } if (minDist === horizontalDist) { - // Snap to horizontal return [x, y1] } - // Snap to vertical return [x1, y] } /** * Creates a ceiling with the given polygon points and returns its ID */ -const commitCeilingDrawing = ( - levelId: LevelNode['id'], - points: Array<[number, number]>, -): string => { +const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array): string => { 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}` @@ -70,6 +79,9 @@ const commitCeilingDrawing = ( } export const CeilingTool: React.FC = () => { + const measurementGuides = useEditor((state) => state.measurementGuides) + const unitSystem = useViewer((state) => state.unitSystem) + const showGuides = useViewer((state) => state.showGuides) const cursorRef = useRef(null) const gridCursorRef = useRef(null) const mainLineRef = useRef(null!) @@ -80,14 +92,19 @@ export const CeilingTool: React.FC = () => { const currentLevelId = useViewer((state) => state.selection.levelId) const setSelection = useViewer((state) => state.setSelection) - const [points, setPoints] = useState>([]) - const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) - const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) + const [points, setPoints] = useState>([]) + const [snappedCursorPosition, setSnappedCursorPosition] = useState([0, 0]) const [levelY, setLevelY] = useState(0) - const previousSnappedPointRef = useRef<[number, number] | null>(null) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) + const previousSnappedPointRef = useRef(null) const shiftPressed = useRef(false) + const pointsRef = useRef>([]) + const cursorPositionRef = useRef([0, 0]) + const snappedCursorPositionRef = useRef([0, 0]) + const levelYRef = useRef(0) + const inputOpenRef = useRef(false) + const ignoreNextGridClickRef = useRef(false) - // Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY const verticalGeo = useMemo( () => new BufferGeometry().setFromPoints([ @@ -97,40 +114,134 @@ export const CeilingTool: React.FC = () => { [], ) - // opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0 const gradientOpacityNode = useMemo( () => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()), [], ) - // Update cursor position and lines on grid move + const updatePoints = useCallback((nextPoints: Array) => { + pointsRef.current = nextPoints + setPoints(nextPoints) + }, []) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const clearDraft = useCallback(() => { + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() + }, [closeDistanceInput, updatePoints]) + + const commitDraftPoint = useCallback( + (point: PlanPoint) => { + if (!currentLevelId) return + + const firstPoint = pointsRef.current[0] + if ( + pointsRef.current.length >= 3 && + firstPoint && + Math.abs(point[0] - firstPoint[0]) < CLOSE_LOOP_TOLERANCE && + Math.abs(point[1] - firstPoint[1]) < CLOSE_LOOP_TOLERANCE + ) { + const ceilingId = commitCeilingDrawing(currentLevelId, pointsRef.current) + setSelection({ selectedIds: [ceilingId] }) + clearDraft() + return + } + + updatePoints([...pointsRef.current, point]) + }, + [clearDraft, currentLevelId, setSelection, updatePoints], + ) + + const applyDistanceInput = ( + rawValue: string, + options?: { commitAfterApply?: boolean; ignoreNextGridClick?: boolean }, + ) => { + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) { + closeDistanceInput(options) + return + } + + const parsedDistance = parseDistanceInput(rawValue, unitSystem) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } + + const projected = projectPointAtDistance( + lastPoint, + snappedCursorPositionRef.current, + parsedDistance, + ) + cursorPositionRef.current = projected + snappedCursorPositionRef.current = projected + previousSnappedPointRef.current = projected + setSnappedCursorPosition(projected) + + const ceilingY = levelYRef.current + CEILING_HEIGHT + const gridY = levelYRef.current + GRID_OFFSET + cursorRef.current?.position.set(projected[0], ceilingY, projected[1]) + gridCursorRef.current?.position.set(projected[0], gridY, projected[1]) + verticalLineRef.current?.position.set(projected[0], gridY, projected[1]) + + if (options?.commitAfterApply) { + closeDistanceInput() + commitDraftPoint(projected) + return + } + + closeDistanceInput(options) + } + useEffect(() => { if (!currentLevelId) return + const getLevelWalls = () => + Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === currentLevelId, + ) + const getSnapSegments = () => [ + ...getLevelWalls(), + ...(showGuides + ? measurementGuides + .filter((guide) => guide.levelId === currentLevelId) + .map((guide) => ({ start: guide.start, end: guide.end })) + : []), + ] + const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && gridCursorRef.current)) return + if (inputOpenRef.current) return - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 - const gridPosition: [number, number] = [gridX, gridZ] + const gridPosition: PlanPoint = [snapToGrid(event.position[0]), snapToGrid(event.position[2])] - setCursorPosition(gridPosition) + cursorPositionRef.current = gridPosition + levelYRef.current = event.position[1] setLevelY(event.position[1]) - const ceilingY = event.position[1] + CEILING_HEIGHT - const gridY = event.position[1] + GRID_OFFSET - - // Calculate snapped display position (bypass snap when Shift is held) - const lastPoint = points[points.length - 1] - const displayPoint = + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + const basePoint = shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) + const displayPoint = shiftPressed.current + ? basePoint + : (getSegmentSnapPoint(basePoint, getSnapSegments()) ?? basePoint) + + snappedCursorPositionRef.current = displayPoint setSnappedCursorPosition(displayPoint) - // Play snap sound when the snapped position actually changes (only when drawing) if ( - points.length > 0 && + pointsRef.current.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1]) @@ -139,62 +250,75 @@ export const CeilingTool: React.FC = () => { } previousSnappedPointRef.current = displayPoint + + const ceilingY = event.position[1] + CEILING_HEIGHT + const gridY = event.position[1] + GRID_OFFSET cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1]) gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) - - if (verticalLineRef.current) { - verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) - } + verticalLineRef.current?.position.set(displayPoint[0], gridY, displayPoint[1]) } const onGridClick = (_event: GridEvent) => { if (!currentLevelId) return - - // Use the last displayed snapped position (respects Shift state from onGridMove) - const clickPoint = previousSnappedPointRef.current ?? cursorPosition - - // Check if clicking on the first point to close the shape - const firstPoint = points[0] - if ( - points.length >= 3 && - firstPoint && - Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && - Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 - ) { - // Create the ceiling and select it - const ceilingId = commitCeilingDrawing(currentLevelId, points) - setSelection({ selectedIds: [ceilingId] }) - setPoints([]) - } else { - // Add point to polygon - setPoints([...points, clickPoint]) + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return } + if (inputOpenRef.current) return + + const clickPoint = previousSnappedPointRef.current ?? cursorPositionRef.current + commitDraftPoint(clickPoint) } const onGridDoubleClick = (_event: GridEvent) => { if (!currentLevelId) return - // Need at least 3 points to form a polygon - if (points.length >= 3) { - const ceilingId = commitCeilingDrawing(currentLevelId, points) + if (pointsRef.current.length >= 3) { + const ceilingId = commitCeilingDrawing(currentLevelId, pointsRef.current) setSelection({ selectedIds: [ceilingId] }) - setPoints([]) + clearDraft() } } const onCancel = () => { - setPoints([]) + clearDraft() } - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + + if (event.key === 'Shift') { + shiftPressed.current = true + return + } + + if (event.key !== 'Tab' || pointsRef.current.length === 0) return + + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) return + + const currentDistance = getPlanDistance(lastPoint, snappedCursorPositionRef.current) + if (currentDistance < MIN_DRAW_DISTANCE) return + + event.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: formatLengthInputValue(currentDistance, unitSystem), + }) } - const onKeyUp = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = false + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressed.current = false + } } + document.addEventListener('keydown', onKeyDown) document.addEventListener('keyup', onKeyUp) - emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('grid:double-click', onGridDoubleClick) @@ -208,22 +332,30 @@ export const CeilingTool: React.FC = () => { emitter.off('grid:double-click', onGridDoubleClick) emitter.off('tool:cancel', onCancel) } - }, [currentLevelId, points, cursorPosition, setSelection]) + }, [ + clearDraft, + commitDraftPoint, + currentLevelId, + measurementGuides, + setSelection, + showGuides, + unitSystem, + ]) - // Update line geometries when points change useEffect(() => { if (!(mainLineRef.current && closingLineRef.current)) return if (points.length === 0) { mainLineRef.current.visible = false closingLineRef.current.visible = false + groundMainLineRef.current.visible = false + groundClosingLineRef.current.visible = false return } const ceilingY = levelY + CEILING_HEIGHT const snappedCursor = snappedCursorPosition - // 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])) @@ -231,7 +363,6 @@ export const CeilingTool: React.FC = () => { const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z)) groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1])) - // Update main line if (linePoints.length >= 2) { mainLineRef.current.geometry.dispose() mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) @@ -245,7 +376,6 @@ export const CeilingTool: React.FC = () => { groundMainLineRef.current.visible = false } - // Update closing line (from cursor back to first point) const firstPoint = points[0] if (points.length >= 2 && firstPoint) { const closingPoints = [ @@ -269,19 +399,12 @@ export const CeilingTool: React.FC = () => { closingLineRef.current.visible = false groundClosingLineRef.current.visible = false } - }, [points, snappedCursorPosition, levelY]) + }, [levelY, points, snappedCursorPosition]) - // Create preview shape when we have 3+ points const previewShape = useMemo(() => { if (points.length < 3) return null - const snappedCursor = snappedCursorPosition - - 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 allPoints = [...points, snappedCursorPosition] const firstPt = allPoints[0] if (!firstPt) return null @@ -299,12 +422,23 @@ export const CeilingTool: React.FC = () => { return shape }, [points, snappedCursorPosition]) + const currentSegment = useMemo(() => { + const lastPoint = points[points.length - 1] + if (!lastPoint) return null + + const distance = getPlanDistance(lastPoint, snappedCursorPosition) + if (distance < MIN_DRAW_DISTANCE) return null + + return { + distance, + midpoint: getPlanMidpoint(lastPoint, snappedCursorPosition), + } + }, [points, snappedCursorPosition]) + return ( - {/* Cursor at ceiling height */} - {/* Grid-level cursor indicator */} { /> - {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */} {/* @ts-ignore */} { /> - {/* Preview fill (Top) */} {previewShape && ( { )} - {/* Preview fill (Ground) */} {previewShape && ( { )} - {/* Main line */} {/* @ts-ignore */} { - {/* Closing line */} {/* @ts-ignore */} { /> - {/* Ground main line */} {/* @ts-ignore */} { /> - {/* Ground closing line */} {/* @ts-ignore */} { /> - {/* Point markers */} {points.map(([x, z], index) => ( { showTooltip={false} /> ))} + + {currentSegment && ( + { + if (!inputOpenRef.current) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value, { commitAfterApply: true }) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={[ + currentSegment.midpoint[0], + levelY + CEILING_HEIGHT + 0.18, + currentSegment.midpoint[1], + ]} + value={formatDistance(currentSegment.distance, unitSystem)} + /> + )} ) } diff --git a/packages/editor/src/components/tools/door/door-tool.tsx b/packages/editor/src/components/tools/door/door-tool.tsx index 67e45831..169a82ce 100644 --- a/packages/editor/src/components/tools/door/door-tool.tsx +++ b/packages/editor/src/components/tools/door/door-tool.tsx @@ -18,7 +18,7 @@ import { calculateItemRotation, getSideFromNormal, isValidWallSideFace, - snapToHalf, + snapToInch, } from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -94,7 +94,7 @@ export const DoorTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) + const localX = snapToInch(event.localPosition[0]) const width = 0.9 const height = 2.1 @@ -136,7 +136,7 @@ export const DoorTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) + const localX = snapToInch(event.localPosition[0]) const width = draftRef.current?.width ?? 0.9 const height = draftRef.current?.height ?? 2.1 @@ -183,7 +183,7 @@ export const DoorTool: React.FC = () => { const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const localX = snapToHalf(event.localPosition[0]) + const localX = snapToInch(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( event.node, localX, diff --git a/packages/editor/src/components/tools/door/move-door-tool.tsx b/packages/editor/src/components/tools/door/move-door-tool.tsx index 31de62ad..26716eed 100644 --- a/packages/editor/src/components/tools/door/move-door-tool.tsx +++ b/packages/editor/src/components/tools/door/move-door-tool.tsx @@ -19,7 +19,7 @@ import { calculateItemRotation, getSideFromNormal, isValidWallSideFace, - snapToHalf, + snapToInch, } from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' @@ -104,7 +104,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) + const localX = snapToInch(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -157,7 +157,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) + const localX = snapToInch(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -209,7 +209,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const localX = snapToHalf(event.localPosition[0]) + const localX = snapToInch(event.localPosition[0]) const { clampedX, clampedY } = clampToWall( event.node, localX, diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 2895de32..74c0a289 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { isObject } from '@pascal-app/core' +import { METERS_PER_INCH } from '../../../lib/measurements' /** * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines. @@ -19,6 +20,13 @@ export function snapToHalf(value: number): number { return Math.round(value * 2) / 2 } +/** + * Snap a value to one-inch increments for precise wall-hosted placement. + */ +export function snapToInch(value: number): number { + return Math.round(value / METERS_PER_INCH) * METERS_PER_INCH +} + /** * Calculate cursor rotation in WORLD space from wall normal and orientation. */ diff --git a/packages/editor/src/components/tools/measure/measure-tool.tsx b/packages/editor/src/components/tools/measure/measure-tool.tsx new file mode 100644 index 00000000..df06fa6c --- /dev/null +++ b/packages/editor/src/components/tools/measure/measure-tool.tsx @@ -0,0 +1,397 @@ +import { emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { BufferGeometry, type Group, type Line, Vector3 } from 'three' +import { EDITOR_LAYER } from '../../../lib/constants' +import { formatLengthInputValue, getLengthInputUnitLabel } from '../../../lib/measurements' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + formatDistance, + getPlanDistance, + getPlanMidpoint, + getSegmentSnapPoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapSegmentTo45Degrees, + snapToGrid, +} from '../shared/drawing-utils' + +type MeasureState = { + start: PlanPoint | null + end: PlanPoint | null + isLocked: boolean + levelY: number +} + +const syncLineGeometry = ( + line: Line, + start: PlanPoint | null, + end: PlanPoint | null, + y: number, +) => { + if (!(start && end)) { + line.visible = false + return + } + + if (getPlanDistance(start, end) < MIN_DRAW_DISTANCE) { + line.visible = false + return + } + + const points = [new Vector3(start[0], y + 0.02, start[1]), new Vector3(end[0], y + 0.02, end[1])] + + line.geometry.dispose() + line.geometry = new BufferGeometry().setFromPoints(points) + line.visible = true +} + +export const MeasureTool: React.FC = () => { + const addMeasurementGuide = useEditor((state) => state.addMeasurementGuide) + const currentLevelId = useViewer((state) => state.selection.levelId) + const measurementGuides = useEditor((state) => state.measurementGuides) + const showGuides = useViewer((state) => state.showGuides) + const unitSystem = useViewer((state) => state.unitSystem) + const cursorRef = useRef(null) + const lineRef = useRef(null!) + const startRef = useRef(null) + const endRef = useRef(null) + const isLockedRef = useRef(false) + const shiftPressed = useRef(false) + const previousEndRef = useRef(null) + const inputOpenRef = useRef(false) + const levelYRef = useRef(0) + const ignoreNextGridClickRef = useRef(false) + + const [measurement, setMeasurement] = useState({ + start: null, + end: null, + isLocked: false, + levelY: 0, + }) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const syncMeasurementState = useCallback((levelY: number) => { + levelYRef.current = levelY + setMeasurement({ + start: startRef.current, + end: endRef.current, + isLocked: isLockedRef.current, + levelY, + }) + }, []) + + const lockMeasurement = useCallback( + (levelY: number) => { + if (!(startRef.current && endRef.current)) return + if (getPlanDistance(startRef.current, endRef.current) < MIN_DRAW_DISTANCE) return + + if (currentLevelId) { + addMeasurementGuide({ + end: endRef.current, + levelId: currentLevelId, + levelY, + start: startRef.current, + }) + } + + const finalEnd = endRef.current + cursorRef.current?.position.set(finalEnd[0], levelY, finalEnd[1]) + startRef.current = null + endRef.current = null + isLockedRef.current = false + previousEndRef.current = null + ignoreNextGridClickRef.current = false + if (lineRef.current.geometry) { + lineRef.current.visible = false + } + setMeasurement({ start: null, end: null, isLocked: false, levelY }) + }, + [addMeasurementGuide, currentLevelId], + ) + + const applyDistanceInput = ( + rawValue: string, + options?: { commitAfterApply?: boolean; ignoreNextGridClick?: boolean }, + ) => { + if (!(startRef.current && endRef.current)) { + closeDistanceInput(options) + return + } + + const parsedDistance = parseDistanceInput(rawValue, unitSystem) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } + + const nextEnd = projectPointAtDistance(startRef.current, endRef.current, parsedDistance) + endRef.current = nextEnd + previousEndRef.current = nextEnd + cursorRef.current?.position.set(nextEnd[0], levelYRef.current, nextEnd[1]) + syncLineGeometry(lineRef.current, startRef.current, nextEnd, levelYRef.current) + syncMeasurementState(levelYRef.current) + + if (options?.commitAfterApply) { + closeDistanceInput() + lockMeasurement(levelYRef.current) + return + } + + closeDistanceInput(options) + } + + useEffect(() => { + lineRef.current.geometry = new BufferGeometry() + const getLevelWalls = () => + Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === currentLevelId, + ) + const getSnapSegments = () => [ + ...getLevelWalls(), + ...(showGuides + ? measurementGuides + .filter((guide) => guide.levelId === currentLevelId) + .map((guide) => ({ start: guide.start, end: guide.end })) + : []), + ] + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return + + const levelY = event.position[1] + const rawGridPosition: PlanPoint = [ + snapToGrid(event.position[0]), + snapToGrid(event.position[2]), + ] + const gridPosition = + shiftPressed.current || !currentLevelId + ? rawGridPosition + : (getSegmentSnapPoint(rawGridPosition, getSnapSegments()) ?? rawGridPosition) + + if (!(startRef.current && !isLockedRef.current)) { + cursorRef.current.position.set(gridPosition[0], levelY, gridPosition[1]) + return + } + + if (inputOpenRef.current) return + + const angleSnapped = shiftPressed.current + ? gridPosition + : snapSegmentTo45Degrees(startRef.current, gridPosition) + const nextEnd = + shiftPressed.current || !currentLevelId + ? angleSnapped + : (getSegmentSnapPoint(angleSnapped, getSnapSegments()) ?? angleSnapped) + + if ( + previousEndRef.current && + (nextEnd[0] !== previousEndRef.current[0] || nextEnd[1] !== previousEndRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + + previousEndRef.current = nextEnd + endRef.current = nextEnd + cursorRef.current.position.set(nextEnd[0], levelY, nextEnd[1]) + syncLineGeometry(lineRef.current, startRef.current, nextEnd, levelY) + syncMeasurementState(levelY) + } + + const onGridClick = (event: GridEvent) => { + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return + } + if (inputOpenRef.current) return + + const levelY = event.position[1] + const rawGridPosition: PlanPoint = [ + snapToGrid(event.position[0]), + snapToGrid(event.position[2]), + ] + const gridPosition = + shiftPressed.current || !currentLevelId + ? rawGridPosition + : (getSegmentSnapPoint(rawGridPosition, getSnapSegments()) ?? rawGridPosition) + + if (!startRef.current || isLockedRef.current) { + startRef.current = gridPosition + endRef.current = gridPosition + isLockedRef.current = false + previousEndRef.current = gridPosition + cursorRef.current?.position.set(gridPosition[0], levelY, gridPosition[1]) + syncLineGeometry(lineRef.current, null, null, levelY) + syncMeasurementState(levelY) + return + } + + const finalEnd = endRef.current ?? gridPosition + endRef.current = finalEnd + lockMeasurement(levelY) + } + + const onCancel = () => { + startRef.current = null + endRef.current = null + isLockedRef.current = false + previousEndRef.current = null + ignoreNextGridClickRef.current = false + closeDistanceInput() + if (lineRef.current.geometry) { + lineRef.current.visible = false + } + setMeasurement({ start: null, end: null, isLocked: false, levelY: 0 }) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + + if (event.key === 'Shift') { + shiftPressed.current = true + return + } + + if (event.key !== 'Tab') return + if (!(startRef.current && endRef.current && !isLockedRef.current)) return + + const currentDistance = getPlanDistance(startRef.current, endRef.current) + if (currentDistance < MIN_DRAW_DISTANCE) return + + event.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: formatLengthInputValue(currentDistance, unitSystem), + }) + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressed.current = false + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + closeDistanceInput() + } + }, [ + closeDistanceInput, + currentLevelId, + lockMeasurement, + measurementGuides, + showGuides, + syncMeasurementState, + unitSystem, + ]) + + const currentDistance = useMemo(() => { + if (!(measurement.start && measurement.end)) return 0 + return getPlanDistance(measurement.start, measurement.end) + }, [measurement.end, measurement.start]) + + const labelPosition = useMemo(() => { + if (!(measurement.start && measurement.end)) return null + const midpoint = getPlanMidpoint(measurement.start, measurement.end) + return [midpoint[0], measurement.levelY + 0.18, midpoint[1]] as [number, number, number] + }, [measurement.end, measurement.levelY, measurement.start]) + + return ( + + + + {/* @ts-ignore R3F line type mismatches DOM line typing */} + + + + + + {measurement.start && ( + + )} + + {measurement.isLocked && measurement.end && ( + + )} + + {labelPosition && currentDistance >= MIN_DRAW_DISTANCE && ( + { + if (!inputOpenRef.current) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value, { commitAfterApply: true }) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={labelPosition} + value={formatDistance(currentDistance, unitSystem)} + /> + )} + + ) +} diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index ee6c5022..93f1e03e 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -4,6 +4,7 @@ import { type GridEvent, type LevelNode, RoofNode, + RoofSegmentNode, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -11,12 +12,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' // Default roof dimensions const DEFAULT_HEIGHT = 1.5 -const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 /** @@ -27,7 +26,7 @@ const commitRoofPlacement = ( corner1: [number, number, number], corner2: [number, number, number], ): RoofNode['id'] => { - const { createNode, nodes } = useScene.getState() + const { createNodes, nodes } = useScene.getState() // Calculate center position and dimensions from corners const centerX = (corner1[0] + corner2[0]) / 2 @@ -36,9 +35,6 @@ const commitRoofPlacement = ( const length = Math.abs(corner2[0] - corner1[0]) const width = Math.abs(corner2[2] - corner1[2]) - // Split width evenly between left and right slopes - const slopeWidth = Math.max(width / 2, 0.5) - // Count existing roofs for naming const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length const name = `Roof ${roofCount + 1}` @@ -46,13 +42,21 @@ const commitRoofPlacement = ( const roof = RoofNode.parse({ name, position: [centerX, 0, centerZ], // Y is always 0 - length: Math.max(length, 0.5), - height: DEFAULT_HEIGHT, - leftWidth: slopeWidth, - rightWidth: slopeWidth, }) - createNode(roof, levelId) + const segment = RoofSegmentNode.parse({ + position: [0, 0, 0], + roofType: 'gable', + width: Math.max(length, 0.5), + depth: Math.max(width, 0.5), + wallHeight: 0, + roofHeight: DEFAULT_HEIGHT, + }) + + createNodes([ + { node: roof, parentId: levelId }, + { node: segment, parentId: roof.id }, + ]) sfxEmitter.emit('sfx:structure-build') return roof.id } @@ -68,8 +72,6 @@ export const RoofTool: React.FC = () => { const outlineRef = useRef(null!) const currentLevelId = useViewer((state) => state.selection.levelId) const setSelection = useViewer((state) => state.setSelection) - const setTool = useEditor((state) => state.setTool) - const setMode = useEditor((state) => state.setMode) const corner1Ref = useRef<[number, number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) @@ -190,7 +192,7 @@ export const RoofTool: React.FC = () => { // Reset state on unmount corner1Ref.current = null } - }, [currentLevelId, setTool, setSelection, setMode]) + }, [currentLevelId, setSelection]) const { corner1, cursorPosition, levelY } = preview diff --git a/packages/editor/src/components/tools/shared/drawing-dimension-label.tsx b/packages/editor/src/components/tools/shared/drawing-dimension-label.tsx new file mode 100644 index 00000000..7d0d52e0 --- /dev/null +++ b/packages/editor/src/components/tools/shared/drawing-dimension-label.tsx @@ -0,0 +1,81 @@ +import { Html } from '@react-three/drei' +import { useEffect, useRef } from 'react' + +interface DrawingDimensionLabelProps { + position: [number, number, number] + value: string + isEditing?: boolean + inputValue?: string + inputLabel?: string + inputUnitLabel?: string + hint?: string + onInputBlur?: () => void + onInputChange?: (value: string) => void + onInputKeyDown?: (event: React.KeyboardEvent) => void +} + +export function DrawingDimensionLabel({ + position, + value, + isEditing = false, + inputValue = '', + inputLabel = 'Distance', + inputUnitLabel = 'm', + hint = 'Enter to apply', + onInputBlur, + onInputChange, + onInputKeyDown, +}: DrawingDimensionLabelProps) { + const inputRef = useRef(null) + + useEffect(() => { + if (!isEditing) return + + const id = requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + + return () => cancelAnimationFrame(id) + }, [isEditing]) + + return ( + +
{ + event.stopPropagation() + }} + > + {isEditing ? ( +
+
+ {inputLabel} +
+
+ onInputChange?.(event.target.value)} + onKeyDown={onInputKeyDown} + ref={inputRef} + type="text" + value={inputValue} + /> + {inputUnitLabel} +
+
{hint}
+
+ ) : ( +
{value}
+ )} +
+ + ) +} diff --git a/packages/editor/src/components/tools/shared/drawing-utils.ts b/packages/editor/src/components/tools/shared/drawing-utils.ts new file mode 100644 index 00000000..9a4b1d85 --- /dev/null +++ b/packages/editor/src/components/tools/shared/drawing-utils.ts @@ -0,0 +1,142 @@ +import type { WallNode } from '@pascal-app/core' +import { + formatLength, + METERS_PER_INCH, + parseLengthInput, + type UnitSystem, +} from '../../../lib/measurements' + +export type PlanPoint = [number, number] +export type SnapSegment = { + start: PlanPoint + end: PlanPoint +} + +export const GRID_STEP = METERS_PER_INCH +export const MIN_DRAW_DISTANCE = 0.01 +export const CLOSE_LOOP_TOLERANCE = GRID_STEP * 4 +export const WALL_SNAP_DISTANCE = METERS_PER_INCH * 6 + +export const snapToGrid = (value: number) => Math.round(value / GRID_STEP) * GRID_STEP + +export const getPlanDistance = (start: PlanPoint, end: PlanPoint) => { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + return Math.hypot(dx, dz) +} + +export const getPlanMidpoint = (start: PlanPoint, end: PlanPoint): PlanPoint => [ + (start[0] + end[0]) / 2, + (start[1] + end[1]) / 2, +] + +export const projectPointAtDistance = ( + start: PlanPoint, + target: PlanPoint, + distance: number, +): PlanPoint => { + const dx = target[0] - start[0] + const dz = target[1] - start[1] + const length = Math.hypot(dx, dz) + + if (length < MIN_DRAW_DISTANCE) { + return [start[0] + distance, start[1]] + } + + const unitX = dx / length + const unitZ = dz / length + + return [start[0] + unitX * distance, start[1] + unitZ * distance] +} + +export const formatDistance = (distance: number, unitSystem: UnitSystem) => + formatLength(distance, unitSystem, { compact: unitSystem === 'metric' }) + +export const parseDistanceInput = (value: string, unitSystem: UnitSystem) => + parseLengthInput(value, unitSystem) + +const getDistanceSquared = (a: PlanPoint, b: PlanPoint) => { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +const getClosestPointOnSegment = ( + point: PlanPoint, + segmentStart: PlanPoint, + segmentEnd: PlanPoint, +): PlanPoint => { + const dx = segmentEnd[0] - segmentStart[0] + const dz = segmentEnd[1] - segmentStart[1] + const lengthSquared = dx * dx + dz * dz + + if (lengthSquared < MIN_DRAW_DISTANCE * MIN_DRAW_DISTANCE) { + return segmentStart + } + + const t = Math.max( + 0, + Math.min( + 1, + ((point[0] - segmentStart[0]) * dx + (point[1] - segmentStart[1]) * dz) / lengthSquared, + ), + ) + + return [segmentStart[0] + dx * t, segmentStart[1] + dz * t] +} + +export const getSegmentSnapPoint = ( + point: PlanPoint, + segments: Array, + maxDistance = WALL_SNAP_DISTANCE, +): PlanPoint | null => { + const maxDistanceSquared = maxDistance * maxDistance + let nearestCorner: PlanPoint | null = null + let nearestCornerDistanceSquared = Number.POSITIVE_INFINITY + let nearestWallPoint: PlanPoint | null = null + let nearestWallDistanceSquared = Number.POSITIVE_INFINITY + + for (const segment of segments) { + for (const corner of [segment.start, segment.end] as PlanPoint[]) { + const cornerDistanceSquared = getDistanceSquared(point, corner) + if ( + cornerDistanceSquared <= maxDistanceSquared && + cornerDistanceSquared < nearestCornerDistanceSquared + ) { + nearestCorner = corner + nearestCornerDistanceSquared = cornerDistanceSquared + } + } + + const projectedPoint = getClosestPointOnSegment(point, segment.start, segment.end) + const wallDistanceSquared = getDistanceSquared(point, projectedPoint) + if ( + wallDistanceSquared <= maxDistanceSquared && + wallDistanceSquared < nearestWallDistanceSquared + ) { + nearestWallPoint = projectedPoint + nearestWallDistanceSquared = wallDistanceSquared + } + } + + return nearestCorner ?? nearestWallPoint +} + +export const getWallSnapPoint = ( + point: PlanPoint, + walls: Array>, + maxDistance = WALL_SNAP_DISTANCE, +) => getSegmentSnapPoint(point, walls, maxDistance) + +export const snapSegmentTo45Degrees = (start: PlanPoint, cursor: PlanPoint): PlanPoint => { + const dx = cursor[0] - start[0] + const dz = cursor[1] - start[1] + const angle = Math.atan2(dz, dx) + const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) + const distance = Math.hypot(dx, dz) + + return [ + snapToGrid(start[0] + Math.cos(snappedAngle) * distance), + snapToGrid(start[1] + Math.sin(snappedAngle) * distance), + ] +} diff --git a/packages/editor/src/components/tools/shared/measurement-guides.tsx b/packages/editor/src/components/tools/shared/measurement-guides.tsx new file mode 100644 index 00000000..319fca01 --- /dev/null +++ b/packages/editor/src/components/tools/shared/measurement-guides.tsx @@ -0,0 +1,100 @@ +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import { BufferGeometry, Vector3 } from 'three' +import { EDITOR_LAYER } from '../../../lib/constants' +import useEditor from '../../../store/use-editor' +import { DrawingDimensionLabel } from './drawing-dimension-label' +import { + formatDistance, + getPlanDistance, + getPlanMidpoint, + MIN_DRAW_DISTANCE, +} from './drawing-utils' + +const MeasurementGuideLine = ({ + isHovered, + isSelected, + end, + levelY, + start, +}: { + start: [number, number] + end: [number, number] + levelY: number + isSelected: boolean + isHovered: boolean +}) => { + const unitSystem = useViewer((state) => state.unitSystem) + const distance = useMemo(() => getPlanDistance(start, end), [end, start]) + const midpoint = useMemo(() => getPlanMidpoint(start, end), [end, start]) + const color = isSelected ? '#f97316' : isHovered ? '#f59e0b' : '#fbbf24' + const opacity = isSelected ? 1 : isHovered ? 0.95 : 0.8 + const geometry = useMemo(() => { + return new BufferGeometry().setFromPoints([ + new Vector3(start[0], levelY + 0.02, start[1]), + new Vector3(end[0], levelY + 0.02, end[1]), + ]) + }, [end, levelY, start]) + + useEffect(() => { + return () => { + geometry.dispose() + } + }, [geometry]) + + if (distance < MIN_DRAW_DISTANCE) return null + + return ( + <> + {/* @ts-ignore */} + {}} renderOrder={1}> + + + + + + + ) +} + +export const MeasurementGuides: React.FC = () => { + const currentLevelId = useViewer((state) => state.selection.levelId) + const showGuides = useViewer((state) => state.showGuides) + const measurementGuides = useEditor((state) => state.measurementGuides) + const selectedMeasurementGuideId = useEditor((state) => state.selectedMeasurementGuideId) + const hoveredMeasurementGuideId = useEditor((state) => state.hoveredMeasurementGuideId) + + const visibleGuides = useMemo(() => { + if (!(showGuides && currentLevelId)) return [] + return measurementGuides.filter( + (guide) => guide.levelId === currentLevelId && guide.visible !== false, + ) + }, [currentLevelId, measurementGuides, showGuides]) + + if (visibleGuides.length === 0) return null + + return ( + + {visibleGuides.map((guide) => ( + + ))} + + ) +} diff --git a/packages/editor/src/components/tools/slab/slab-tool.tsx b/packages/editor/src/components/tools/slab/slab-tool.tsx index 2226b836..c62c1e73 100644 --- a/packages/editor/src/components/tools/slab/slab-tool.tsx +++ b/packages/editor/src/components/tools/slab/slab-tool.tsx @@ -1,10 +1,32 @@ -import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core' +import { + emitter, + type GridEvent, + type LevelNode, + SlabNode, + useScene, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { EDITOR_LAYER } from '../../../lib/constants' +import { formatLengthInputValue, getLengthInputUnitLabel } from '../../../lib/measurements' import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + CLOSE_LOOP_TOLERANCE, + formatDistance, + getPlanDistance, + getPlanMidpoint, + getSegmentSnapPoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapToGrid, +} from '../shared/drawing-utils' const Y_OFFSET = 0.02 @@ -65,6 +87,9 @@ const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, numb } export const SlabTool: React.FC = () => { + const measurementGuides = useEditor((state) => state.measurementGuides) + const showGuides = useViewer((state) => state.showGuides) + const unitSystem = useViewer((state) => state.unitSystem) const cursorRef = useRef(null) const mainLineRef = useRef(null!) const closingLineRef = useRef(null!) @@ -72,37 +97,136 @@ export const SlabTool: React.FC = () => { const setSelection = useViewer((state) => state.setSelection) const [points, setPoints] = useState>([]) - const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0]) const [levelY, setLevelY] = useState(0) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) const previousSnappedPointRef = useRef<[number, number] | null>(null) const shiftPressed = useRef(false) + const pointsRef = useRef>([]) + const cursorPositionRef = useRef([0, 0]) + const snappedCursorPositionRef = useRef([0, 0]) + const levelYRef = useRef(0) + const inputOpenRef = useRef(false) + const ignoreNextGridClickRef = useRef(false) + + const updatePoints = useCallback((nextPoints: Array) => { + pointsRef.current = nextPoints + setPoints(nextPoints) + }, []) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const commitDraftPoint = useCallback( + (point: PlanPoint) => { + if (!currentLevelId) return + + const firstPoint = pointsRef.current[0] + if ( + pointsRef.current.length >= 3 && + firstPoint && + Math.abs(point[0] - firstPoint[0]) < CLOSE_LOOP_TOLERANCE && + Math.abs(point[1] - firstPoint[1]) < CLOSE_LOOP_TOLERANCE + ) { + const slabId = commitSlabDrawing(currentLevelId, pointsRef.current) + setSelection({ selectedIds: [slabId] }) + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() + return + } + + updatePoints([...pointsRef.current, point]) + }, + [closeDistanceInput, currentLevelId, setSelection, updatePoints], + ) + + const applyDistanceInput = ( + rawValue: string, + options?: { commitAfterApply?: boolean; ignoreNextGridClick?: boolean }, + ) => { + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) { + closeDistanceInput(options) + return + } + + const parsedDistance = parseDistanceInput(rawValue, unitSystem) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } + + const projected = projectPointAtDistance( + lastPoint, + snappedCursorPositionRef.current, + parsedDistance, + ) + cursorPositionRef.current = projected + snappedCursorPositionRef.current = projected + previousSnappedPointRef.current = projected + setSnappedCursorPosition(projected) + cursorRef.current?.position.set(projected[0], levelYRef.current, projected[1]) + + if (options?.commitAfterApply) { + closeDistanceInput() + commitDraftPoint(projected) + return + } + + closeDistanceInput(options) + } // Update cursor position and lines on grid move useEffect(() => { if (!currentLevelId) return + const getLevelWalls = () => + Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === currentLevelId, + ) + const getSnapSegments = () => [ + ...getLevelWalls(), + ...(showGuides + ? measurementGuides + .filter((guide) => guide.levelId === currentLevelId) + .map((guide) => ({ start: guide.start, end: guide.end })) + : []), + ] const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 - const gridPosition: [number, number] = [gridX, gridZ] + const gridX = snapToGrid(event.position[0]) + const gridZ = snapToGrid(event.position[2]) + const gridPosition: PlanPoint = [gridX, gridZ] - setCursorPosition(gridPosition) + if (inputOpenRef.current) return + + cursorPositionRef.current = gridPosition + levelYRef.current = event.position[1] setLevelY(event.position[1]) // Calculate snapped display position (bypass snap when Shift is held) - const lastPoint = points[points.length - 1] - const displayPoint = + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + const basePoint = shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) + const displayPoint = shiftPressed.current + ? basePoint + : (getSegmentSnapPoint(basePoint, getSnapSegments()) ?? basePoint) + snappedCursorPositionRef.current = displayPoint setSnappedCursorPosition(displayPoint) // Play snap sound when the snapped position actually changes (only when drawing) if ( - points.length > 0 && + pointsRef.current.length > 0 && previousSnappedPointRef.current && (displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1]) @@ -116,45 +240,61 @@ export const SlabTool: React.FC = () => { const onGridClick = (_event: GridEvent) => { if (!currentLevelId) return + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return + } + if (inputOpenRef.current) return // Use the last displayed snapped position (respects Shift state from onGridMove) - const clickPoint = previousSnappedPointRef.current ?? cursorPosition + const clickPoint = previousSnappedPointRef.current ?? cursorPositionRef.current // Check if clicking on the first point to close the shape - const firstPoint = points[0] - if ( - 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, points) - setSelection({ selectedIds: [slabId] }) - setPoints([]) - } else { - // Add point to polygon - setPoints([...points, clickPoint]) - } + commitDraftPoint(clickPoint) } const onGridDoubleClick = (_event: GridEvent) => { if (!currentLevelId) return // Need at least 3 points to form a polygon - if (points.length >= 3) { - const slabId = commitSlabDrawing(currentLevelId, points) + if (pointsRef.current.length >= 3) { + const slabId = commitSlabDrawing(currentLevelId, pointsRef.current) setSelection({ selectedIds: [slabId] }) - setPoints([]) + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() } } const onCancel = () => { - setPoints([]) + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() } const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Shift') shiftPressed.current = true + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + + if (e.key === 'Shift') { + shiftPressed.current = true + return + } + + if (e.key !== 'Tab' || pointsRef.current.length === 0) return + + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) return + + const currentDistance = getPlanDistance(lastPoint, snappedCursorPositionRef.current) + if (currentDistance < MIN_DRAW_DISTANCE) return + + e.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: formatLengthInputValue(currentDistance, unitSystem), + }) } const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false @@ -175,7 +315,16 @@ export const SlabTool: React.FC = () => { emitter.off('grid:double-click', onGridDoubleClick) emitter.off('tool:cancel', onCancel) } - }, [currentLevelId, points, cursorPosition, setSelection]) + }, [ + closeDistanceInput, + commitDraftPoint, + currentLevelId, + measurementGuides, + setSelection, + showGuides, + unitSystem, + updatePoints, + ]) // Update line geometries when points change useEffect(() => { @@ -246,6 +395,19 @@ export const SlabTool: React.FC = () => { return shape }, [points, snappedCursorPosition]) + const currentSegment = useMemo(() => { + const lastPoint = points[points.length - 1] + if (!lastPoint) return null + + const distance = getPlanDistance(lastPoint, snappedCursorPosition) + if (distance < MIN_DRAW_DISTANCE) return null + + return { + distance, + midpoint: getPlanMidpoint(lastPoint, snappedCursorPosition), + } + }, [points, snappedCursorPosition]) + return ( {/* Cursor */} @@ -313,6 +475,34 @@ export const SlabTool: React.FC = () => { showTooltip={false} /> ))} + + {currentSegment && ( + { + if (!inputOpenRef.current) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value, { commitAfterApply: true }) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={[currentSegment.midpoint[0], levelY + 0.18, currentSegment.midpoint[1]]} + value={formatDistance(currentSegment.distance, unitSystem)} + /> + )} ) } diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 0d2fde96..29cf9ca2 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -7,7 +7,9 @@ import { CeilingTool } from './ceiling/ceiling-tool' import { DoorTool } from './door/door-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' +import { MeasureTool } from './measure/measure-tool' import { RoofTool } from './roof/roof-tool' +import { MeasurementGuides } from './shared/measurement-guides' import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabHoleEditor } from './slab/slab-hole-editor' @@ -22,6 +24,7 @@ const tools: Record>> = { 'property-line': SiteBoundaryEditor, }, structure: { + measure: MeasureTool, wall: WallTool, slab: SlabTool, ceiling: CeilingTool, @@ -99,6 +102,7 @@ export const ToolManager: React.FC = () => { return ( <> + {showSiteBoundaryEditor && } {showZoneBoundaryEditor && selectedZoneId && } {showSlabBoundaryEditor && selectedSlabId && } diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index eb0d45bf..09988038 100644 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -1,40 +1,33 @@ import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { EDITOR_LAYER } from '../../../lib/constants' +import { formatLengthInputValue, getLengthInputUnitLabel } from '../../../lib/measurements' import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + formatDistance, + getPlanDistance, + getPlanMidpoint, + getSegmentSnapPoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapSegmentTo45Degrees, + snapToGrid, +} from '../shared/drawing-utils' const WALL_HEIGHT = 2.5 const WALL_THICKNESS = 0.15 -/** - * Snap point to 45° angle increments relative to start point - * Also snaps end point to 0.5 grid - */ -const snapTo45Degrees = (start: Vector3, cursor: Vector3): Vector3 => { - const dx = cursor.x - start.x - const dz = cursor.z - start.z - - // Calculate angle in radians - const angle = Math.atan2(dz, dx) - - // Round to nearest 45° (π/4 radians) - const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) - - // Calculate distance from start to cursor - const distance = Math.sqrt(dx * dx + dz * dz) - - // Project end point along snapped angle - let snappedX = start.x + Math.cos(snappedAngle) * distance - let snappedZ = start.z + Math.sin(snappedAngle) * distance - - // Snap to 0.5 grid - snappedX = Math.round(snappedX * 2) / 2 - snappedZ = Math.round(snappedZ * 2) / 2 - - return new Vector3(snappedX, cursor.y, snappedZ) +type WallDraft = { + start: PlanPoint | null + end: PlanPoint | null + levelY: number } /** @@ -98,35 +91,145 @@ const commitWallDrawing = (start: [number, number], end: [number, number]) => { } export const WallTool: React.FC = () => { + const currentLevelId = useViewer((state) => state.selection.levelId) + const showGuides = useViewer((state) => state.showGuides) + const unitSystem = useViewer((state) => state.unitSystem) + const measurementGuides = useEditor((state) => state.measurementGuides) const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const shiftPressed = useRef(false) + const levelYRef = useRef(0) + const inputOpenRef = useRef(false) + const ignoreNextGridClickRef = useRef(false) + + const [draft, setDraft] = useState({ + start: null, + end: null, + levelY: 0, + }) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const syncDraftState = useCallback(() => { + setDraft({ + start: [startingPoint.current.x, startingPoint.current.z], + end: [endingPoint.current.x, endingPoint.current.z], + levelY: levelYRef.current, + }) + }, []) + + const clearDraft = useCallback( + (options?: { ignoreNextGridClick?: boolean }) => { + buildingState.current = 0 + wallPreviewRef.current.visible = false + closeDistanceInput(options) + setDraft({ start: null, end: null, levelY: 0 }) + }, + [closeDistanceInput], + ) + + const commitCurrentWall = useCallback(() => { + if (buildingState.current !== 1) return + + const dx = endingPoint.current.x - startingPoint.current.x + const dz = endingPoint.current.z - startingPoint.current.z + if (dx * dx + dz * dz < MIN_DRAW_DISTANCE * MIN_DRAW_DISTANCE) return + + commitWallDrawing( + [startingPoint.current.x, startingPoint.current.z], + [endingPoint.current.x, endingPoint.current.z], + ) + clearDraft() + }, [clearDraft]) + + const applyDistanceInput = ( + rawValue: string, + options?: { commitAfterApply?: boolean; ignoreNextGridClick?: boolean }, + ) => { + if (buildingState.current !== 1) { + closeDistanceInput(options) + return + } + + const parsedDistance = parseDistanceInput(rawValue, unitSystem) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } + + const start: PlanPoint = [startingPoint.current.x, startingPoint.current.z] + const currentEnd: PlanPoint = [endingPoint.current.x, endingPoint.current.z] + const projected = projectPointAtDistance(start, currentEnd, parsedDistance) + + endingPoint.current.set(projected[0], levelYRef.current, projected[1]) + cursorRef.current?.position.set(projected[0], levelYRef.current, projected[1]) + updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + syncDraftState() + + if (options?.commitAfterApply) { + commitCurrentWall() + return + } + + closeDistanceInput(options) + } useEffect(() => { - let gridPosition: [number, number] = [0, 0] - let previousWallEnd: [number, number] | null = null + let gridPosition: PlanPoint = [0, 0] + let previousWallEnd: PlanPoint | null = null + const getLevelWalls = () => + Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === currentLevelId, + ) + const getSnapSegments = () => [ + ...getLevelWalls(), + ...(showGuides + ? measurementGuides + .filter((guide) => guide.levelId === currentLevelId) + .map((guide) => ({ start: guide.start, end: guide.end })) + : []), + ] const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && wallPreviewRef.current)) return - gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[2] * 2) / 2] - const cursorPosition = new Vector3(gridPosition[0], event.position[1], gridPosition[1]) + const rawGridPosition: PlanPoint = [ + snapToGrid(event.position[0]), + snapToGrid(event.position[2]), + ] + levelYRef.current = event.position[1] + const cursorPosition: PlanPoint = rawGridPosition if (buildingState.current === 1) { - // Snap to 45° angles only if shift is not pressed - const snapped = shiftPressed.current + if (inputOpenRef.current) return + + const start: PlanPoint = [startingPoint.current.x, startingPoint.current.z] + const angleSnapped = shiftPressed.current ? cursorPosition - : snapTo45Degrees(startingPoint.current, cursorPosition) - endingPoint.current.copy(snapped) + : snapSegmentTo45Degrees(start, cursorPosition) + const snapped = + shiftPressed.current || !currentLevelId + ? angleSnapped + : (getSegmentSnapPoint(angleSnapped, getSnapSegments()) ?? angleSnapped) + + endingPoint.current.set(snapped[0], event.position[1], snapped[1]) // Position the cursor at the end of the wall being drawn - cursorRef.current.position.set(snapped.x, snapped.y, snapped.z) + cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]) // Play snap sound only when the actual wall end position changes - const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z] + const currentWallEnd: PlanPoint = [endingPoint.current.x, endingPoint.current.z] if ( previousWallEnd && (currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1]) @@ -137,34 +240,60 @@ export const WallTool: React.FC = () => { // Update wall preview geometry updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) + syncDraftState() } else { + gridPosition = + shiftPressed.current || !currentLevelId + ? rawGridPosition + : (getSegmentSnapPoint(rawGridPosition, getSnapSegments()) ?? rawGridPosition) // Not drawing a wall, just follow the grid position cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) } } const onGridClick = (event: GridEvent) => { + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return + } + + if (inputOpenRef.current) return + if (buildingState.current === 0) { startingPoint.current.set(gridPosition[0], event.position[1], gridPosition[1]) + endingPoint.current.copy(startingPoint.current) + levelYRef.current = event.position[1] buildingState.current = 1 wallPreviewRef.current.visible = true + syncDraftState() } else if (buildingState.current === 1) { - const dx = endingPoint.current.x - startingPoint.current.x - const dz = endingPoint.current.z - startingPoint.current.z - if (dx * dx + dz * dz < 0.01 * 0.01) return - commitWallDrawing( - [startingPoint.current.x, startingPoint.current.z], - [endingPoint.current.x, endingPoint.current.z], - ) - wallPreviewRef.current.visible = false - buildingState.current = 0 + commitCurrentWall() } } const onKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key === 'Shift') { shiftPressed.current = true + return } + + if (e.key !== 'Tab' || buildingState.current !== 1) return + + const currentDistance = getPlanDistance( + [startingPoint.current.x, startingPoint.current.z], + [endingPoint.current.x, endingPoint.current.z], + ) + if (currentDistance < MIN_DRAW_DISTANCE) return + + e.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: formatLengthInputValue(currentDistance, unitSystem), + }) } const onKeyUp = (e: KeyboardEvent) => { @@ -175,8 +304,7 @@ export const WallTool: React.FC = () => { const onCancel = () => { if (buildingState.current === 1) { - buildingState.current = 0 - wallPreviewRef.current.visible = false + clearDraft() } } @@ -193,7 +321,26 @@ export const WallTool: React.FC = () => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, []) + }, [ + clearDraft, + commitCurrentWall, + currentLevelId, + measurementGuides, + showGuides, + syncDraftState, + unitSystem, + ]) + + const currentDistance = useMemo(() => { + if (!(draft.start && draft.end)) return 0 + return getPlanDistance(draft.start, draft.end) + }, [draft.end, draft.start]) + + const labelPosition = useMemo(() => { + if (!(draft.start && draft.end)) return null + const midpoint = getPlanMidpoint(draft.start, draft.end) + return [midpoint[0], draft.levelY + WALL_HEIGHT + 0.3, midpoint[1]] as [number, number, number] + }, [draft.end, draft.levelY, draft.start]) return ( @@ -212,6 +359,34 @@ export const WallTool: React.FC = () => { transparent /> + + {labelPosition && currentDistance >= MIN_DRAW_DISTANCE && ( + { + if (!inputOpenRef.current) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value, { commitAfterApply: true }) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={labelPosition} + value={formatDistance(currentDistance, unitSystem)} + /> + )} ) } diff --git a/packages/editor/src/components/tools/window/move-window-tool.tsx b/packages/editor/src/components/tools/window/move-window-tool.tsx index 5cc64b2c..5bfee042 100644 --- a/packages/editor/src/components/tools/window/move-window-tool.tsx +++ b/packages/editor/src/components/tools/window/move-window-tool.tsx @@ -19,7 +19,7 @@ import { calculateItemRotation, getSideFromNormal, isValidWallSideFace, - snapToHalf, + snapToInch, } from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -119,8 +119,8 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) + const localX = snapToInch(event.localPosition[0]) + const localY = snapToInch(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -175,8 +175,8 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) + const localX = snapToInch(event.localPosition[0]) + const localY = snapToInch(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, localX, @@ -230,8 +230,8 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) + const localX = snapToInch(event.localPosition[0]) + const localY = snapToInch(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, localX, diff --git a/packages/editor/src/components/tools/window/window-tool.tsx b/packages/editor/src/components/tools/window/window-tool.tsx index 072bf118..18f4fcb0 100644 --- a/packages/editor/src/components/tools/window/window-tool.tsx +++ b/packages/editor/src/components/tools/window/window-tool.tsx @@ -18,7 +18,7 @@ import { calculateItemRotation, getSideFromNormal, isValidWallSideFace, - snapToHalf, + snapToInch, } from '../item/placement-math' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' @@ -97,8 +97,8 @@ export const WindowTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) + const localX = snapToInch(event.localPosition[0]) + const localY = snapToInch(event.localPosition[1]) const width = 1.5 const height = 1.5 @@ -142,8 +142,8 @@ export const WindowTool: React.FC = () => { const itemRotation = calculateItemRotation(event.normal) const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) + const localX = snapToInch(event.localPosition[0]) + const localY = snapToInch(event.localPosition[1]) const width = draftRef.current?.width ?? 1.5 const height = draftRef.current?.height ?? 1.5 @@ -192,8 +192,8 @@ export const WindowTool: React.FC = () => { const side = getSideFromNormal(event.normal) const itemRotation = calculateItemRotation(event.normal) - const localX = snapToHalf(event.localPosition[0]) - const localY = snapToHalf(event.localPosition[1]) + const localX = snapToInch(event.localPosition[0]) + const localY = snapToInch(event.localPosition[1]) const { clampedX, clampedY } = clampToWall( event.node, localX, diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx index 1ae50734..f9369551 100644 --- a/packages/editor/src/components/tools/zone/zone-tool.tsx +++ b/packages/editor/src/components/tools/zone/zone-tool.tsx @@ -1,21 +1,40 @@ -import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pascal-app/core' +import { + emitter, + type GridEvent, + type LevelNode, + useScene, + type WallNode, + ZoneNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot' import { EDITOR_LAYER } from './../../../lib/constants' +import { formatLengthInputValue, getLengthInputUnitLabel } from './../../../lib/measurements' +import { sfxEmitter } from './../../../lib/sfx-bus' import useEditor from './../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +import { DrawingDimensionLabel } from '../shared/drawing-dimension-label' +import { + CLOSE_LOOP_TOLERANCE, + formatDistance, + getPlanDistance, + getPlanMidpoint, + getSegmentSnapPoint, + MIN_DRAW_DISTANCE, + type PlanPoint, + parseDistanceInput, + projectPointAtDistance, + snapToGrid, +} from '../shared/drawing-utils' 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], -): [number, number] => { +const calculateSnapPoint = (lastPoint: PlanPoint, currentPoint: PlanPoint): PlanPoint => { const [x1, y1] = lastPoint const [x, y] = currentPoint @@ -24,38 +43,29 @@ const calculateSnapPoint = ( 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) - - // Find the minimum distance to determine which axis to snap to 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] } if (minDist === horizontalDist) { - // Snap to horizontal return [x, y1] } - // Snap to vertical return [x1, y] } /** * Creates a zone with the given polygon points */ -const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => { +const commitZoneDrawing = (levelId: LevelNode['id'], points: Array) => { const { createNode, nodes } = useScene.getState() - // Count existing zones for naming and color cycling const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length const name = `Zone ${zoneCount + 1}` - - // Cycle through colors const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length] const zone = ZoneNode.parse({ @@ -65,236 +75,314 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb }) createNode(zone, levelId) - - // Select the newly created zone useViewer.getState().setSelection({ zoneId: zone.id }) } -type PreviewState = { - points: Array<[number, number]> - cursorPoint: [number, number] | null - levelY: number -} - -// Helper to validate point values (no NaN or Infinity) -const isValidPoint = (pt: [number, number] | null | undefined): pt is [number, number] => { - if (!pt) return false - return Number.isFinite(pt[0]) && Number.isFinite(pt[1]) -} - export const ZoneTool: React.FC = () => { + const currentLevelId = useViewer((state) => state.selection.levelId) + const measurementGuides = useEditor((state) => state.measurementGuides) + const showGuides = useViewer((state) => state.showGuides) + const unitSystem = useViewer((state) => state.unitSystem) const cursorRef = useRef(null) const mainLineRef = useRef(null!) const closingLineRef = useRef(null!) - const pointsRef = useRef>([]) - const levelYRef = useRef(0) // Track current level Y position - const currentLevelId = useViewer((state) => state.selection.levelId) - const setTool = useEditor((state) => state.setTool) - - // Preview state for reactive rendering (for shape and point markers) - const [preview, setPreview] = useState({ - points: [], - cursorPoint: null, - levelY: 0, - }) - - useEffect(() => { - if (!currentLevelId) return - - let cursorPosition: [number, number] = [0, 0] - - // Initialize line geometries - mainLineRef.current.geometry = new BufferGeometry() - closingLineRef.current.geometry = new BufferGeometry() + const pointsRef = useRef>([]) + const levelYRef = useRef(0) + const cursorPositionRef = useRef([0, 0]) + const snappedCursorPositionRef = useRef([0, 0]) + const previousSnappedPointRef = useRef(null) + const shiftPressed = useRef(false) + const inputOpenRef = useRef(false) + const ignoreNextGridClickRef = useRef(false) + + const [points, setPoints] = useState>([]) + const [snappedCursorPosition, setSnappedCursorPosition] = useState([0, 0]) + const [levelY, setLevelY] = useState(0) + const [distanceInput, setDistanceInput] = useState({ open: false, value: '' }) + + const updatePoints = useCallback((nextPoints: Array) => { + pointsRef.current = nextPoints + setPoints(nextPoints) + }, []) + + const closeDistanceInput = useCallback((options?: { ignoreNextGridClick?: boolean }) => { + inputOpenRef.current = false + shiftPressed.current = false + if (options?.ignoreNextGridClick) { + ignoreNextGridClickRef.current = true + } + setDistanceInput({ open: false, value: '' }) + }, []) + + const clearDraft = useCallback(() => { + updatePoints([]) + previousSnappedPointRef.current = null + closeDistanceInput() + if (mainLineRef.current.geometry) { + mainLineRef.current.visible = false + closingLineRef.current.visible = false + } + }, [closeDistanceInput, updatePoints]) - const updateLines = () => { - const points = pointsRef.current - const y = levelYRef.current + Y_OFFSET + const commitDraftPoint = useCallback( + (point: PlanPoint) => { + if (!currentLevelId) return - if (points.length === 0) { - mainLineRef.current.visible = false - closingLineRef.current.visible = false + const firstPoint = pointsRef.current[0] + if ( + pointsRef.current.length >= 3 && + firstPoint && + Math.abs(point[0] - firstPoint[0]) < CLOSE_LOOP_TOLERANCE && + Math.abs(point[1] - firstPoint[1]) < CLOSE_LOOP_TOLERANCE + ) { + commitZoneDrawing(currentLevelId, pointsRef.current) + clearDraft() return } - // Build main line points - const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z)) + updatePoints([...pointsRef.current, point]) + }, + [clearDraft, currentLevelId, updatePoints], + ) - // 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])) - } - } + const applyDistanceInput = ( + rawValue: string, + options?: { commitAfterApply?: boolean; ignoreNextGridClick?: boolean }, + ) => { + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) { + closeDistanceInput(options) + return + } - // 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 - } + const parsedDistance = parseDistanceInput(rawValue, unitSystem) + if (!(parsedDistance && parsedDistance >= MIN_DRAW_DISTANCE)) { + closeDistanceInput(options) + return + } - // 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 projected = projectPointAtDistance( + lastPoint, + snappedCursorPositionRef.current, + parsedDistance, + ) + cursorPositionRef.current = projected + snappedCursorPositionRef.current = projected + previousSnappedPointRef.current = projected + setSnappedCursorPosition(projected) + cursorRef.current?.position.set(projected[0], levelYRef.current, projected[1]) + + if (options?.commitAfterApply) { + closeDistanceInput() + commitDraftPoint(projected) + return } - const updatePreview = () => { - const points = pointsRef.current - const lastPoint = points[points.length - 1] + closeDistanceInput(options) + } + + useEffect(() => { + if (!currentLevelId) return - let cursorPt: [number, number] | null = null - if (lastPoint) { - cursorPt = calculateSnapPoint(lastPoint, cursorPosition) - } else if (points.length === 0) { - cursorPt = cursorPosition - } + mainLineRef.current.geometry = new BufferGeometry() + closingLineRef.current.geometry = new BufferGeometry() - setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current }) - updateLines() - } + const getLevelWalls = () => + Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === currentLevelId, + ) + const getSnapSegments = () => [ + ...getLevelWalls(), + ...(showGuides + ? measurementGuides + .filter((guide) => guide.levelId === currentLevelId) + .map((guide) => ({ start: guide.start, end: guide.end })) + : []), + ] const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return + if (inputOpenRef.current) return + + const gridPosition: PlanPoint = [snapToGrid(event.position[0]), snapToGrid(event.position[2])] - // 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] + cursorPositionRef.current = gridPosition levelYRef.current = event.position[1] + setLevelY(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) + const basePoint = + shiftPressed.current || !lastPoint + ? gridPosition + : calculateSnapPoint(lastPoint, gridPosition) + const displayPoint = shiftPressed.current + ? basePoint + : (getSegmentSnapPoint(basePoint, getSnapSegments()) ?? basePoint) + + snappedCursorPositionRef.current = displayPoint + setSnappedCursorPosition(displayPoint) + + if ( + pointsRef.current.length > 0 && + previousSnappedPointRef.current && + (displayPoint[0] !== previousSnappedPointRef.current[0] || + displayPoint[1] !== previousSnappedPointRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') } - updatePreview() + previousSnappedPointRef.current = displayPoint + cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1]) } - const onGridClick = (event: GridEvent) => { + const onGridClick = (_event: GridEvent) => { if (!currentLevelId) return - - const gridX = Math.round(event.position[0] * 2) / 2 - const gridZ = Math.round(event.position[2] * 2) / 2 - let clickPoint: [number, number] = [gridX, gridZ] - - // Snap to axis from last point - const lastPoint = pointsRef.current[pointsRef.current.length - 1] - if (lastPoint) { - clickPoint = calculateSnapPoint(lastPoint, clickPoint) + if (ignoreNextGridClickRef.current) { + ignoreNextGridClickRef.current = false + return } + if (inputOpenRef.current) return - // Check if clicking on the first point to close the shape - const firstPoint = pointsRef.current[0] - if ( - pointsRef.current.length >= 3 && - firstPoint && - Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && - Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 - ) { - // Create the zone - commitZoneDrawing(currentLevelId, pointsRef.current) - - // Reset state - pointsRef.current = [] - setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }) - mainLineRef.current.visible = false - closingLineRef.current.visible = false - } else { - // Add point to polygon - pointsRef.current = [...pointsRef.current, clickPoint] - updatePreview() - } + const clickPoint = previousSnappedPointRef.current ?? cursorPositionRef.current + commitDraftPoint(clickPoint) } const onGridDoubleClick = (_event: GridEvent) => { if (!currentLevelId) return - // Need at least 3 points to form a polygon if (pointsRef.current.length >= 3) { commitZoneDrawing(currentLevelId, pointsRef.current) + clearDraft() + } + } - // Reset state - pointsRef.current = [] - setPreview({ points: [], cursorPoint: null, levelY: levelYRef.current }) - mainLineRef.current.visible = false - closingLineRef.current.visible = false + const onCancel = () => { + clearDraft() + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return + } + + if (event.key === 'Shift') { + shiftPressed.current = true + return + } + + if (event.key !== 'Tab' || pointsRef.current.length === 0) return + + const lastPoint = pointsRef.current[pointsRef.current.length - 1] + if (!lastPoint) return + + const currentDistance = getPlanDistance(lastPoint, snappedCursorPositionRef.current) + if (currentDistance < MIN_DRAW_DISTANCE) return + + event.preventDefault() + shiftPressed.current = false + inputOpenRef.current = true + setDistanceInput({ + open: true, + value: formatLengthInputValue(currentDistance, unitSystem), + }) + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftPressed.current = false } } - // Subscribe to events emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('grid:double-click', onGridDoubleClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, [clearDraft, commitDraftPoint, currentLevelId, measurementGuides, showGuides, unitSystem]) - // Reset state on unmount - pointsRef.current = [] + useEffect(() => { + if (!(mainLineRef.current && closingLineRef.current)) return + + if (points.length === 0) { + mainLineRef.current.visible = false + closingLineRef.current.visible = false + return } - }, [currentLevelId, setTool]) - const { points, cursorPoint, levelY } = preview + const y = levelY + Y_OFFSET + const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z)) + linePoints.push(new Vector3(snappedCursorPosition[0], y, snappedCursorPosition[1])) - // Create preview shape when we have 3+ points - const previewShape = useMemo(() => { - if (points.length < 3) return null + if (linePoints.length >= 2) { + mainLineRef.current.geometry.dispose() + mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints) + mainLineRef.current.visible = true + } else { + mainLineRef.current.visible = false + } - const allPoints = [...points] - if (isValidPoint(cursorPoint)) { - allPoints.push(cursorPoint) + const firstPoint = points[0] + if (points.length >= 2 && firstPoint) { + const closingPoints = [ + new Vector3(snappedCursorPosition[0], y, snappedCursorPosition[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 } + }, [levelY, points, snappedCursorPosition]) + + const previewShape = useMemo(() => { + if (points.length < 3) return null - // 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 allPoints = [...points, snappedCursorPosition] const firstPt = allPoints[0] - if (!isValidPoint(firstPt)) return null + if (!firstPt) return null 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)) { + if (pt) { shape.lineTo(pt[0], -pt[1]) } } shape.closePath() return shape - }, [points, cursorPoint]) + }, [points, snappedCursorPosition]) + + const currentSegment = useMemo(() => { + const lastPoint = points[points.length - 1] + if (!lastPoint) return null + + const distance = getPlanDistance(lastPoint, snappedCursorPosition) + if (distance < MIN_DRAW_DISTANCE) return null + + return { + distance, + midpoint: getPlanMidpoint(lastPoint, snappedCursorPosition), + } + }, [points, snappedCursorPosition]) return ( - {/* Cursor */} - {/* Preview fill */} {previewShape && ( { )} - {/* Main line - uses native line element with TSL-compatible material */} {/* @ts-ignore */} { - {/* Closing line - uses native line element with TSL-compatible material */} {/* @ts-ignore */} { /> - {/* Point markers */} - {points.map(([x, z], index) => - isValidPoint([x, z]) ? ( - - ) : null, + {points.map(([x, z], index) => ( + + ))} + + {currentSegment && ( + { + if (!inputOpenRef.current) return + applyDistanceInput(distanceInput.value, { ignoreNextGridClick: true }) + }} + onInputChange={(value) => { + setDistanceInput((current) => ({ ...current, value })) + }} + onInputKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + applyDistanceInput(distanceInput.value, { commitAfterApply: true }) + } else if (event.key === 'Escape') { + event.preventDefault() + closeDistanceInput() + } + }} + position={[currentSegment.midpoint[0], levelY + 0.18, currentSegment.midpoint[1]]} + value={formatDistance(currentSegment.distance, unitSystem)} + /> )} ) diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 80270e5d..a0ac2d1e 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -19,6 +19,7 @@ export type ToolConfig = { } export const tools: ToolConfig[] = [ + { id: 'measure', iconSrc: '/icons/measure.svg', label: 'Measure' }, { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' }, // { id: 'room', iconSrc: '/icons/room.png', label: 'Room' }, // { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' }, diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx index 647d3c20..c2be1a72 100644 --- a/packages/editor/src/components/ui/controls/metric-control.tsx +++ b/packages/editor/src/components/ui/controls/metric-control.tsx @@ -1,7 +1,13 @@ 'use client' import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' +import { + formatLength, + formatLengthInputValue, + parseLengthInput, +} from '../../../lib/measurements' import { cn } from '../../../lib/utils' interface MetricControlProps { @@ -14,6 +20,7 @@ interface MetricControlProps { step?: number className?: string unit?: string + editTrigger?: 'click' | 'doubleClick' } export function MetricControl({ @@ -26,7 +33,10 @@ export function MetricControl({ step = 1, className, unit = '', + editTrigger = 'click', }: MetricControlProps) { + const unitSystem = useViewer((state) => state.unitSystem) + const isLengthMeasurement = unit === 'm' const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isHovered, setIsHovered] = useState(false) @@ -37,6 +47,12 @@ export function MetricControl({ const valueRef = useRef(value) valueRef.current = value + const formattedInputValue = isLengthMeasurement + ? formatLengthInputValue(value, unitSystem) + : value.toFixed(precision) + const formattedDisplayValue = isLengthMeasurement + ? formatLength(value, unitSystem) + : Number(value.toFixed(precision)).toFixed(precision) const clamp = useCallback( (val: number) => { @@ -47,9 +63,9 @@ export function MetricControl({ useEffect(() => { if (!isEditing) { - setInputValue(value.toFixed(precision)) + setInputValue(formattedInputValue) } - }, [value, precision, isEditing]) + }, [formattedInputValue, isEditing]) useEffect(() => { const container = containerRef.current @@ -155,22 +171,24 @@ export function MetricControl({ const handleValueClick = useCallback(() => { setIsEditing(true) - setInputValue(value.toFixed(precision)) - }, [value, precision]) + setInputValue(formattedInputValue) + }, [formattedInputValue]) const handleInputChange = useCallback((e: React.ChangeEvent) => { setInputValue(e.target.value) }, []) const submitValue = useCallback(() => { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { - setInputValue(value.toFixed(precision)) + const numValue = isLengthMeasurement + ? parseLengthInput(inputValue, unitSystem) + : Number.parseFloat(inputValue) + if (numValue === null || Number.isNaN(numValue)) { + setInputValue(formattedInputValue) } else { onChange(clamp(Number.parseFloat(numValue.toFixed(precision)))) } setIsEditing(false) - }, [inputValue, onChange, clamp, precision, value]) + }, [inputValue, isLengthMeasurement, unitSystem, formattedInputValue, onChange, clamp, precision]) const handleInputBlur = useCallback(() => { submitValue() @@ -181,21 +199,25 @@ export function MetricControl({ if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { - setInputValue(value.toFixed(precision)) + setInputValue(formattedInputValue) setIsEditing(false) } else if (e.key === 'ArrowUp') { e.preventDefault() const newV = clamp(value + step) onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue( + isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision), + ) } else if (e.key === 'ArrowDown') { e.preventDefault() const newV = clamp(value - step) onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue( + isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision), + ) } }, - [submitValue, value, precision, step, clamp, onChange], + [submitValue, formattedInputValue, value, step, clamp, isLengthMeasurement, onChange, unitSystem, precision], ) return ( @@ -221,7 +243,7 @@ export function MetricControl({ {label} -
+
{isEditing ? (
- {unit && {unit}} + {!isLengthMeasurement && unit && ( + {unit} + )}
) : (
- - {Number(value.toFixed(precision)).toFixed(precision)} - - {unit && {unit}} + {formattedDisplayValue} + {!isLengthMeasurement && unit && ( + {unit} + )}
)}
diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 4a278949..6623d506 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -1,7 +1,13 @@ 'use client' import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' +import { + formatLength, + formatLengthInputValue, + parseLengthInput, +} from '../../../lib/measurements' import { cn } from '../../../lib/utils' interface SliderControlProps { @@ -27,6 +33,8 @@ export function SliderControl({ className, unit = '', }: SliderControlProps) { + const unitSystem = useViewer((state) => state.unitSystem) + const isLengthMeasurement = unit === 'm' const [isEditing, setIsEditing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isHovered, setIsHovered] = useState(false) @@ -42,6 +50,12 @@ export function SliderControl({ const valueRef = useRef(value) valueRef.current = value + const formattedInputValue = isLengthMeasurement + ? formatLengthInputValue(value, unitSystem) + : value.toFixed(precision) + const formattedDisplayValue = isLengthMeasurement + ? formatLength(value, unitSystem) + : Number(value.toFixed(precision)).toFixed(precision) const clamp = useCallback( (val: number) => { @@ -52,9 +66,9 @@ export function SliderControl({ useEffect(() => { if (!isEditing) { - setInputValue(value.toFixed(precision)) + setInputValue(formattedInputValue) } - }, [value, precision, isEditing]) + }, [formattedInputValue, isEditing]) useEffect(() => { const container = containerRef.current @@ -177,22 +191,24 @@ export function SliderControl({ const handleValueClick = useCallback(() => { setIsEditing(true) - setInputValue(value.toFixed(precision)) - }, [value, precision]) + setInputValue(formattedInputValue) + }, [formattedInputValue]) const handleInputChange = useCallback((e: React.ChangeEvent) => { setInputValue(e.target.value) }, []) const submitValue = useCallback(() => { - const numValue = Number.parseFloat(inputValue) - if (Number.isNaN(numValue)) { - setInputValue(value.toFixed(precision)) + const numValue = isLengthMeasurement + ? parseLengthInput(inputValue, unitSystem) + : Number.parseFloat(inputValue) + if (numValue === null || Number.isNaN(numValue)) { + setInputValue(formattedInputValue) } else { onChange(clamp(Number.parseFloat(numValue.toFixed(precision)))) } setIsEditing(false) - }, [inputValue, onChange, clamp, precision, value]) + }, [inputValue, isLengthMeasurement, unitSystem, formattedInputValue, onChange, clamp, precision]) const handleInputBlur = useCallback(() => { submitValue() @@ -203,21 +219,25 @@ export function SliderControl({ if (e.key === 'Enter') { submitValue() } else if (e.key === 'Escape') { - setInputValue(value.toFixed(precision)) + setInputValue(formattedInputValue) setIsEditing(false) } else if (e.key === 'ArrowUp') { e.preventDefault() const newV = clamp(value + step) onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue( + isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision), + ) } else if (e.key === 'ArrowDown') { e.preventDefault() const newV = clamp(value - step) onChange(newV) - setInputValue(newV.toFixed(precision)) + setInputValue( + isLengthMeasurement ? formatLengthInputValue(newV, unitSystem) : newV.toFixed(precision), + ) } }, - [submitValue, value, precision, step, clamp, onChange], + [submitValue, formattedInputValue, value, step, clamp, isLengthMeasurement, onChange, unitSystem, precision], ) const currentMin = isDragging && dragMin !== null ? dragMin : min @@ -301,7 +321,7 @@ export function SliderControl({ />
-
+
{isEditing ? (
- {unit && {unit}} + {!isLengthMeasurement && unit && ( + {unit} + )}
) : (
- - {Number(value.toFixed(precision)).toFixed(precision)} - - {unit && {unit}} + {formattedDisplayValue} + {!isLengthMeasurement && unit && ( + {unit} + )}
)}
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index b2b6cd3c..da3d990e 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -3,6 +3,7 @@ import useEditor from '../../../store/use-editor' import { CeilingHelper } from './ceiling-helper' import { ItemHelper } from './item-helper' +import { MeasureHelper } from './measure-helper' import { RoofHelper } from './roof-helper' import { SlabHelper } from './slab-helper' import { WallHelper } from './wall-helper' @@ -19,6 +20,8 @@ export function HelperManager() { switch (tool) { case 'wall': return + case 'measure': + return case 'item': return case 'slab': diff --git a/packages/editor/src/components/ui/helpers/measure-helper.tsx b/packages/editor/src/components/ui/helpers/measure-helper.tsx new file mode 100644 index 00000000..c30da50f --- /dev/null +++ b/packages/editor/src/components/ui/helpers/measure-helper.tsx @@ -0,0 +1,18 @@ +export function MeasureHelper() { + return ( +
+
+ Shift + Allow non-45° angles +
+
+ Tab + Type a measurement distance +
+
+ Esc + Cancel the current measurement draft +
+
+ ) +} diff --git a/packages/editor/src/components/ui/helpers/slab-helper.tsx b/packages/editor/src/components/ui/helpers/slab-helper.tsx index bf9b6aad..6104a7e1 100644 --- a/packages/editor/src/components/ui/helpers/slab-helper.tsx +++ b/packages/editor/src/components/ui/helpers/slab-helper.tsx @@ -5,6 +5,10 @@ export function SlabHelper() { Shift Allow non-45° angles
+
+ Tab + Type an exact segment length +
Esc Cancel diff --git a/packages/editor/src/components/ui/helpers/wall-helper.tsx b/packages/editor/src/components/ui/helpers/wall-helper.tsx index f15b3ac1..3d04cca4 100644 --- a/packages/editor/src/components/ui/helpers/wall-helper.tsx +++ b/packages/editor/src/components/ui/helpers/wall-helper.tsx @@ -5,6 +5,10 @@ export function WallHelper() { Shift Allow non-45° angles
+
+ Tab + Type an exact wall length +
Esc Cancel diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index 178e4945..b33ae9f0 100644 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' +import { formatArea } from '../../../lib/measurements' import useEditor from '../../../store/use-editor' import { ActionButton } from '../controls/action-button' import { PanelSection } from '../controls/panel-section' @@ -12,6 +13,7 @@ import { PanelWrapper } from './panel-wrapper' export function CeilingPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) + const unitSystem = useViewer((s) => s.unitSystem) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) const updateNode = useScene((s) => s.updateNode) @@ -139,7 +141,7 @@ export function CeilingPanel() {
Area - {area.toFixed(2)} m² + {formatArea(area, unitSystem)}
@@ -166,7 +168,7 @@ export function CeilingPanel() { Hole {index + 1} {isEditing && '(Editing)'}

- {holeArea.toFixed(2)} m² · {hole.length} pts + {formatArea(holeArea, unitSystem)} · {hole.length} pts

diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 8dd543f2..542c683e 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { useEffect } from 'react' import useEditor from '../../../store/use-editor' import { CeilingPanel } from './ceiling-panel' import { DoorPanel } from './door-panel' @@ -13,19 +14,58 @@ import { WallPanel } from './wall-panel' import { WindowPanel } from './window-panel' export function PanelManager() { - const selectedIds = useViewer((s) => s.selection.selectedIds) + const selection = useViewer((s) => s.selection) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) + const selectedMeasurementGuideId = useEditor((s) => s.selectedMeasurementGuideId) + const setSelectedMeasurementGuideId = useEditor((s) => s.setSelectedMeasurementGuideId) + const measurementGuides = useEditor((s) => s.measurementGuides) const nodes = useScene((s) => s.nodes) + useEffect(() => { + const selectedMeasurementGuide = measurementGuides.find( + (guide) => guide.id === selectedMeasurementGuideId, + ) + + if ( + selectedMeasurementGuideId && + (selection.selectedIds.length > 0 || + selection.zoneId || + selectedReferenceId || + !selectedMeasurementGuide || + selectedMeasurementGuide.levelId !== selection.levelId) + ) { + setSelectedMeasurementGuideId(null) + } + }, [ + measurementGuides, + selectedMeasurementGuideId, + selectedReferenceId, + selection, + setSelectedMeasurementGuideId, + ]) + // Show reference panel if a reference is selected if (selectedReferenceId) { return } + const selectedNodes = selection.selectedIds + .map((selectedId) => nodes[selectedId as AnyNodeId]) + .filter(Boolean) + + if (selectedNodes.length !== selection.selectedIds.length) { + return null + } + + const selectedTypes = new Set(selectedNodes.map((node) => node.type)) + + if (selectedTypes.size === 1 && selectedNodes[0]?.type === 'wall') { + return + } + // Show appropriate panel based on selected node type - if (selectedIds.length === 1) { - const selectedNode = selectedIds[0] - const node = nodes[selectedNode as AnyNodeId] + if (selection.selectedIds.length === 1) { + const node = selectedNodes[0] if (node) { switch (node.type) { case 'item': @@ -36,8 +76,6 @@ export function PanelManager() { return case 'ceiling': return - case 'wall': - return case 'door': return case 'window': diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 38bab922..882e3175 100644 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -1,29 +1,64 @@ 'use client' -import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, type RoofNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' +import { formatLength } from '../../../lib/measurements' +import { getRoofDimensions } from '../../../lib/roof-dimensions' import { ActionButton } from '../controls/action-button' -import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' export function RoofPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) + const unitSystem = useViewer((s) => s.unitSystem) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) - const updateNode = useScene((s) => s.updateNode) + const updateNodes = useScene((s) => s.updateNodes) const selectedId = selectedIds[0] const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined const handleUpdate = useCallback( - (updates: Partial) => { + ( + updates: Partial & { + length?: number + height?: number + leftWidth?: number + rightWidth?: number + }, + ) => { if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) + + const roof = nodes[selectedId as AnyNode['id']] as RoofNode | undefined + if (!roof) return + + const dimensions = getRoofDimensions(roof, nodes) + const nextLeftWidth = updates.leftWidth ?? dimensions.leftWidth + const nextRightWidth = updates.rightWidth ?? dimensions.rightWidth + + const batchedUpdates: Array<{ id: AnyNodeId; data: Partial }> = [ + { + id: selectedId as AnyNodeId, + data: updates as Partial, + }, + ] + + if (dimensions.primarySegment) { + batchedUpdates.push({ + id: dimensions.primarySegment.id as AnyNodeId, + data: { + width: updates.length ?? dimensions.primarySegment.width, + depth: nextLeftWidth + nextRightWidth, + roofHeight: updates.height ?? dimensions.primarySegment.roofHeight, + } as Partial, + }) + } + + updateNodes(batchedUpdates) }, - [selectedId, updateNode], + [nodes, selectedId, updateNodes], ) const handleClose = useCallback(() => { @@ -32,7 +67,7 @@ export function RoofPanel() { if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null - const totalWidth = node.leftWidth + node.rightWidth + const { height, leftWidth, length, rightWidth, totalWidth } = getRoofDimensions(node, nodes) return (
Widths - Total: {totalWidth.toFixed(1)}m + Total: {formatLength(totalWidth, unitSystem)}
diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index 5e3a989a..76fcae56 100644 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' +import { formatArea } from '../../../lib/measurements' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { PanelSection } from '../controls/panel-section' @@ -12,6 +13,7 @@ import { PanelWrapper } from './panel-wrapper' export function SlabPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) + const unitSystem = useViewer((s) => s.unitSystem) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) const updateNode = useScene((s) => s.updateNode) @@ -138,7 +140,7 @@ export function SlabPanel() {
Area - {area.toFixed(2)} m² + {formatArea(area, unitSystem)}
@@ -165,7 +167,7 @@ export function SlabPanel() { Hole {index + 1} {isEditing && '(Editing)'}

- {holeArea.toFixed(2)} m² · {hole.length} pts + {formatArea(holeArea, unitSystem)} · {hole.length} pts

diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index aa210617..816c5843 100644 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -3,76 +3,158 @@ import { type AnyNode, type AnyNodeId, useScene, type WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' +import { formatLength, METERS_PER_INCH } from '../../../lib/measurements' import { PanelSection } from '../controls/panel-section' +import { MetricControl } from '../controls/metric-control' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' +const DEFAULT_WALL_HEIGHT = 2.5 +const DEFAULT_WALL_THICKNESS = 0.1 +const VALUE_TOLERANCE = 1e-4 + +const getWallLength = (wall: WallNode) => { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + return Math.sqrt(dx * dx + dz * dz) +} + +const getUniformValue = (values: number[]) => { + if (values.length === 0) return null + + const firstValue = values[0]! + return values.every((value) => Math.abs(value - firstValue) <= VALUE_TOLERANCE) ? firstValue : null +} + export function WallPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) + const unitSystem = useViewer((s) => s.unitSystem) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) - const updateNode = useScene((s) => s.updateNode) + const updateNodes = useScene((s) => s.updateNodes) - const selectedId = selectedIds[0] - const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined + const wallNodes = selectedIds + .map((selectedId) => nodes[selectedId as AnyNode['id']]) + .filter((node): node is WallNode => Boolean(node && node.type === 'wall')) - const handleUpdate = useCallback( - (updates: Partial) => { - if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) - useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + const node = wallNodes[0] + const isSingleWall = wallNodes.length === 1 + const selectionCount = wallNodes.length + + const handleBatchUpdate = useCallback( + (getUpdates: (wall: WallNode) => Partial) => { + if (wallNodes.length === 0) return + + updateNodes(wallNodes.map((wall) => ({ id: wall.id, data: getUpdates(wall) }))) + for (const wall of wallNodes) { + useScene.getState().dirtyNodes.add(wall.id as AnyNodeId) + } }, - [selectedId, updateNode], + [updateNodes, wallNodes], ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) - if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null + if (!node || wallNodes.length !== selectedIds.length) return null + + const height = getUniformValue(wallNodes.map((wall) => wall.height ?? DEFAULT_WALL_HEIGHT)) + const thickness = getUniformValue(wallNodes.map((wall) => wall.thickness ?? DEFAULT_WALL_THICKNESS)) + const length = getUniformValue(wallNodes.map(getWallLength)) + const hasMixedDimensionValues = height === null || thickness === null + const title = isSingleWall ? node.name || 'Wall' : `${selectionCount} Walls` - const dx = node.end[0] - node.start[0] - const dz = node.end[1] - node.start[1] - const length = Math.sqrt(dx * dx + dz * dz) + const handleLengthChange = useCallback( + (nextLength: number) => { + const resolvedLength = Math.max(METERS_PER_INCH, nextLength) - const height = node.height ?? 2.5 - const thickness = node.thickness ?? 0.1 + handleBatchUpdate((wall) => { + const directionX = wall.end[0] - wall.start[0] + const directionZ = wall.end[1] - wall.start[1] + const currentLength = Math.hypot(directionX, directionZ) + const unitX = currentLength > 1e-6 ? directionX / currentLength : 1 + const unitZ = currentLength > 1e-6 ? directionZ / currentLength : 0 + + return { + end: [wall.start[0] + unitX * resolvedLength, wall.start[1] + unitZ * resolvedLength], + } + }) + }, + [handleBatchUpdate], + ) return ( - handleUpdate({ height: Math.max(0.1, v) })} - precision={2} - step={0.1} - unit="m" - value={Math.round(height * 100) / 100} - /> - handleUpdate({ thickness: Math.max(0.05, v) })} - precision={3} - step={0.01} - unit="m" - value={Math.round(thickness * 1000) / 1000} - /> + {height !== null && ( + + handleBatchUpdate(() => ({ height: Math.max(0.1, value) })) + } + precision={2} + step={0.1} + unit="m" + value={Math.round(height * 100) / 100} + /> + )} + {thickness !== null && ( + + handleBatchUpdate(() => ({ thickness: Math.max(0.05, value) })) + } + precision={3} + step={0.01} + unit="m" + value={Math.round(thickness * 1000) / 1000} + /> + )} + {!isSingleWall && hasMixedDimensionValues && ( +

+ Only shared wall dimensions are editable in bulk. Mixed values stay read-only until the + selection matches. +

+ )}
-
- Length - {length.toFixed(2)} m -
+ {length !== null ? ( + + ) : ( +
+ Length + + Mixed lengths + +
+ )} + {!isSingleWall && length !== null && ( +

+ Double-click Length to set every selected wall to {formatLength(length, unitSystem)} and + then drag or type a new shared value. +

+ )}
) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 3305ebc9..98686e90 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -19,6 +19,7 @@ import { } from './../../../../../components/ui/primitives/dialog' import { Switch } from './../../../../../components/ui/primitives/switch' import useEditor from './../../../../../store/use-editor' +import { SegmentedControl } from '../../../controls/segmented-control' import { AudioSettingsDialog } from './audio-settings-dialog' import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog' @@ -182,6 +183,8 @@ export function SettingsPanel({ const clearScene = useScene((state) => state.clearScene) const resetSelection = useViewer((state) => state.resetSelection) const exportScene = useViewer((state) => state.exportScene) + const unitSystem = useViewer((state) => state.unitSystem) + const setUnitSystem = useViewer((state) => state.setUnitSystem) const setPhase = useEditor((state) => state.setPhase) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) const sceneGraphValue = useMemo( @@ -314,6 +317,31 @@ export function SettingsPanel({
)} +
+ +
+
+
+
Units
+
+ Live dimensions, manual entry, and panel readouts +
+
+
+ Snap: 1 in +
+
+ +
+
+ {/* Export Section */}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index 0663ebe5..5b2bfb4a 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -38,6 +38,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { keys: ['3'], action: 'Switch to Furnish phase' }, { keys: ['S'], action: 'Switch to Structure layer' }, { keys: ['F'], action: 'Switch to Furnish layer' }, + { keys: ['M'], action: 'Jump to the Measure tool' }, { keys: ['Z'], action: 'Switch to Zones layer' }, { keys: ['Cmd/Ctrl', 'Arrow Up'], @@ -79,9 +80,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ shortcuts: [ { keys: ['Shift'], - action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings', + action: 'Temporarily disable angle snapping while drawing or measuring', note: 'Hold while drawing.', }, + { + keys: ['Tab'], + action: 'Type an exact wall, slab segment, or measurement distance', + note: 'Available while a draw segment is active.', + }, ], }, { diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx index 82db3e49..2cc6a2f0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -2,7 +2,9 @@ import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { useEffect, useState } from 'react' +import { formatArea } from '../../../../../lib/measurements' import useEditor from './../../../../../store/use-editor' +import { calculatePolygonArea } from './polygon-math' import { InlineRenameInput } from './inline-rename-input' import { handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' @@ -17,6 +19,7 @@ export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) { const [expanded, setExpanded] = useState(false) const [isEditing, setIsEditing] = useState(false) const selectedIds = useViewer((state) => state.selection.selectedIds) + const unitSystem = useViewer((state) => state.unitSystem) const isSelected = selectedIds.includes(node.id) const isHovered = useViewer((state) => state.hoveredId === node.id) const setSelection = useViewer((state) => state.setSelection) @@ -63,8 +66,7 @@ export function CeilingTreeNode({ node, depth, isLast }: CeilingTreeNodeProps) { } // Calculate approximate area from polygon - const area = calculatePolygonArea(node.polygon).toFixed(1) - const defaultName = `Ceiling (${area}m²)` + const defaultName = `Ceiling (${formatArea(calculatePolygonArea(node.polygon), unitSystem)})` return ( ) } - -/** - * Calculate the area of a polygon using the shoelace formula - */ -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 -} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 32d9cb4d..4bbf5a0f 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -28,6 +28,7 @@ import { } from 'lucide-react' import { AnimatePresence, LayoutGroup, motion } from 'motion/react' import { useEffect, useRef, useState } from 'react' +import { formatArea, formatLength } from '../../../../../lib/measurements' import { ColorDot } from './../../../../../components/ui/primitives/color-dot' import { Popover, @@ -38,6 +39,7 @@ import { cn } from './../../../../../lib/utils' import useEditor from './../../../../../store/use-editor' import { useUploadStore } from '../../../../../store/use-upload' import { InlineRenameInput } from './inline-rename-input' +import { MeasurementGuideTreeNode } from './measurement-guide-tree-node' import { TreeNode } from './tree-node' // ============================================================================ @@ -82,6 +84,7 @@ function useSiteNode(): SiteNode | null { function PropertyLineSection() { const siteNode = useSiteNode() const updateNode = useScene((state) => state.updateNode) + const unitSystem = useViewer((state) => state.unitSystem) const mode = useEditor((state) => state.mode) const setMode = useEditor((state) => state.setMode) @@ -157,10 +160,10 @@ function PropertyLineSection() { {/* Measurements */}
- Area: {area.toFixed(1)} m² + Area: {formatArea(area, unitSystem)}
- Perimeter: {perimeter.toFixed(1)} m + Perimeter: {formatLength(perimeter, unitSystem)}
@@ -986,6 +989,7 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) { const updateNode = useScene((state) => state.updateNode) const selectedZoneId = useViewer((state) => state.selection.zoneId) const hoveredId = useViewer((state) => state.hoveredId) + const unitSystem = useViewer((state) => state.unitSystem) const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) const setPhase = useEditor((state) => state.setPhase) @@ -1002,8 +1006,7 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) { } }, [isSelected]) - const area = calculatePolygonArea(zone.polygon).toFixed(1) - const defaultName = `Zone (${area}m²)` + const defaultName = `Zone (${formatArea(calculatePolygonArea(zone.polygon), unitSystem)})` const handleClick = () => { setSelection({ zoneId: zone.id }) @@ -1168,6 +1171,7 @@ function ContentSection() { const nodes = useScene((state) => state.nodes) const selectedLevelId = useViewer((state) => state.selection.levelId) const structureLayer = useEditor((state) => state.structureLayer) + const measurementGuides = useEditor((state) => state.measurementGuides) const phase = useEditor((state) => state.phase) const setPhase = useEditor((state) => state.setPhase) const setMode = useEditor((state) => state.setMode) @@ -1224,19 +1228,34 @@ function ContentSection() { return true }) - if (elementChildren.length === 0) { + const levelMeasurementGuides = measurementGuides.filter((guide) => guide.levelId === selectedLevelId) + const rows = [ + ...levelMeasurementGuides.map((guide) => ({ type: 'measurement' as const, guide })), + ...elementChildren.map((childId) => ({ type: 'node' as const, childId })), + ] + + if (rows.length === 0) { return
No elements on this level
} return (
- {elementChildren.map((childId, index) => ( - + {rows.map((row, index) => ( + row.type === 'measurement' ? ( + + ) : ( + + ) ))}
) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-guide-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-guide-tree-node.tsx new file mode 100644 index 00000000..78df9c71 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/measurement-guide-tree-node.tsx @@ -0,0 +1,95 @@ +import { useViewer } from '@pascal-app/viewer' +import { Eye, EyeOff, Ruler, Trash2 } from 'lucide-react' +import { formatLength } from './../../../../../lib/measurements' +import useEditor, { type MeasurementGuide } from './../../../../../store/use-editor' +import { TreeNodeWrapper } from './tree-node' + +interface MeasurementGuideTreeNodeProps { + guide: MeasurementGuide + depth: number + isLast?: boolean +} + +function MeasurementGuideTreeActions({ guide }: { guide: MeasurementGuide }) { + const selectedMeasurementGuideId = useEditor((state) => state.selectedMeasurementGuideId) + const updateMeasurementGuide = useEditor((state) => state.updateMeasurementGuide) + const deleteMeasurementGuide = useEditor((state) => state.deleteMeasurementGuide) + + const isVisible = guide.visible !== false + + const handleToggleVisibility = (e: React.MouseEvent) => { + e.stopPropagation() + updateMeasurementGuide(guide.id, { visible: !isVisible }) + } + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation() + deleteMeasurementGuide(guide.id) + } + + return ( +
+ + + {selectedMeasurementGuideId === guide.id && Selected} +
+ ) +} + +export function MeasurementGuideTreeNode({ + guide, + depth, + isLast, +}: MeasurementGuideTreeNodeProps) { + const unitSystem = useViewer((state) => state.unitSystem) + const setSelection = useViewer((state) => state.setSelection) + const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) + const selectedMeasurementGuideId = useEditor((state) => state.selectedMeasurementGuideId) + const setSelectedMeasurementGuideId = useEditor((state) => state.setSelectedMeasurementGuideId) + const hoveredMeasurementGuideId = useEditor((state) => state.hoveredMeasurementGuideId) + const setHoveredMeasurementGuideId = useEditor((state) => state.setHoveredMeasurementGuideId) + + const distance = Math.hypot(guide.end[0] - guide.start[0], guide.end[1] - guide.start[1]) + const label = `${guide.name ?? 'Measurement'} · ${formatLength(distance, unitSystem)}` + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + setSelection({ selectedIds: [], zoneId: null }) + setSelectedReferenceId(null) + setSelectedMeasurementGuideId(guide.id) + } + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={} + isHovered={hoveredMeasurementGuideId === guide.id} + isLast={isLast} + isSelected={selectedMeasurementGuideId === guide.id} + isVisible={guide.visible !== false} + label={label} + nodeId={guide.id} + onClick={handleClick} + onMouseEnter={() => setHoveredMeasurementGuideId(guide.id)} + onMouseLeave={() => setHoveredMeasurementGuideId(null)} + onToggle={() => {}} + /> + ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/polygon-math.ts b/packages/editor/src/components/ui/sidebar/panels/site-panel/polygon-math.ts new file mode 100644 index 00000000..aaf4ad31 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/polygon-math.ts @@ -0,0 +1,14 @@ +export 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 +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx index 369cae36..3bea83ca 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx @@ -1,7 +1,8 @@ -import type { RoofNode } from '@pascal-app/core' +import { type RoofNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { useState } from 'react' +import { getRoofDimensions } from './../../../../../lib/roof-dimensions' import useEditor from './../../../../../store/use-editor' import { InlineRenameInput } from './inline-rename-input' import { handleTreeSelection, TreeNodeWrapper } from './tree-node' @@ -15,6 +16,7 @@ interface RoofTreeNodeProps { export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) + const nodes = useScene((state) => state.nodes) const selectedIds = useViewer((state) => state.selection.selectedIds) const isSelected = selectedIds.includes(node.id) const isHovered = useViewer((state) => state.hoveredId === node.id) @@ -41,9 +43,8 @@ export function RoofTreeNode({ node, depth, isLast }: RoofTreeNodeProps) { setHoveredId(null) } - // Calculate dimensions: length × total width (leftWidth + rightWidth) - const totalWidth = node.leftWidth + node.rightWidth - const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m` + const { length, totalWidth } = getRoofDimensions(node, nodes) + const sizeLabel = `${length.toFixed(1)}×${totalWidth.toFixed(1)}m` const defaultName = `Roof (${sizeLabel})` return ( diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx index fb03c42e..6bba06ba 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -2,7 +2,9 @@ import type { SlabNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { useState } from 'react' +import { formatArea } from '../../../../../lib/measurements' import useEditor from './../../../../../store/use-editor' +import { calculatePolygonArea } from './polygon-math' import { InlineRenameInput } from './inline-rename-input' import { handleTreeSelection, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' @@ -16,6 +18,7 @@ interface SlabTreeNodeProps { export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) const selectedIds = useViewer((state) => state.selection.selectedIds) + const unitSystem = useViewer((state) => state.unitSystem) const isSelected = selectedIds.includes(node.id) const isHovered = useViewer((state) => state.hoveredId === node.id) const setSelection = useViewer((state) => state.setSelection) @@ -42,8 +45,7 @@ export function SlabTreeNode({ node, depth, isLast }: SlabTreeNodeProps) { } // Calculate approximate area from polygon - const area = calculatePolygonArea(node.polygon).toFixed(1) - const defaultName = `Slab (${area}m²)` + const defaultName = `Slab (${formatArea(calculatePolygonArea(node.polygon), unitSystem)})` return ( ) } - -/** - * Calculate the area of a polygon using the shoelace formula - */ -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 -} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx index 69453d3d..032ff6fd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx @@ -1,7 +1,9 @@ import { useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useState } from 'react' +import { formatArea } from '../../../../../lib/measurements' import { ColorDot } from './../../../../../components/ui/primitives/color-dot' +import { calculatePolygonArea } from './polygon-math' import { InlineRenameInput } from './inline-rename-input' import { TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' @@ -15,6 +17,7 @@ interface ZoneTreeNodeProps { export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) const updateNode = useScene((state) => state.updateNode) + const unitSystem = useViewer((state) => state.unitSystem) const isSelected = useViewer((state) => state.selection.zoneId === node.id) const isHovered = useViewer((state) => state.hoveredId === node.id) const setSelection = useViewer((state) => state.setSelection) @@ -37,8 +40,7 @@ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) { } // Calculate approximate area from polygon - const area = calculatePolygonArea(node.polygon).toFixed(1) - const defaultName = `Zone (${area}m²)` + const defaultName = `Zone (${formatArea(calculatePolygonArea(node.polygon), unitSystem)})` return ( ) } - -/** - * Calculate the area of a polygon using the shoelace formula - */ -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 -} diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts index 50beff88..3a8f2e01 100644 --- a/packages/editor/src/hooks/use-contextual-tools.ts +++ b/packages/editor/src/hooks/use-contextual-tools.ts @@ -16,7 +16,15 @@ export function useContextualTools() { } // Default tools when nothing is selected - const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window'] + const defaultTools: StructureTool[] = [ + 'measure', + 'wall', + 'slab', + 'ceiling', + 'roof', + 'door', + 'window', + ] if (selection.selectedIds.length === 0) { return defaultTools @@ -29,12 +37,12 @@ export function useContextualTools() { // If a wall is selected, prioritize wall-hosted elements if (selectedTypes.has('wall')) { - return ['window', 'door', 'wall'] as StructureTool[] + return ['measure', 'window', 'door', 'wall'] as StructureTool[] } // If a slab is selected, prioritize slab editing if (selectedTypes.has('slab')) { - return ['slab', 'wall'] as StructureTool[] + return ['measure', 'slab', 'wall'] as StructureTool[] } // If a ceiling is selected, prioritize ceiling editing diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index b558331c..9ba00658 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -19,6 +19,8 @@ export const useKeyboard = () => { // Clear selections to close UI panels, but KEEP the active building and level context useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) useEditor.getState().setSelectedReferenceId(null) + useEditor.getState().setSelectedMeasurementGuideId(null) + useEditor.getState().setHoveredMeasurementGuideId(null) } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('site') @@ -38,6 +40,12 @@ export const useKeyboard = () => { } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('furnish') + } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('build') + useEditor.getState().setTool('measure') } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('structure') @@ -49,12 +57,12 @@ export const useKeyboard = () => { } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setMode('build') - } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - useScene.temporal.getState().undo() - } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { + } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { e.preventDefault() useScene.temporal.getState().redo() + } else if (e.key.toLowerCase() === 'z' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + useScene.temporal.getState().undo() } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { e.preventDefault() const { buildingId, levelId } = useViewer.getState().selection @@ -91,6 +99,7 @@ export const useKeyboard = () => { e.preventDefault() const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] + const { deleteMeasurementGuide, selectedMeasurementGuideId } = useEditor.getState() if (selectedNodeIds.length > 0) { // Play appropriate SFX based on what's being deleted @@ -106,6 +115,9 @@ export const useKeyboard = () => { } useScene.getState().deleteNodes(selectedNodeIds) + } else if (selectedMeasurementGuideId) { + sfxEmitter.emit('sfx:structure-delete') + deleteMeasurementGuide(selectedMeasurementGuideId) } } } diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts new file mode 100644 index 00000000..b139b408 --- /dev/null +++ b/packages/editor/src/lib/measurements.ts @@ -0,0 +1,142 @@ +export type UnitSystem = 'metric' | 'imperial' + +export const METERS_PER_INCH = 0.0254 +export const INCHES_PER_FOOT = 12 +export const METERS_PER_FOOT = METERS_PER_INCH * INCHES_PER_FOOT +export const SQUARE_FEET_PER_SQUARE_METER = 10.763910416709722 + +const trimFixed = (value: number, precision: number) => + value + .toFixed(precision) + .replace(/\.0+$/, '') + .replace(/(\.\d*?)0+$/, '$1') + +const formatMetricLength = (meters: number, precision?: number) => { + const resolvedPrecision = + precision ?? (Math.abs(meters) >= 10 ? 1 : Math.abs(meters) >= 1 ? 2 : 3) + return trimFixed(meters, resolvedPrecision) +} + +const formatImperialLength = ( + meters: number, + options?: { + includeZeroInches?: boolean + }, +) => { + const totalInches = Math.round(Math.abs(meters) / METERS_PER_INCH) + const feet = Math.floor(totalInches / INCHES_PER_FOOT) + const inches = totalInches % INCHES_PER_FOOT + const sign = meters < 0 ? '-' : '' + + if (feet > 0) { + if (inches > 0 || options?.includeZeroInches) { + return `${sign}${feet}' ${inches}"` + } + return `${sign}${feet}'` + } + + return `${sign}${inches}"` +} + +const parseMetricLength = (value: string) => { + const match = value.match( + /^(-?\d+(?:\.\d+)?)\s*(mm|millimeters?|cm|centimeters?|m|meters?|metres?)$/, + ) + if (!match) return null + + const amount = Number.parseFloat(match[1]!) + const unit = match[2]! + + if (!Number.isFinite(amount)) return null + if (unit.startsWith('mm')) return amount / 1000 + if (unit.startsWith('cm')) return amount / 100 + return amount +} + +const parseImperialLength = (value: string) => { + const normalized = value + .replace(/[′’]/g, "'") + .replace(/[″”“]/g, '"') + .replace(/\b(feet|foot|ft)\b/g, "'") + .replace(/\b(inches|inch|in)\b/g, '"') + .replace(/\s+/g, ' ') + .trim() + + if (!(normalized.includes("'") || normalized.includes('"'))) return null + + const sign = normalized.startsWith('-') ? -1 : 1 + const feetMatch = normalized.match(/(-?\d+(?:\.\d+)?)\s*'/) + const inchesMatch = normalized.match(/(-?\d+(?:\.\d+)?)\s*"/) + + if (!(feetMatch || inchesMatch)) return null + + const feet = Math.abs(Number.parseFloat(feetMatch?.[1] ?? '0')) + const inches = Math.abs(Number.parseFloat(inchesMatch?.[1] ?? '0')) + + if (!(Number.isFinite(feet) && Number.isFinite(inches))) return null + + return sign * (feet * METERS_PER_FOOT + inches * METERS_PER_INCH) +} + +export const formatLength = ( + meters: number, + unitSystem: UnitSystem, + options?: { + compact?: boolean + includeZeroInches?: boolean + precision?: number + }, +) => { + if (!Number.isFinite(meters)) return '--' + + if (unitSystem === 'imperial') { + return formatImperialLength(meters, { + includeZeroInches: options?.includeZeroInches, + }) + } + + const value = formatMetricLength(meters, options?.precision) + return options?.compact ? `${value}m` : `${value} m` +} + +export const formatLengthInputValue = (meters: number, unitSystem: UnitSystem) => { + if (!Number.isFinite(meters)) return '' + + if (unitSystem === 'imperial') { + return formatImperialLength(meters, { includeZeroInches: true }) + } + + return formatMetricLength(meters) +} + +export const getLengthInputUnitLabel = (unitSystem: UnitSystem) => + unitSystem === 'imperial' ? 'ft/in' : 'm' + +export const parseLengthInput = (value: string, preferredUnitSystem: UnitSystem) => { + const normalized = value.trim().toLowerCase() + if (!normalized) return null + + const imperial = parseImperialLength(normalized) + if (imperial !== null) return imperial + + const metric = parseMetricLength(normalized) + if (metric !== null) return metric + + const parsed = Number.parseFloat(normalized) + if (!Number.isFinite(parsed)) return null + + return preferredUnitSystem === 'imperial' ? parsed * METERS_PER_FOOT : parsed +} + +export const formatArea = (squareMeters: number, unitSystem: UnitSystem) => { + if (!Number.isFinite(squareMeters)) return '--' + + if (unitSystem === 'imperial') { + const squareFeet = squareMeters * SQUARE_FEET_PER_SQUARE_METER + const precision = squareFeet >= 100 ? 0 : squareFeet >= 10 ? 1 : 2 + return `${trimFixed(squareFeet, precision)} ft²` + } + + const precision = squareMeters >= 100 ? 0 : squareMeters >= 10 ? 1 : 2 + return `${trimFixed(squareMeters, precision)} m²` +} diff --git a/packages/editor/src/lib/roof-dimensions.ts b/packages/editor/src/lib/roof-dimensions.ts new file mode 100644 index 00000000..d258efba --- /dev/null +++ b/packages/editor/src/lib/roof-dimensions.ts @@ -0,0 +1,60 @@ +import type { AnyNode, RoofNode, RoofSegmentNode } from '@pascal-app/core' + +type LegacyRoofDimensions = { + length?: number + height?: number + leftWidth?: number + rightWidth?: number +} + +const DEFAULT_ROOF_LENGTH = 8 +const DEFAULT_ROOF_HEIGHT = 2.5 +const DEFAULT_ROOF_SIDE_WIDTH = 3 + +export function getRoofDimensions( + roof: RoofNode, + nodes: Record, +): { + length: number + height: number + leftWidth: number + rightWidth: number + totalWidth: number + primarySegment: RoofSegmentNode | null +} { + const legacyRoof = roof as RoofNode & LegacyRoofDimensions + const primarySegment = + (roof.children ?? []) + .map((childId) => nodes[childId]) + .find((child): child is RoofSegmentNode => child?.type === 'roof-segment') ?? null + + const length = + typeof legacyRoof.length === 'number' + ? legacyRoof.length + : (primarySegment?.width ?? DEFAULT_ROOF_LENGTH) + const height = + typeof legacyRoof.height === 'number' + ? legacyRoof.height + : (primarySegment?.roofHeight ?? DEFAULT_ROOF_HEIGHT) + const leftWidth = + typeof legacyRoof.leftWidth === 'number' + ? legacyRoof.leftWidth + : primarySegment + ? primarySegment.depth / 2 + : DEFAULT_ROOF_SIDE_WIDTH + const rightWidth = + typeof legacyRoof.rightWidth === 'number' + ? legacyRoof.rightWidth + : primarySegment + ? primarySegment.depth / 2 + : DEFAULT_ROOF_SIDE_WIDTH + + return { + length, + height, + leftWidth, + rightWidth, + totalWidth: leftWidth + rightWidth, + primarySegment, + } +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 2542d632..f3819119 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -19,6 +19,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build' // Structure mode tools (building elements) export type StructureTool = + | 'measure' | 'wall' | 'room' | 'custom-room' @@ -50,6 +51,16 @@ export type CatalogCategory = export type StructureLayer = 'zones' | 'elements' +export type MeasurementGuide = { + id: string + name?: string + levelId: LevelNode['id'] + levelY: number + start: [number, number] + end: [number, number] + visible?: boolean +} + // Combined tool type export type Tool = SiteTool | StructureTool | FurnishTool @@ -70,6 +81,15 @@ type EditorState = { setMovingNode: (node: ItemNode | WindowNode | DoorNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void + selectedMeasurementGuideId: string | null + setSelectedMeasurementGuideId: (id: string | null) => void + hoveredMeasurementGuideId: string | null + setHoveredMeasurementGuideId: (id: string | null) => void + measurementGuides: MeasurementGuide[] + addMeasurementGuide: (guide: Omit & { id?: string }) => void + updateMeasurementGuide: (id: string, updates: Partial) => void + deleteMeasurementGuide: (id: string) => void + clearMeasurementGuides: (levelId?: LevelNode['id']) => void // Space detection for cutaway mode spaces: Record setSpaces: (spaces: Record) => void @@ -81,6 +101,14 @@ type EditorState = { setPreviewMode: (preview: boolean) => void } +const createMeasurementGuideId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + + return `measure-guide-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + const useEditor = create()((set, get) => ({ phase: 'site', setPhase: (phase) => { @@ -174,6 +202,7 @@ const useEditor = create()((set, get) => ({ selectedIds: [], zoneId: null, }) + set({ selectedMeasurementGuideId: null }) // Ensure a tool is selected in build mode if (!tool) { @@ -209,6 +238,7 @@ const useEditor = create()((set, get) => ({ selectedIds: [], zoneId: null, }) + set({ selectedMeasurementGuideId: null }) }, catalogCategory: null, setCatalogCategory: (category) => set({ catalogCategory: category }), @@ -218,6 +248,57 @@ const useEditor = create()((set, get) => ({ setMovingNode: (node) => set({ movingNode: node }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), + selectedMeasurementGuideId: null, + setSelectedMeasurementGuideId: (id) => set({ selectedMeasurementGuideId: id }), + hoveredMeasurementGuideId: null, + setHoveredMeasurementGuideId: (id) => set({ hoveredMeasurementGuideId: id }), + measurementGuides: [], + addMeasurementGuide: (guide) => + set((state) => ({ + measurementGuides: [ + ...state.measurementGuides, + { + ...guide, + id: guide.id ?? createMeasurementGuideId(), + name: guide.name ?? `Measurement ${state.measurementGuides.length + 1}`, + visible: guide.visible ?? true, + }, + ], + })), + updateMeasurementGuide: (id, updates) => + set((state) => ({ + measurementGuides: state.measurementGuides.map((guide) => + guide.id === id ? { ...guide, ...updates } : guide, + ), + })), + deleteMeasurementGuide: (id) => + set((state) => ({ + measurementGuides: state.measurementGuides.filter((guide) => guide.id !== id), + selectedMeasurementGuideId: + state.selectedMeasurementGuideId === id ? null : state.selectedMeasurementGuideId, + hoveredMeasurementGuideId: + state.hoveredMeasurementGuideId === id ? null : state.hoveredMeasurementGuideId, + })), + clearMeasurementGuides: (levelId) => + set((state) => { + const remainingGuides = levelId + ? state.measurementGuides.filter((guide) => guide.levelId !== levelId) + : [] + + return { + measurementGuides: remainingGuides, + selectedMeasurementGuideId: remainingGuides.some( + (guide) => guide.id === state.selectedMeasurementGuideId, + ) + ? state.selectedMeasurementGuideId + : null, + hoveredMeasurementGuideId: remainingGuides.some( + (guide) => guide.id === state.hoveredMeasurementGuideId, + ) + ? state.hoveredMeasurementGuideId + : null, + } + }), spaces: {}, setSpaces: (spaces) => set({ spaces }), editingHole: null, @@ -225,7 +306,13 @@ const useEditor = create()((set, get) => ({ isPreviewMode: false, setPreviewMode: (preview) => { if (preview) { - set({ isPreviewMode: true, mode: 'select', tool: null, catalogCategory: null }) + set({ + isPreviewMode: true, + mode: 'select', + tool: null, + catalogCategory: null, + selectedMeasurementGuideId: null, + }) // Clear zone/item selection for clean viewer drill-down hierarchy useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index 4d43ed19..53f56186 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -1,6 +1,6 @@ import { type RoofSegmentNode, useRegistry } from '@pascal-app/core' -import { useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import useViewer from '../../../store/use-viewer' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' @@ -12,18 +12,27 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const handlers = useNodeEvents(node, 'roof-segment') const debugColors = useViewer((s) => s.debugColors) + const placeholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + return geometry + }, []) + + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) return ( - {/* RoofSystem will replace this geometry in the next frame */} - - + /> ) } diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 01cff317..56d510a4 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -1,6 +1,6 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' -import { useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' @@ -13,6 +13,17 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) + const placeholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + return geometry + }, []) + + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) return ( { > - - + /> {(node.children ?? []).map((childId) => ( diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index a0f2b65d..ea374846 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -29,6 +29,9 @@ type ViewerState = { theme: 'light' | 'dark' setTheme: (theme: 'light' | 'dark') => void + unitSystem: 'metric' | 'imperial' + setUnitSystem: (unitSystem: 'metric' | 'imperial') => void + levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void @@ -81,6 +84,9 @@ const useViewer = create()( theme: 'light', setTheme: (theme) => set({ theme }), + unitSystem: 'metric', + setUnitSystem: (unitSystem) => set({ unitSystem }), + levelMode: 'stacked', setLevelMode: (mode) => set({ levelMode: mode }), @@ -187,6 +193,7 @@ const useViewer = create()( partialize: (state) => ({ cameraMode: state.cameraMode, theme: state.theme, + unitSystem: state.unitSystem, levelMode: state.levelMode, wallMode: state.wallMode, projectPreferences: state.projectPreferences, diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..34c3e38b --- /dev/null +++ b/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "outputDirectory": "apps/editor/.next" +}