From 3750679d484f2e5097a568b249534be40999aa25 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sun, 7 Jun 2026 00:05:36 -0400 Subject: [PATCH] Add wall placement axis angle guides --- packages/nodes/src/wall/tool.tsx | 157 +++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 0d8825d2..67104a6a 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -58,6 +58,18 @@ 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' }, +] type DraftAngleLabel = { id: string @@ -78,6 +90,21 @@ type DraftMeasurementState = { 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 @@ -122,6 +149,53 @@ function pointMatches(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-5) { return distanceSquared(a, b) <= tolerance * tolerance } +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] } @@ -423,6 +497,7 @@ export const WallTool: React.FC = () => { const buildingState = useRef(0) const shiftPressed = useRef(false) const [draftMeasurement, setDraftMeasurement] = useState(null) + const [axisGuide, setAxisGuide] = useState(null) const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' @@ -463,6 +538,7 @@ export const WallTool: React.FC = () => { wallPreviewRef.current.visible = false } setDraftMeasurement(null) + setAxisGuide(null) useAlignmentGuides.getState().clear() } @@ -484,6 +560,15 @@ export const WallTool: React.FC = () => { const snappedLocal = gridPosition endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) cursorRef.current.position.copy(endingPoint.current) + setAxisGuide({ + origin: [startingPoint.current.x, startingPoint.current.z], + y: startingPoint.current.y, + angleLabel: getNearestAxisAngleLabel( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + startingPoint.current.y, + ), + }) const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] if ( @@ -514,6 +599,7 @@ export const WallTool: React.FC = () => { } else { cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1]) setDraftMeasurement(null) + setAxisGuide(null) } } @@ -538,6 +624,11 @@ export const WallTool: React.FC = () => { startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) endingPoint.current.copy(startingPoint.current) buildingState.current = 1 + setAxisGuide({ + origin: snappedStart, + y: event.localPosition[1], + angleLabel: null, + }) triggerSFX('sfx:structure-build-start') // Visibility is owned by `updateWallPreview` — it flips // `mesh.visible` based on segment length. Setting it here @@ -572,6 +663,11 @@ export const WallTool: React.FC = () => { endingPoint.current.copy(startingPoint.current) cursorRef.current?.position.copy(startingPoint.current) buildingState.current = 1 + setAxisGuide({ + origin: nextStart, + y: event.localPosition[1], + angleLabel: null, + }) // Hide the preview until the next `onGridMove` writes the // new segment's geometry. Without this the prior segment's // BoxGeometry stays visible for a frame on top of the @@ -615,6 +711,11 @@ export const WallTool: React.FC = () => { return ( + @@ -652,6 +753,62 @@ export const WallTool: React.FC = () => { ) } +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(