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:
@@ -5165,8 +5165,82 @@ function FloorplanLinearDraftLayer({
|
|||||||
}
|
}
|
||||||
}, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls])
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{draftAxisGuideLines && (
|
||||||
|
<g pointerEvents="none">
|
||||||
|
{draftAxisGuideLines.map((line, index) => (
|
||||||
|
<line
|
||||||
|
key={index}
|
||||||
|
stroke="#818cf8"
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
strokeWidth={unitsPerPixel}
|
||||||
|
x1={line.x1}
|
||||||
|
x2={line.x2}
|
||||||
|
y1={line.y1}
|
||||||
|
y2={line.y2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
<FloorplanDraftLayer
|
<FloorplanDraftLayer
|
||||||
anchorFill={draftStroke}
|
anchorFill={draftStroke}
|
||||||
draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
|
draftAnchorPoints={EMPTY_DRAFT_ANCHOR_POINTS}
|
||||||
@@ -5195,6 +5269,8 @@ function FloorplanLinearDraftLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_DRAFT_ANCHOR_POINTS: Array<{ x: number; y: number; isPrimary: boolean }> = []
|
const EMPTY_DRAFT_ANCHOR_POINTS: Array<{ x: number; y: number; isPrimary: boolean }> = []
|
||||||
|
/** 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({
|
export function FloorplanPanel({
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
|
|
||||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -60,6 +59,14 @@ import {
|
|||||||
type Mesh,
|
type Mesh,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from 'three'
|
} 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_HEIGHT = 1.8
|
||||||
const FENCE_PREVIEW_THICKNESS = 0.08
|
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_Y_OFFSET = 0.012
|
||||||
const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32
|
const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32
|
||||||
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
|
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 = {
|
type DraftMeasurementState = {
|
||||||
lengthLabel: string
|
lengthLabel: string
|
||||||
@@ -498,6 +491,7 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const buildingState = useRef(0)
|
const buildingState = useRef(0)
|
||||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||||
|
const [axisGuide, setAxisGuide] = useState<DraftAxisGuideState>(null)
|
||||||
const measurementColor = isDark ? '#ffffff' : '#111111'
|
const measurementColor = isDark ? '#ffffff' : '#111111'
|
||||||
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
||||||
|
|
||||||
@@ -544,6 +538,7 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
previewRef.current.visible = false
|
previewRef.current.visible = false
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
|
setAxisGuide(null)
|
||||||
const draftPreview = useFloorplanDraftPreview.getState()
|
const draftPreview = useFloorplanDraftPreview.getState()
|
||||||
draftPreview.setFenceDraftStart(null)
|
draftPreview.setFenceDraftStart(null)
|
||||||
draftPreview.setFenceDraftEnd(null)
|
draftPreview.setFenceDraftEnd(null)
|
||||||
@@ -590,6 +585,16 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z])
|
draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z])
|
||||||
draftPreview.setFenceDraftEnd(snappedLocal)
|
draftPreview.setFenceDraftEnd(snappedLocal)
|
||||||
cursorRef.current.position.copy(endingPoint.current)
|
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]]
|
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
||||||
if (
|
if (
|
||||||
previousFenceEnd &&
|
previousFenceEnd &&
|
||||||
@@ -627,6 +632,7 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
)
|
)
|
||||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
|
setAxisGuide(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +664,12 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
triggerSFX('sfx:structure-build-start')
|
triggerSFX('sfx:structure-build-start')
|
||||||
previewRef.current.visible = true
|
previewRef.current.visible = true
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
|
setAxisGuide({
|
||||||
|
origin: snappedStart,
|
||||||
|
endOrigin: null,
|
||||||
|
y: event.localPosition[1],
|
||||||
|
angleLabel: null,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
const angleLocked = isAngleSnapActive()
|
const angleLocked = isAngleSnapActive()
|
||||||
const snappedEnd = alignPoint(
|
const snappedEnd = alignPoint(
|
||||||
@@ -709,6 +721,12 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
previewRef.current.visible = false
|
previewRef.current.visible = false
|
||||||
buildingState.current = 1
|
buildingState.current = 1
|
||||||
setDraftMeasurement(null)
|
setDraftMeasurement(null)
|
||||||
|
setAxisGuide({
|
||||||
|
origin: nextStart,
|
||||||
|
endOrigin: null,
|
||||||
|
y: event.localPosition[1],
|
||||||
|
angleLabel: null,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,6 +756,11 @@ const StraightFenceTool: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
<DraftAxisGuides
|
||||||
|
guide={axisGuide}
|
||||||
|
labelColor={measurementColor}
|
||||||
|
labelShadowColor={measurementShadowColor}
|
||||||
|
/>
|
||||||
<CursorSphere height={previewHeight} ref={cursorRef} />
|
<CursorSphere height={previewHeight} ref={cursorRef} />
|
||||||
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
||||||
<shapeGeometry />
|
<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
|
export default FenceTool
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<group position={[x, guide.y + DRAFT_AXIS_GUIDE_Y_OFFSET, z]}>
|
||||||
|
<DraftAxisGuideLine axis="x" />
|
||||||
|
<DraftAxisGuideLine axis="z" />
|
||||||
|
</group>
|
||||||
|
{guide.endOrigin && endRotationY !== null && (
|
||||||
|
<group
|
||||||
|
position={[guide.endOrigin[0], guide.y + DRAFT_AXIS_GUIDE_Y_OFFSET, guide.endOrigin[1]]}
|
||||||
|
>
|
||||||
|
<DraftAxisGuideLine rotationY={endRotationY} />
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
{guide.angleLabel && (
|
||||||
|
<>
|
||||||
|
<DraftAngleArc arc={guide.angleLabel.arc} color="#818cf8" />
|
||||||
|
<DraftMeasurementLabel
|
||||||
|
color={labelColor}
|
||||||
|
label={guide.angleLabel.label}
|
||||||
|
position={guide.angleLabel.position}
|
||||||
|
shadowColor={labelShadowColor}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftAxisGuideLine({ axis, rotationY }: { axis?: 'x' | 'z'; rotationY?: number }) {
|
||||||
|
const y = rotationY ?? (axis === 'z' ? Math.PI / 2 : 0)
|
||||||
|
return (
|
||||||
|
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={0} rotation={[0, y, 0]}>
|
||||||
|
<boxGeometry
|
||||||
|
args={[DRAFT_AXIS_GUIDE_LENGTH, DRAFT_AXIS_GUIDE_HEIGHT, DRAFT_AXIS_GUIDE_WIDTH]}
|
||||||
|
/>
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#818cf8"
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.36}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
<line frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={2}>
|
||||||
|
<lineBasicNodeMaterial
|
||||||
|
color={color}
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
linewidth={2}
|
||||||
|
opacity={0.95}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</line>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,10 +45,17 @@ import {
|
|||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three'
|
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).
|
* 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_Y_OFFSET = 0.012
|
||||||
const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32
|
const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32
|
||||||
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
|
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
|
// Grid-plane surface publish (pointer-decided): scratch + constant normal so
|
||||||
// per-move publishes don't allocate.
|
// per-move publishes don't allocate.
|
||||||
const SURFACE_UP = new Vector3(0, 1, 0)
|
const SURFACE_UP = new Vector3(0, 1, 0)
|
||||||
const surfacePointScratch = new Vector3()
|
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 = {
|
type DraftMeasurementState = {
|
||||||
lengthLabel: string
|
lengthLabel: string
|
||||||
lengthPosition: [number, number, number]
|
lengthPosition: [number, number, number]
|
||||||
angleLabels: DraftAngleLabel[]
|
angleLabels: DraftAngleLabel[]
|
||||||
} | null
|
} | null
|
||||||
|
|
||||||
type DraftAxisGuideState = {
|
|
||||||
origin: WallPlanPoint
|
|
||||||
y: number
|
|
||||||
angleLabel: DraftAngleLabel | null
|
|
||||||
} | null
|
|
||||||
|
|
||||||
type AxisAngleCandidate = {
|
|
||||||
angle: number
|
|
||||||
arc: {
|
|
||||||
startAngle: number
|
|
||||||
endAngle: number
|
|
||||||
midAngle: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FaceAngleCandidate = {
|
type FaceAngleCandidate = {
|
||||||
index: number
|
index: number
|
||||||
point: WallPlanPoint
|
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
|
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 {
|
function toWallPlanPoint(point: Point2D): WallPlanPoint {
|
||||||
return [point.x, point.y]
|
return [point.x, point.y]
|
||||||
}
|
}
|
||||||
@@ -691,6 +610,7 @@ export const WallTool: React.FC = () => {
|
|||||||
cursorRef.current.position.copy(endingPoint.current)
|
cursorRef.current.position.copy(endingPoint.current)
|
||||||
setAxisGuide({
|
setAxisGuide({
|
||||||
origin: [startingPoint.current.x, startingPoint.current.z],
|
origin: [startingPoint.current.x, startingPoint.current.z],
|
||||||
|
endOrigin: snappedLocal,
|
||||||
y: startingPoint.current.y,
|
y: startingPoint.current.y,
|
||||||
angleLabel: getNearestAxisAngleLabel(
|
angleLabel: getNearestAxisAngleLabel(
|
||||||
[startingPoint.current.x, startingPoint.current.z],
|
[startingPoint.current.x, startingPoint.current.z],
|
||||||
@@ -762,6 +682,7 @@ export const WallTool: React.FC = () => {
|
|||||||
draftPreview.setWallDraftEnd(snappedStart)
|
draftPreview.setWallDraftEnd(snappedStart)
|
||||||
setAxisGuide({
|
setAxisGuide({
|
||||||
origin: snappedStart,
|
origin: snappedStart,
|
||||||
|
endOrigin: null,
|
||||||
y: event.localPosition[1],
|
y: event.localPosition[1],
|
||||||
angleLabel: null,
|
angleLabel: null,
|
||||||
})
|
})
|
||||||
@@ -847,6 +768,7 @@ export const WallTool: React.FC = () => {
|
|||||||
buildingState.current = 1
|
buildingState.current = 1
|
||||||
setAxisGuide({
|
setAxisGuide({
|
||||||
origin: nextStart,
|
origin: nextStart,
|
||||||
|
endOrigin: null,
|
||||||
y: event.localPosition[1],
|
y: event.localPosition[1],
|
||||||
angleLabel: null,
|
angleLabel: null,
|
||||||
})
|
})
|
||||||
@@ -889,7 +811,7 @@ export const WallTool: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<WallAxisGuides
|
<DraftAxisGuides
|
||||||
guide={axisGuide}
|
guide={axisGuide}
|
||||||
labelColor={measurementColor}
|
labelColor={measurementColor}
|
||||||
labelShadowColor={measurementShadowColor}
|
labelShadowColor={measurementShadowColor}
|
||||||
@@ -931,127 +853,4 @@ 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 (
|
|
||||||
<>
|
|
||||||
<group position={[x, guide.y + DRAFT_AXIS_GUIDE_Y_OFFSET, z]}>
|
|
||||||
<WallAxisGuideLine axis="x" />
|
|
||||||
<WallAxisGuideLine axis="z" />
|
|
||||||
</group>
|
|
||||||
{guide.angleLabel && (
|
|
||||||
<>
|
|
||||||
<DraftAngleArc arc={guide.angleLabel.arc} color="#818cf8" />
|
|
||||||
<DraftMeasurementLabel
|
|
||||||
color={labelColor}
|
|
||||||
label={guide.angleLabel.label}
|
|
||||||
position={guide.angleLabel.position}
|
|
||||||
shadowColor={labelShadowColor}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WallAxisGuideLine({ axis }: { axis: 'x' | 'z' }) {
|
|
||||||
return (
|
|
||||||
<mesh
|
|
||||||
frustumCulled={false}
|
|
||||||
layers={EDITOR_LAYER}
|
|
||||||
renderOrder={0}
|
|
||||||
rotation={[0, axis === 'z' ? Math.PI / 2 : 0, 0]}
|
|
||||||
>
|
|
||||||
<boxGeometry
|
|
||||||
args={[DRAFT_AXIS_GUIDE_LENGTH, DRAFT_AXIS_GUIDE_HEIGHT, DRAFT_AXIS_GUIDE_WIDTH]}
|
|
||||||
/>
|
|
||||||
<meshBasicMaterial
|
|
||||||
color="#818cf8"
|
|
||||||
depthTest={false}
|
|
||||||
depthWrite={false}
|
|
||||||
opacity={0.36}
|
|
||||||
transparent
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 WallTool
|
export default WallTool
|
||||||
|
|||||||
Reference in New Issue
Block a user