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