Replace the legacy held-Alt mechanism on wall and fence drafting with a mode toggle, mirroring the snapping-mode chip: - Wall: `wallChainMode` room (auto-close on loop) / single. Room mode finishes automatically when the new endpoint lands within the join-snap radius of the chain's first vertex; single commits one wall per click. - Fence: `fenceChainMode` continuous (chain until double-click/Esc) / single. Fences are linear barriers, so continuous has no auto-close. - Both: Alt-tap cycles the active drafting tool's chain mode (clean-tap, scoped to wall/fence drafting); a clickable HUD chip shows the mode. Persisted + migrated in `useEditor`. Migrate wall and fence off held-Alt-bypass-alignment to the unified convention: alignment now follows the magnetic snap mode, which frees Alt for the toggle. 2D floorplan parity kept in sync with the 3D tools. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
763 lines
24 KiB
TypeScript
763 lines
24 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
calculateLevelMiters,
|
|
collectAlignmentAnchors,
|
|
emitter,
|
|
type FenceNode,
|
|
type GridEvent,
|
|
getWallMiterBoundaryPoints,
|
|
type LevelNode,
|
|
type Point2D,
|
|
resolveAlignment,
|
|
useScene,
|
|
type WallMiterData,
|
|
type WallNode,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
CursorSphere,
|
|
createFenceOnCurrentLevel,
|
|
EDITOR_LAYER,
|
|
type FencePlanPoint,
|
|
formatAngleRadians,
|
|
formatLinearMeasurement,
|
|
getAngleArcToSegmentReference,
|
|
getAngleToSegmentReference,
|
|
getSegmentAngleReferenceAtPoint,
|
|
isAngleSnapActive,
|
|
isMagneticSnapActive,
|
|
markToolCancelConsumed,
|
|
type SegmentAngleReference,
|
|
snapFenceDraftPoint,
|
|
triggerSFX,
|
|
useAlignmentGuides,
|
|
useEditor,
|
|
useSegmentDraftChain,
|
|
} from '@pascal-app/editor'
|
|
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
|
import { Html } from '@react-three/drei'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three'
|
|
|
|
const FENCE_PREVIEW_HEIGHT = 1.8
|
|
const FENCE_PREVIEW_THICKNESS = 0.08
|
|
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
|
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
// HUD label heights are measured from the top of the preview bar, so they
|
|
// track whatever height a seeded preset draws at (`previewHeight`).
|
|
const DRAFT_LABEL_Y_OFFSET = 0.22
|
|
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
|
|
lengthPosition: [number, number, number]
|
|
angleLabels: DraftAngleLabel[]
|
|
} | null
|
|
|
|
type SegmentLike = {
|
|
id: string
|
|
start: FencePlanPoint
|
|
end: FencePlanPoint
|
|
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 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,
|
|
thickness: number,
|
|
): SegmentLike {
|
|
return {
|
|
id: 'fence_draft',
|
|
start,
|
|
end,
|
|
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(
|
|
start: FencePlanPoint,
|
|
end: FencePlanPoint,
|
|
segments: SegmentLike[],
|
|
baseY: number,
|
|
previewHeight: number,
|
|
previewThickness: number,
|
|
): DraftAngleLabel[] {
|
|
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
|
|
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
|
|
const draftSegment = buildDraftFenceSegment(start, end, previewThickness)
|
|
const miterData = calculateLevelMiters([...segments, draftSegment].map(toMiterWall))
|
|
const endpoints = [
|
|
{ id: 'start', point: start, draftVector: draftFromStart },
|
|
{ id: 'end', point: end, draftVector: draftFromEnd },
|
|
]
|
|
const labels: DraftAngleLabel[] = []
|
|
for (const endpoint of endpoints) {
|
|
const connectedSegment = segments.find((segment) =>
|
|
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
|
|
)
|
|
if (!connectedSegment) continue
|
|
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
|
|
if (!connectedReference) continue
|
|
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
|
|
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({
|
|
id: endpoint.id,
|
|
label: formatAngleRadians(angle),
|
|
position: [
|
|
arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16),
|
|
baseY + previewHeight + DRAFT_ANGLE_LABEL_Y_OFFSET,
|
|
arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16),
|
|
],
|
|
arc: {
|
|
center: arcCenter,
|
|
radius,
|
|
startAngle: arc.startAngle,
|
|
endAngle: arc.endAngle,
|
|
y: baseY + previewHeight + DRAFT_ANGLE_ARC_Y_OFFSET,
|
|
},
|
|
})
|
|
}
|
|
return labels
|
|
}
|
|
|
|
function getDraftMeasurementState(
|
|
start: FencePlanPoint,
|
|
end: FencePlanPoint,
|
|
segments: SegmentLike[],
|
|
unit: 'metric' | 'imperial',
|
|
baseY: number,
|
|
previewHeight: number,
|
|
previewThickness: number,
|
|
): DraftMeasurementState {
|
|
const dx = end[0] - start[0]
|
|
const dz = end[1] - start[1]
|
|
const length = Math.hypot(dx, dz)
|
|
if (length < 0.01) return null
|
|
return {
|
|
lengthLabel: formatLinearMeasurement(length, unit),
|
|
lengthPosition: [
|
|
(start[0] + end[0]) / 2,
|
|
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
|
|
(start[1] + end[1]) / 2,
|
|
],
|
|
angleLabels: getDraftAngleLabels(start, end, segments, baseY, previewHeight, previewThickness),
|
|
}
|
|
}
|
|
|
|
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
|
|
return [
|
|
...walls.map((wall) => ({
|
|
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,
|
|
})),
|
|
]
|
|
}
|
|
|
|
function updateFencePreview(
|
|
mesh: Mesh,
|
|
start: Vector3,
|
|
end: Vector3,
|
|
previewHeight: number,
|
|
previewThickness: number,
|
|
) {
|
|
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
|
const length = direction.length()
|
|
if (length < 0.01) {
|
|
mesh.visible = false
|
|
return
|
|
}
|
|
mesh.visible = true
|
|
direction.normalize()
|
|
const geometry = new BoxGeometry(length, previewHeight, previewThickness)
|
|
const angle = Math.atan2(direction.z, direction.x)
|
|
|
|
mesh.position.set((start.x + end.x) / 2, start.y + previewHeight / 2, (start.z + end.z) / 2)
|
|
mesh.rotation.y = -angle
|
|
|
|
if (mesh.geometry) {
|
|
mesh.geometry.dispose()
|
|
}
|
|
mesh.geometry = geometry
|
|
}
|
|
|
|
function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
|
|
const currentLevelId = useViewer.getState().selection.levelId
|
|
const { nodes } = useScene.getState()
|
|
if (!currentLevelId) return { walls: [], fences: [] }
|
|
const levelNode = nodes[currentLevelId]
|
|
if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
|
|
const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
|
|
return {
|
|
walls: children.filter((n): n is WallNode => n?.type === 'wall'),
|
|
fences: children.filter((n): n is FenceNode => n?.type === 'fence'),
|
|
}
|
|
}
|
|
|
|
export const FenceTool: React.FC = () => {
|
|
const unit = useViewer((state) => state.unit)
|
|
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
// A placed preset seeds `toolDefaults.fence` before the tool mounts, so
|
|
// the draft preview is drawn at the preset's height / thickness rather
|
|
// than the generic fallbacks. Read through refs so the live event
|
|
// handlers below see the latest values without re-subscribing.
|
|
const fenceDefaults = useEditor((s) => s.toolDefaults.fence)
|
|
const previewHeight =
|
|
typeof fenceDefaults?.height === 'number' ? fenceDefaults.height : FENCE_PREVIEW_HEIGHT
|
|
const previewThickness =
|
|
typeof fenceDefaults?.thickness === 'number' ? fenceDefaults.thickness : FENCE_PREVIEW_THICKNESS
|
|
const previewHeightRef = useRef(previewHeight)
|
|
previewHeightRef.current = previewHeight
|
|
const previewThicknessRef = useRef(previewThickness)
|
|
previewThicknessRef.current = previewThickness
|
|
const cursorRef = useRef<Group>(null)
|
|
const previewRef = useRef<Mesh>(null!)
|
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
|
const buildingState = useRef(0)
|
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
|
const measurementColor = isDark ? '#ffffff' : '#111111'
|
|
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
|
|
|
// Scope seeded defaults to this tool session: clear on deactivation so a
|
|
// later manual fence draw isn't drawn with a stale preset's parameters.
|
|
// Unmount-only (empty deps) — the [unit] effect below must not clear it.
|
|
useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), [])
|
|
|
|
useEffect(() => {
|
|
let previousFenceEnd: FencePlanPoint | null = null
|
|
|
|
// Alignment candidates — anchors of every alignable object. Refreshed
|
|
// after each segment commits (the new fence becomes a candidate too).
|
|
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
|
const refreshAlignmentCandidates = () => {
|
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
|
}
|
|
|
|
// Align the drafted point onto another object's nearest real anchor and
|
|
// publish the guide. Returns the possibly snapped point.
|
|
const alignPoint = (point: FencePlanPoint, bypass: boolean): FencePlanPoint => {
|
|
// Figma alignment pulls the endpoint onto existing corners / edges, so it
|
|
// is a line snap — suppress it whenever magnetic snap is off (`'off'` /
|
|
// `'angles'`), matching the fence-geometry snap.
|
|
if (bypass || !isMagneticSnapActive() || alignmentCandidates.length === 0) {
|
|
useAlignmentGuides.getState().clear()
|
|
return point
|
|
}
|
|
const ar = resolveAlignment({
|
|
moving: [{ nodeId: '__fence-draft__', kind: 'corner', x: point[0], z: point[1] }],
|
|
candidates: alignmentCandidates,
|
|
threshold: ALIGNMENT_THRESHOLD_M,
|
|
})
|
|
useAlignmentGuides.getState().set(ar.guides)
|
|
return ar.snap ? [point[0] + ar.snap.dx, point[1] + ar.snap.dz] : point
|
|
}
|
|
|
|
const stopDrafting = () => {
|
|
buildingState.current = 0
|
|
previewRef.current.visible = false
|
|
setDraftMeasurement(null)
|
|
useSegmentDraftChain.getState().clear('fence')
|
|
useAlignmentGuides.getState().clear()
|
|
}
|
|
|
|
const onGridMove = (event: GridEvent) => {
|
|
if (!(cursorRef.current && previewRef.current)) return
|
|
const { walls, fences } = getCurrentLevelElements()
|
|
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
// While drafting, the segment locks to 15° rays from its start.
|
|
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
|
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
|
|
// mode, not Alt (Alt-tap toggles continuous/single chaining).
|
|
const bypassAlign = !isMagneticSnapActive()
|
|
|
|
if (buildingState.current === 1) {
|
|
const angleLocked = isAngleSnapActive()
|
|
const snappedLocal = alignPoint(
|
|
snapFenceDraftPoint({
|
|
point: localPoint,
|
|
walls,
|
|
fences,
|
|
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
|
angleSnap: angleLocked,
|
|
magnetic: isMagneticSnapActive(),
|
|
}),
|
|
bypassAlign || angleLocked,
|
|
)
|
|
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
|
cursorRef.current.position.copy(endingPoint.current)
|
|
const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]]
|
|
if (
|
|
previousFenceEnd &&
|
|
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
|
|
) {
|
|
triggerSFX('sfx:grid-snap')
|
|
}
|
|
previousFenceEnd = currentFenceEnd
|
|
updateFencePreview(
|
|
previewRef.current,
|
|
startingPoint.current,
|
|
endingPoint.current,
|
|
previewHeightRef.current,
|
|
previewThicknessRef.current,
|
|
)
|
|
setDraftMeasurement(
|
|
getDraftMeasurementState(
|
|
[startingPoint.current.x, startingPoint.current.z],
|
|
snappedLocal,
|
|
getReferenceSegments(walls, fences),
|
|
unit,
|
|
startingPoint.current.y,
|
|
previewHeightRef.current,
|
|
previewThicknessRef.current,
|
|
),
|
|
)
|
|
} else {
|
|
const snappedPoint = alignPoint(
|
|
snapFenceDraftPoint({
|
|
point: localPoint,
|
|
walls,
|
|
fences,
|
|
magnetic: isMagneticSnapActive(),
|
|
}),
|
|
bypassAlign,
|
|
)
|
|
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
|
setDraftMeasurement(null)
|
|
}
|
|
}
|
|
|
|
const onGridClick = (event: GridEvent) => {
|
|
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
|
|
stopDrafting()
|
|
return
|
|
}
|
|
|
|
const { walls, fences } = getCurrentLevelElements()
|
|
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
|
const bypassAlign = !isMagneticSnapActive()
|
|
|
|
if (buildingState.current === 0) {
|
|
const snappedStart = alignPoint(
|
|
snapFenceDraftPoint({
|
|
point: localClick,
|
|
walls,
|
|
fences,
|
|
magnetic: isMagneticSnapActive(),
|
|
}),
|
|
bypassAlign,
|
|
)
|
|
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
|
endingPoint.current.copy(startingPoint.current)
|
|
buildingState.current = 1
|
|
triggerSFX('sfx:structure-build-start')
|
|
previewRef.current.visible = true
|
|
setDraftMeasurement(null)
|
|
} else {
|
|
const angleLocked = isAngleSnapActive()
|
|
const snappedEnd = alignPoint(
|
|
snapFenceDraftPoint({
|
|
point: localClick,
|
|
walls,
|
|
fences,
|
|
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
|
|
angleSnap: angleLocked,
|
|
magnetic: isMagneticSnapActive(),
|
|
}),
|
|
bypassAlign || angleLocked,
|
|
)
|
|
const dx = snappedEnd[0] - startingPoint.current.x
|
|
const dz = snappedEnd[1] - startingPoint.current.z
|
|
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
|
const createdFence = createFenceOnCurrentLevel(
|
|
[startingPoint.current.x, startingPoint.current.z],
|
|
snappedEnd,
|
|
)
|
|
if (!createdFence) return
|
|
|
|
// The new segment is now a real node — make it an alignment target
|
|
// for the next segment, and drop the just-shown guide.
|
|
refreshAlignmentCandidates()
|
|
useAlignmentGuides.getState().clear()
|
|
|
|
// Single mode commits one segment per click: stop drafting so the next
|
|
// click starts a fresh segment instead of chaining off this endpoint.
|
|
if (useEditor.getState().fenceChainMode === 'single') {
|
|
stopDrafting()
|
|
return
|
|
}
|
|
|
|
const nextStart = createdFence.end
|
|
// Publish the resolved chain start so the 2D floor-plan draft
|
|
// chains its next segment from the same point (its own snap
|
|
// pipeline can resolve a slightly different endpoint).
|
|
useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]])
|
|
startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1])
|
|
endingPoint.current.copy(startingPoint.current)
|
|
cursorRef.current?.position.copy(startingPoint.current)
|
|
previewRef.current.visible = false
|
|
buildingState.current = 1
|
|
setDraftMeasurement(null)
|
|
}
|
|
}
|
|
|
|
const onCancel = () => {
|
|
if (buildingState.current === 1) {
|
|
markToolCancelConsumed()
|
|
stopDrafting()
|
|
}
|
|
}
|
|
|
|
emitter.on('grid:move', onGridMove)
|
|
emitter.on('grid:click', onGridClick)
|
|
emitter.on('tool:cancel', onCancel)
|
|
|
|
return () => {
|
|
emitter.off('grid:move', onGridMove)
|
|
emitter.off('grid:click', onGridClick)
|
|
emitter.off('tool:cancel', onCancel)
|
|
useSegmentDraftChain.getState().clear('fence')
|
|
useAlignmentGuides.getState().clear()
|
|
}
|
|
}, [unit])
|
|
|
|
return (
|
|
<group>
|
|
<CursorSphere height={previewHeight} ref={cursorRef} />
|
|
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
|
<shapeGeometry />
|
|
<meshBasicMaterial
|
|
color="#ffffff"
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
opacity={0.45}
|
|
side={DoubleSide}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
{draftMeasurement && (
|
|
<>
|
|
<DraftMeasurementLabel
|
|
color={measurementColor}
|
|
label={draftMeasurement.lengthLabel}
|
|
position={draftMeasurement.lengthPosition}
|
|
shadowColor={measurementShadowColor}
|
|
/>
|
|
{draftMeasurement.angleLabels.map((angleLabel) => (
|
|
<group key={angleLabel.id}>
|
|
<DraftAngleArc arc={angleLabel.arc} color={measurementColor} />
|
|
<DraftMeasurementLabel
|
|
color={measurementColor}
|
|
label={angleLabel.label}
|
|
position={angleLabel.position}
|
|
shadowColor={measurementShadowColor}
|
|
/>
|
|
</group>
|
|
))}
|
|
</>
|
|
)}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
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
|