feat(nodes): draft axis guides at the moving endpoint + 2d parity (#534)

Wall/fence drafting already drew an X/Z axis cross at the segment start.
Now the moving endpoint gets a single long guide line perpendicular to the
draft segment (a second cross would collide with the start cross on
axis-aligned segments), fences gain the same guides they lacked entirely,
and the 2D floor plan renders the equivalent SVG guides (cross at start,
perpendicular line at end) fed by the floorplan draft preview store.

The guide components are extracted from wall/tool.tsx into
nodes/shared/draft-axis-guides.tsx so both tools (and the fence's formerly
duplicated arc/label helpers) share one implementation.
This commit is contained in:
Wassim SAMAD
2026-07-22 14:51:34 -04:00
committed by GitHub
parent 77442861d9
commit 26c9e17fed
4 changed files with 381 additions and 297 deletions
+38 -82
View File
@@ -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<DraftMeasurementState>(null)
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(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 (
<group>
<DraftAxisGuides
guide={axisGuide}
labelColor={measurementColor}
labelShadowColor={measurementShadowColor}
/>
<CursorSphere height={previewHeight} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry />
@@ -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.
<line frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={2}>
<lineBasicNodeMaterial
color={color}
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.95}
transparent
/>
</line>
)
}
function DraftMeasurementLabel({
color,
label,
position,
shadowColor,
}: {
color: string
label: string
position: [number, number, number]
shadowColor: string
}) {
return (
<Html
center
position={position}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div
className="whitespace-nowrap font-bold font-mono text-[15px]"
style={{
color,
textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
}}
>
{label}
</div>
</Html>
)
}
export default FenceTool