Add draft angle arcs for walls and fences

This commit is contained in:
sudhir
2026-05-20 00:41:11 +00:00
committed by open-pascal
parent e009f3658b
commit 6af3a0aa82
2 changed files with 353 additions and 42 deletions
+2
View File
@@ -53,8 +53,10 @@ export {
} from './components/tools/shared/polygon-editor' } from './components/tools/shared/polygon-editor'
export { export {
formatAngleRadians, formatAngleRadians,
getAngleArcToSegmentReference,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
type SegmentAngleReference,
} from './components/tools/shared/segment-angle' } from './components/tools/shared/segment-angle'
// Stair placement defaults — used by the kind-owned stair / stair-segment // Stair placement defaults — used by the kind-owned stair / stair-segment
// panels. Re-exported from `components/tools/stair/stair-defaults.ts`. // panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
+351 -42
View File
@@ -1,11 +1,15 @@
'use client' 'use client'
import { import {
calculateLevelMiters,
emitter, emitter,
type FenceNode, type FenceNode,
type GridEvent, type GridEvent,
getWallMiterBoundaryPoints,
type LevelNode, type LevelNode,
type Point2D,
useScene, useScene,
type WallMiterData,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
@@ -14,39 +18,39 @@ import {
EDITOR_LAYER, EDITOR_LAYER,
type FencePlanPoint, type FencePlanPoint,
formatAngleRadians, formatAngleRadians,
getAngleArcToSegmentReference,
getAngleToSegmentReference, getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint, getSegmentAngleReferenceAtPoint,
markToolCancelConsumed, markToolCancelConsumed,
type SegmentAngleReference,
snapFenceDraftPoint, snapFenceDraftPoint,
triggerSFX, triggerSFX,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three'
/**
* Phase 5 Stage D — fence placement tool (kind-owned via `def.tool`).
*
* Replaces the legacy `FenceTool` (346 LoC). Two-click placement flow:
* click 1 sets the start, click 2 creates the fence. Between clicks a
* preview rectangle and a length / angle measurement HUD follow the
* pointer (shift defeats angle snap).
*
* Not a `DragAction` — placement isn't a single pointer-drag, it's a
* sequence of discrete grid:click events with preview state across
* them. `useDragAction` doesn't fit; this component owns the lifecycle
* directly. The registry routes activation here via `def.tool`.
*/
const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_HEIGHT = 1.8
const FENCE_PREVIEW_THICKNESS = 0.08
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22 const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = 0.28 const DRAFT_ANGLE_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.08
const DRAFT_ANGLE_ARC_Y = FENCE_PREVIEW_HEIGHT + 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 = { type DraftAngleLabel = {
id: string id: string
label: string label: string
position: [number, number, number] position: [number, number, number]
arc: {
center: FencePlanPoint
radius: number
startAngle: number
endAngle: number
y: number
}
} }
type DraftMeasurementState = { type DraftMeasurementState = {
@@ -60,6 +64,25 @@ type SegmentLike = {
start: FencePlanPoint start: FencePlanPoint
end: FencePlanPoint end: FencePlanPoint
curveOffset?: number curveOffset?: number
thickness?: number
}
type FaceAngleCandidate = {
index: number
point: FencePlanPoint
vector: FencePlanPoint
}
type FaceAnglePair = {
draft: FaceAngleCandidate
connected: FaceAngleCandidate
distance: number
}
type AngleSource = {
arcCenter: FencePlanPoint
connectedVector: FencePlanPoint
draftVector: FencePlanPoint
} }
function formatMeasurement(value: number, unit: 'metric' | 'imperial') { function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
@@ -73,13 +96,183 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
return `${Number.parseFloat(value.toFixed(2))}m` return `${Number.parseFloat(value.toFixed(2))}m`
} }
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function distanceSquared(a: FencePlanPoint, b: FencePlanPoint) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function pointMatches(a: FencePlanPoint, b: FencePlanPoint, tolerance = 1e-5) {
return distanceSquared(a, b) <= tolerance * tolerance
}
function toFencePlanPoint(point: Point2D): FencePlanPoint {
return [point.x, point.y]
}
function toMiterWall(segment: SegmentLike): WallNode {
return {
object: 'node',
id: segment.id as WallNode['id'],
type: 'wall',
name: 'Fence reference',
parentId: null,
visible: true,
metadata: {},
children: [],
start: segment.start,
end: segment.end,
thickness: segment.thickness,
curveOffset: segment.curveOffset,
frontSide: 'unknown',
backSide: 'unknown',
}
}
function buildDraftFenceSegment(start: FencePlanPoint, end: FencePlanPoint): SegmentLike {
return {
id: 'fence_draft',
start,
end,
thickness: FENCE_PREVIEW_THICKNESS,
}
}
function getSegmentEndpointKind(
point: FencePlanPoint,
segment: SegmentLike,
): 'start' | 'end' | null {
if (pointMatches(point, segment.start)) return 'start'
if (pointMatches(point, segment.end)) return 'end'
return null
}
function getFenceFaceAngleCandidates(
point: FencePlanPoint,
segment: SegmentLike,
miterData: WallMiterData,
): FaceAngleCandidate[] {
const endpoint = getSegmentEndpointKind(point, segment)
const reference = getSegmentAngleReferenceAtPoint(point, segment)
if (!(endpoint && reference)) return []
const boundaryPoints = getWallMiterBoundaryPoints(toMiterWall(segment), miterData)
if (!boundaryPoints) return []
const points =
endpoint === 'start'
? [boundaryPoints.startLeft, boundaryPoints.startRight]
: [boundaryPoints.endLeft, boundaryPoints.endRight]
return points.map((facePoint, index) => ({
index,
point: toFencePlanPoint(facePoint),
vector: reference.vector,
}))
}
function getMatchingFaceAnglePairs(
draftCandidates: FaceAngleCandidate[],
connectedCandidates: FaceAngleCandidate[],
) {
const candidates: FaceAnglePair[] = []
for (const draftCandidate of draftCandidates) {
for (const connectedCandidate of connectedCandidates) {
candidates.push({
draft: draftCandidate,
connected: connectedCandidate,
distance: distanceSquared(draftCandidate.point, connectedCandidate.point),
})
}
}
candidates.sort((a, b) => a.distance - b.distance)
const exactPairs = candidates.filter((pair) => pair.distance <= 1e-6)
const sourcePairs = exactPairs.length > 0 ? exactPairs : candidates.slice(0, 1)
const usedDraftIndexes = new Set<number>()
const usedConnectedIndexes = new Set<number>()
const pairs: FaceAnglePair[] = []
for (const pair of sourcePairs) {
if (usedDraftIndexes.has(pair.draft.index) || usedConnectedIndexes.has(pair.connected.index)) {
continue
}
usedDraftIndexes.add(pair.draft.index)
usedConnectedIndexes.add(pair.connected.index)
pairs.push(pair)
if (pairs.length === 2) break
}
return pairs
}
function getAngleSource(
endpointPoint: FencePlanPoint,
endpointDraftVector: FencePlanPoint,
connectedReference: SegmentAngleReference,
facePairs: FaceAnglePair[],
): AngleSource {
if (facePairs.length === 0) {
return {
arcCenter: endpointPoint,
connectedVector: connectedReference.vector,
draftVector: endpointDraftVector,
}
}
const arc = getAngleArcToSegmentReference(endpointDraftVector, connectedReference)
const angleDirection: FencePlanPoint = arc
? [Math.cos(arc.midAngle), Math.sin(arc.midAngle)]
: [endpointDraftVector[0], endpointDraftVector[1]]
const bestPair =
facePairs
.map((pair) => {
const arcCenter: FencePlanPoint = [
(pair.draft.point[0] + pair.connected.point[0]) / 2,
(pair.draft.point[1] + pair.connected.point[1]) / 2,
]
const fromEndpoint: FencePlanPoint = [
arcCenter[0] - endpointPoint[0],
arcCenter[1] - endpointPoint[1],
]
return {
pair,
score: fromEndpoint[0] * angleDirection[0] + fromEndpoint[1] * angleDirection[1],
}
})
.sort((a, b) => b.score - a.score)[0]?.pair ?? facePairs[0]!
return {
arcCenter: [
(bestPair.draft.point[0] + bestPair.connected.point[0]) / 2,
(bestPair.draft.point[1] + bestPair.connected.point[1]) / 2,
],
connectedVector: bestPair.connected.vector,
draftVector: bestPair.draft.vector,
}
}
function getDraftAngleLabels( function getDraftAngleLabels(
start: FencePlanPoint, start: FencePlanPoint,
end: FencePlanPoint, end: FencePlanPoint,
segments: SegmentLike[], segments: SegmentLike[],
baseY: number,
): DraftAngleLabel[] { ): DraftAngleLabel[] {
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]] const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]] const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
const draftSegment = buildDraftFenceSegment(start, end)
const miterData = calculateLevelMiters([...segments, draftSegment].map(toMiterWall))
const endpoints = [ const endpoints = [
{ id: 'start', point: start, draftVector: draftFromStart }, { id: 'start', point: start, draftVector: draftFromStart },
{ id: 'end', point: end, draftVector: draftFromEnd }, { id: 'end', point: end, draftVector: draftFromEnd },
@@ -92,12 +285,52 @@ function getDraftAngleLabels(
if (!connectedSegment) continue if (!connectedSegment) continue
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment) const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
if (!connectedReference) continue if (!connectedReference) continue
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) const draftFaceCandidates = getFenceFaceAngleCandidates(endpoint.point, draftSegment, miterData)
const connectedFaceCandidates = getFenceFaceAngleCandidates(
endpoint.point,
connectedSegment,
miterData,
)
const facePairs = getMatchingFaceAnglePairs(draftFaceCandidates, connectedFaceCandidates)
const { arcCenter, connectedVector, draftVector } = getAngleSource(
endpoint.point,
endpoint.draftVector,
connectedReference,
facePairs,
)
const angle = getAngleToSegmentReference(draftVector, {
...connectedReference,
vector: connectedVector,
})
if (angle === null) continue if (angle === null) continue
const arc = getAngleArcToSegmentReference(draftVector, {
...connectedReference,
vector: connectedVector,
})
if (!arc || arc.angle < 0.01) continue
const draftLength = Math.hypot(draftVector[0], draftVector[1])
const referenceLength = Math.hypot(connectedVector[0], connectedVector[1])
const radius = clamp(
Math.min(draftLength, referenceLength) * 0.28,
DRAFT_ANGLE_ARC_MIN_RADIUS,
DRAFT_ANGLE_ARC_MAX_RADIUS,
)
labels.push({ labels.push({
id: endpoint.id, id: endpoint.id,
label: formatAngleRadians(angle), label: formatAngleRadians(angle),
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]], position: [
arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16),
baseY + DRAFT_ANGLE_LABEL_Y,
arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16),
],
arc: {
center: arcCenter,
radius,
startAngle: arc.startAngle,
endAngle: arc.endAngle,
y: baseY + DRAFT_ANGLE_ARC_Y,
},
}) })
} }
return labels return labels
@@ -108,6 +341,7 @@ function getDraftMeasurementState(
end: FencePlanPoint, end: FencePlanPoint,
segments: SegmentLike[], segments: SegmentLike[],
unit: 'metric' | 'imperial', unit: 'metric' | 'imperial',
baseY: number,
): DraftMeasurementState { ): DraftMeasurementState {
const dx = end[0] - start[0] const dx = end[0] - start[0]
const dz = end[1] - start[1] const dz = end[1] - start[1]
@@ -115,15 +349,27 @@ function getDraftMeasurementState(
if (length < 0.01) return null if (length < 0.01) return null
return { return {
lengthLabel: formatMeasurement(length, unit), lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2], lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, segments), angleLabels: getDraftAngleLabels(start, end, segments, baseY),
} }
} }
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] { function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
return [ return [
...walls.map((w) => ({ id: w.id, start: w.start, end: w.end, curveOffset: w.curveOffset })), ...walls.map((wall) => ({
...fences.map((f) => ({ id: f.id, start: f.start, end: f.end, curveOffset: f.curveOffset })), id: wall.id,
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
})),
...fences.map((fence) => ({
id: fence.id,
start: fence.start,
end: fence.end,
curveOffset: fence.curveOffset,
thickness: fence.thickness,
})),
] ]
} }
@@ -136,17 +382,19 @@ function updateFencePreview(mesh: Mesh, start: Vector3, end: Vector3) {
} }
mesh.visible = true mesh.visible = true
direction.normalize() direction.normalize()
const shape = new Shape() const geometry = new BoxGeometry(length, FENCE_PREVIEW_HEIGHT, FENCE_PREVIEW_THICKNESS)
shape.moveTo(0, 0) const angle = Math.atan2(direction.z, direction.x)
shape.lineTo(length, 0)
shape.lineTo(length, FENCE_PREVIEW_HEIGHT) mesh.position.set(
shape.lineTo(0, FENCE_PREVIEW_HEIGHT) (start.x + end.x) / 2,
shape.closePath() start.y + FENCE_PREVIEW_HEIGHT / 2,
const geometry = new ShapeGeometry(shape) (start.z + end.z) / 2,
const angle = -Math.atan2(direction.z, direction.x) )
mesh.position.set(start.x, start.y, start.z) mesh.rotation.y = -angle
mesh.rotation.y = angle
if (mesh.geometry) mesh.geometry.dispose() if (mesh.geometry) {
mesh.geometry.dispose()
}
mesh.geometry = geometry mesh.geometry = geometry
} }
@@ -164,7 +412,8 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
} }
export const FenceTool: React.FC = () => { export const FenceTool: React.FC = () => {
const unit = useViewer((s) => s.unit) const unit = useViewer((state) => state.unit)
const theme = useViewer((state) => state.theme)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const previewRef = useRef<Mesh>(null!) const previewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
@@ -172,6 +421,8 @@ export const FenceTool: React.FC = () => {
const buildingState = useRef(0) const buildingState = useRef(0)
const shiftPressed = useRef(false) const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null) const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const measurementColor = theme === 'dark' ? '#ffffff' : '#111111'
const measurementShadowColor = theme === 'dark' ? '#111111' : '#ffffff'
useEffect(() => { useEffect(() => {
let previousFenceEnd: FencePlanPoint | null = null let previousFenceEnd: FencePlanPoint | null = null
@@ -206,6 +457,7 @@ export const FenceTool: React.FC = () => {
snappedLocal, snappedLocal,
getReferenceSegments(walls, fences), getReferenceSegments(walls, fences),
unit, unit,
startingPoint.current.y,
), ),
) )
} else { } else {
@@ -293,15 +545,21 @@ export const FenceTool: React.FC = () => {
{draftMeasurement && ( {draftMeasurement && (
<> <>
<DraftMeasurementLabel <DraftMeasurementLabel
color={measurementColor}
label={draftMeasurement.lengthLabel} label={draftMeasurement.lengthLabel}
position={draftMeasurement.lengthPosition} position={draftMeasurement.lengthPosition}
shadowColor={measurementShadowColor}
/> />
{draftMeasurement.angleLabels.map((angleLabel) => ( {draftMeasurement.angleLabels.map((angleLabel) => (
<DraftMeasurementLabel <group key={angleLabel.id}>
key={angleLabel.id} <DraftAngleArc arc={angleLabel.arc} color={measurementColor} />
label={angleLabel.label} <DraftMeasurementLabel
position={angleLabel.position} color={measurementColor}
/> label={angleLabel.label}
position={angleLabel.position}
shadowColor={measurementShadowColor}
/>
</group>
))} ))}
</> </>
)} )}
@@ -309,16 +567,67 @@ export const FenceTool: 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({ function DraftMeasurementLabel({
color,
label, label,
position, position,
shadowColor,
}: { }: {
color: string
label: string label: string
position: [number, number, number] position: [number, number, number]
shadowColor: string
}) { }) {
return ( return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}> <Html
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md"> 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} {label}
</div> </div>
</Html> </Html>