diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index b3d8b1289..d26ce3ca9 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5165,8 +5165,82 @@ function FloorplanLinearDraftLayer({ } }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls]) + // Axis guides for wall and fence drafts — parity with the 3D tools' + // `DraftAxisGuides`: an X/Z cross through the draft start, and a single + // long line PERPENDICULAR to the segment through the moving endpoint (a + // second cross would collide with the start cross on axis-aligned + // segments). Long solid lines in world space — cheap for the SVG renderer, + // unlike dashed strokes which tessellate per dash over 2000 m. Stroke + // width is budgeted in screen pixels via `unitsPerPixel`, matching the + // alignment-guide layer. + const draftAxisGuideLines = useMemo(() => { + const lines: Array<{ x1: number; y1: number; x2: number; y2: number }> = [] + const pushCross = (point: WallPlanPoint) => { + lines.push( + { + x1: point[0] - DRAFT_AXIS_GUIDE_EXTENT, + y1: point[1], + x2: point[0] + DRAFT_AXIS_GUIDE_EXTENT, + y2: point[1], + }, + { + x1: point[0], + y1: point[1] - DRAFT_AXIS_GUIDE_EXTENT, + x2: point[0], + y2: point[1] + DRAFT_AXIS_GUIDE_EXTENT, + }, + ) + } + const pushDraft = (start: WallPlanPoint, end: WallPlanPoint) => { + pushCross(start) + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return + const nx = -dy / length + const ny = dx / length + lines.push({ + x1: end[0] - nx * DRAFT_AXIS_GUIDE_EXTENT, + y1: end[1] - ny * DRAFT_AXIS_GUIDE_EXTENT, + x2: end[0] + nx * DRAFT_AXIS_GUIDE_EXTENT, + y2: end[1] + ny * DRAFT_AXIS_GUIDE_EXTENT, + }) + } + if (isWallBuildActive && wallDraftStart && wallDraftEnd) { + pushDraft(wallDraftStart, wallDraftEnd) + } + if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) { + pushDraft(fenceDraftStart, fenceDraftEnd) + } + return lines.length > 0 ? lines : null + }, [ + isFenceBuildActive, + isWallBuildActive, + fenceDraftEnd, + fenceDraftStart, + wallDraftEnd, + wallDraftStart, + ]) + return ( <> + {draftAxisGuideLines && ( + + {draftAxisGuideLines.map((line, index) => ( + + ))} + + )} + = [] +/** World-space half-length of the 2D draft axis guide lines (matches the 3D tools' 2000 m guides). */ +const DRAFT_AXIS_GUIDE_EXTENT = 1000 export function FloorplanPanel({ /** diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 25dee57a5..763e787d5 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -48,7 +48,6 @@ import { } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { @@ -60,6 +59,14 @@ import { type Mesh, Vector3, } from 'three' +import { + DraftAngleArc, + type DraftAngleLabel, + type DraftAxisGuideState, + DraftAxisGuides, + DraftMeasurementLabel, + getNearestAxisAngleLabel, +} from '../shared/draft-axis-guides' const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_THICKNESS = 0.08 @@ -86,20 +93,6 @@ const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08 const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 -const DRAFT_ANGLE_ARC_SEGMENTS = 24 - -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] - arc: { - center: FencePlanPoint - radius: number - startAngle: number - endAngle: number - y: number - } -} type DraftMeasurementState = { lengthLabel: string @@ -498,6 +491,7 @@ const StraightFenceTool: React.FC = () => { const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) + const [axisGuide, setAxisGuide] = useState(null) const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' @@ -544,6 +538,7 @@ const StraightFenceTool: React.FC = () => { buildingState.current = 0 previewRef.current.visible = false setDraftMeasurement(null) + setAxisGuide(null) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setFenceDraftStart(null) draftPreview.setFenceDraftEnd(null) @@ -590,6 +585,16 @@ const StraightFenceTool: React.FC = () => { draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z]) draftPreview.setFenceDraftEnd(snappedLocal) cursorRef.current.position.copy(endingPoint.current) + setAxisGuide({ + origin: [startingPoint.current.x, startingPoint.current.z], + endOrigin: snappedLocal, + y: startingPoint.current.y, + angleLabel: getNearestAxisAngleLabel( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + startingPoint.current.y, + ), + }) const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] if ( previousFenceEnd && @@ -627,6 +632,7 @@ const StraightFenceTool: React.FC = () => { ) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) setDraftMeasurement(null) + setAxisGuide(null) } } @@ -658,6 +664,12 @@ const StraightFenceTool: React.FC = () => { triggerSFX('sfx:structure-build-start') previewRef.current.visible = true setDraftMeasurement(null) + setAxisGuide({ + origin: snappedStart, + endOrigin: null, + y: event.localPosition[1], + angleLabel: null, + }) } else { const angleLocked = isAngleSnapActive() const snappedEnd = alignPoint( @@ -709,6 +721,12 @@ const StraightFenceTool: React.FC = () => { previewRef.current.visible = false buildingState.current = 1 setDraftMeasurement(null) + setAxisGuide({ + origin: nextStart, + endOrigin: null, + y: event.localPosition[1], + angleLabel: null, + }) } } @@ -738,6 +756,11 @@ const StraightFenceTool: React.FC = () => { return ( + @@ -927,71 +950,4 @@ const SplineFenceDraft: React.FC = () => { ) } -function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { - const geometry = useMemo(() => { - const segmentCount = Math.max( - 8, - Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), - ) - - const points = Array.from({ length: segmentCount + 1 }, (_, index) => { - const t = index / segmentCount - const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t - - return new Vector3( - arc.center[0] + Math.cos(angle) * arc.radius, - arc.y, - arc.center[1] + Math.sin(angle) * arc.radius, - ) - }) - - return new BufferGeometry().setFromPoints(points) - }, [arc]) - - return ( - // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. - - - - ) -} - -function DraftMeasurementLabel({ - color, - label, - position, - shadowColor, -}: { - color: string - label: string - position: [number, number, number] - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - export default FenceTool diff --git a/packages/nodes/src/shared/draft-axis-guides.tsx b/packages/nodes/src/shared/draft-axis-guides.tsx new file mode 100644 index 000000000..50b52f84d --- /dev/null +++ b/packages/nodes/src/shared/draft-axis-guides.tsx @@ -0,0 +1,253 @@ +import { + EDITOR_LAYER, + formatAngleRadians, + getAngleArcToSegmentReference, + getAngleToSegmentReference, + type SegmentAngleReference, + type WallPlanPoint, +} from '@pascal-app/editor' +import { Html } from '@react-three/drei' +import { useMemo } from 'react' +import { BufferGeometry, Vector3 } from 'three' + +/** + * Axis guide lines + axis-angle readout shown while drafting linear segments + * (walls, fences). An X/Z cross of long thin boxes is drawn through the + * segment start so it can be aligned against the world axes; the moving + * endpoint gets a single long line PERPENDICULAR to the draft segment (a + * second cross there would overlap the start cross whenever the segment is + * axis-aligned). The angle to the nearest axis is shown as an arc + label + * anchored at the start point only (duplicating it at the endpoint is + * visual clutter). + */ +const DRAFT_AXIS_GUIDE_LENGTH = 2000 +const DRAFT_AXIS_GUIDE_WIDTH = 0.035 +const DRAFT_AXIS_GUIDE_HEIGHT = 0.004 +const DRAFT_AXIS_GUIDE_Y_OFFSET = 0.026 +const DRAFT_AXIS_ANGLE_ARC_Y_OFFSET = 0.05 +const DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET = 0.16 +const DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS = 0.36 +const DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS = 0.82 +const DRAFT_ANGLE_ARC_SEGMENTS = 24 +const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ + { vector: [1, 0], orientation: 'axis' }, + { vector: [0, 1], orientation: 'axis' }, +] + +export type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] + arc: { + center: WallPlanPoint + radius: number + startAngle: number + endAngle: number + y: number + } +} + +export type DraftAxisGuideState = { + origin: WallPlanPoint + endOrigin: WallPlanPoint | null + y: number + angleLabel: DraftAngleLabel | null +} | null + +type AxisAngleCandidate = { + angle: number + arc: { + startAngle: number + endAngle: number + midAngle: number + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +export function getNearestAxisAngleLabel( + start: WallPlanPoint, + end: WallPlanPoint, + y: number, +): DraftAngleLabel | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 0.01) return null + + const draftVector: WallPlanPoint = [dx, dz] + const axisCandidates: AxisAngleCandidate[] = [] + for (const reference of AXIS_ANGLE_REFERENCES) { + const angle = getAngleToSegmentReference(draftVector, reference) + const arc = getAngleArcToSegmentReference(draftVector, reference) + if (!(angle === null || arc === null)) { + axisCandidates.push({ angle, arc }) + } + } + const nearestAxisAngle = axisCandidates.sort((a, b) => a.angle - b.angle)[0] + if (!nearestAxisAngle) return null + + const radius = clamp( + length * 0.22, + DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS, + DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS, + ) + const { angle, arc } = nearestAxisAngle + + return { + id: 'axis', + label: formatAngleRadians(angle), + position: [ + start[0] + Math.cos(arc.midAngle) * (radius + 0.16), + y + DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET, + start[1] + Math.sin(arc.midAngle) * (radius + 0.16), + ], + arc: { + center: start, + radius, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + y: y + DRAFT_AXIS_ANGLE_ARC_Y_OFFSET, + }, + } +} + +export function DraftAxisGuides({ + guide, + labelColor, + labelShadowColor, +}: { + guide: DraftAxisGuideState + labelColor: string + labelShadowColor: string +}) { + if (!guide) return null + + const [x, z] = guide.origin + + // Single long line through the endpoint, perpendicular to the draft + // segment (a full axis cross there would collide with the start cross + // whenever the segment is axis-aligned). + let endRotationY: number | null = null + if (guide.endOrigin) { + const dx = guide.endOrigin[0] - x + const dz = guide.endOrigin[1] - z + if (dx * dx + dz * dz >= 0.01 * 0.01) { + endRotationY = Math.atan2(-dx, -dz) + } + } + + return ( + <> + + + + + {guide.endOrigin && endRotationY !== null && ( + + + + )} + {guide.angleLabel && ( + <> + + + + )} + + ) +} + +function DraftAxisGuideLine({ axis, rotationY }: { axis?: 'x' | 'z'; rotationY?: number }) { + const y = rotationY ?? (axis === 'z' ? Math.PI / 2 : 0) + return ( + + + + + ) +} + +export function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { + const geometry = useMemo(() => { + const segmentCount = Math.max( + 8, + Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), + ) + + const points = Array.from({ length: segmentCount + 1 }, (_, index) => { + const t = index / segmentCount + const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t + + return new Vector3( + arc.center[0] + Math.cos(angle) * arc.radius, + arc.y, + arc.center[1] + Math.sin(angle) * arc.radius, + ) + }) + + return new BufferGeometry().setFromPoints(points) + }, [arc]) + + return ( + // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. + + + + ) +} + +export function DraftMeasurementLabel({ + color, + label, + position, + shadowColor, +}: { + color: string + label: string + position: [number, number, number] + shadowColor: string +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 98ef3220f..800705904 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -45,10 +45,17 @@ import { type WallPlanPoint, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' import { useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef, useState } from 'react' -import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { useEffect, useRef, useState } from 'react' +import { BoxGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { + DraftAngleArc, + type DraftAngleLabel, + type DraftAxisGuideState, + DraftAxisGuides, + DraftMeasurementLabel, + getNearestAxisAngleLabel, +} from '../shared/draft-axis-guides' /** * Phase 5 Stage D — wall placement tool (kind-owned). @@ -73,59 +80,18 @@ const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08 const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 -const DRAFT_ANGLE_ARC_SEGMENTS = 24 -const DRAFT_AXIS_GUIDE_LENGTH = 2000 -const DRAFT_AXIS_GUIDE_WIDTH = 0.035 -const DRAFT_AXIS_GUIDE_HEIGHT = 0.004 -const DRAFT_AXIS_GUIDE_Y_OFFSET = 0.026 -const DRAFT_AXIS_ANGLE_ARC_Y_OFFSET = 0.05 -const DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET = 0.16 -const DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS = 0.36 -const DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS = 0.82 -const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ - { vector: [1, 0], orientation: 'axis' }, - { vector: [0, 1], orientation: 'axis' }, -] // Grid-plane surface publish (pointer-decided): scratch + constant normal so // per-move publishes don't allocate. const SURFACE_UP = new Vector3(0, 1, 0) const surfacePointScratch = new Vector3() -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] - arc: { - center: WallPlanPoint - radius: number - startAngle: number - endAngle: number - y: number - } -} - type DraftMeasurementState = { lengthLabel: string lengthPosition: [number, number, number] angleLabels: DraftAngleLabel[] } | null -type DraftAxisGuideState = { - origin: WallPlanPoint - y: number - angleLabel: DraftAngleLabel | null -} | null - -type AxisAngleCandidate = { - angle: number - arc: { - startAngle: number - endAngle: number - midAngle: number - } -} - type FaceAngleCandidate = { index: number point: WallPlanPoint @@ -166,53 +132,6 @@ function isWithinWallJoinSnapRadius(point: WallPlanPoint, vertex: Vector3) { return dx * dx + dz * dz <= WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS } -function getNearestAxisAngleLabel( - start: WallPlanPoint, - end: WallPlanPoint, - y: number, -): DraftAngleLabel | null { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - if (length < 0.01) return null - - const draftVector: WallPlanPoint = [dx, dz] - const axisCandidates: AxisAngleCandidate[] = [] - for (const reference of AXIS_ANGLE_REFERENCES) { - const angle = getAngleToSegmentReference(draftVector, reference) - const arc = getAngleArcToSegmentReference(draftVector, reference) - if (!(angle === null || arc === null)) { - axisCandidates.push({ angle, arc }) - } - } - const nearestAxisAngle = axisCandidates.sort((a, b) => a.angle - b.angle)[0] - if (!nearestAxisAngle) return null - - const radius = clamp( - length * 0.22, - DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS, - DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS, - ) - const { angle, arc } = nearestAxisAngle - - return { - id: 'axis', - label: formatAngleRadians(angle), - position: [ - start[0] + Math.cos(arc.midAngle) * (radius + 0.16), - y + DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET, - start[1] + Math.sin(arc.midAngle) * (radius + 0.16), - ], - arc: { - center: start, - radius, - startAngle: arc.startAngle, - endAngle: arc.endAngle, - y: y + DRAFT_AXIS_ANGLE_ARC_Y_OFFSET, - }, - } -} - function toWallPlanPoint(point: Point2D): WallPlanPoint { return [point.x, point.y] } @@ -691,6 +610,7 @@ export const WallTool: React.FC = () => { cursorRef.current.position.copy(endingPoint.current) setAxisGuide({ origin: [startingPoint.current.x, startingPoint.current.z], + endOrigin: snappedLocal, y: startingPoint.current.y, angleLabel: getNearestAxisAngleLabel( [startingPoint.current.x, startingPoint.current.z], @@ -762,6 +682,7 @@ export const WallTool: React.FC = () => { draftPreview.setWallDraftEnd(snappedStart) setAxisGuide({ origin: snappedStart, + endOrigin: null, y: event.localPosition[1], angleLabel: null, }) @@ -847,6 +768,7 @@ export const WallTool: React.FC = () => { buildingState.current = 1 setAxisGuide({ origin: nextStart, + endOrigin: null, y: event.localPosition[1], angleLabel: null, }) @@ -889,7 +811,7 @@ export const WallTool: React.FC = () => { return ( - { ) } -function WallAxisGuides({ - guide, - labelColor, - labelShadowColor, -}: { - guide: DraftAxisGuideState - labelColor: string - labelShadowColor: string -}) { - if (!guide) return null - - const [x, z] = guide.origin - - return ( - <> - - - - - {guide.angleLabel && ( - <> - - - - )} - - ) -} - -function WallAxisGuideLine({ axis }: { axis: 'x' | 'z' }) { - return ( - - - - - ) -} - -function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { - const geometry = useMemo(() => { - const segmentCount = Math.max( - 8, - Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), - ) - - const points = Array.from({ length: segmentCount + 1 }, (_, index) => { - const t = index / segmentCount - const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t - - return new Vector3( - arc.center[0] + Math.cos(angle) * arc.radius, - arc.y, - arc.center[1] + Math.sin(angle) * arc.radius, - ) - }) - - return new BufferGeometry().setFromPoints(points) - }, [arc]) - - return ( - // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. - - - - ) -} - -function DraftMeasurementLabel({ - color, - label, - position, - shadowColor, -}: { - color: string - label: string - position: [number, number, number] - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - export default WallTool