diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx
index 8c61b407..60b041ce 100755
--- a/packages/editor/src/components/editor/wall-measurement-label.tsx
+++ b/packages/editor/src/components/editor/wall-measurement-label.tsx
@@ -4,10 +4,12 @@ import {
type AnyNodeId,
calculateLevelMiters,
DEFAULT_WALL_HEIGHT,
+ getScaledDimensions,
getWallCurveLength,
getWallMiterBoundaryPoints,
getWallPlanFootprint,
getWallSurfacePolygon,
+ type ItemNode,
isCurvedWall,
type Point2D,
pointToKey,
@@ -27,6 +29,8 @@ const GUIDE_Y_OFFSET = 0.08
const LABEL_LIFT = 0.08
const BAR_THICKNESS = 0.012
const LINE_OPACITY = 0.95
+const HEIGHT_TICK_HALF_LENGTH = 0.14
+const HEIGHT_GUIDE_OUTSIDE_OFFSET = 0.16
const BAR_AXIS = new THREE.Vector3(0, 1, 0)
@@ -39,6 +43,18 @@ type MeasurementGuide = {
extEndStart: Vec3
extEndEnd: Vec3
labelPosition: Vec3
+ heightStart: Vec3
+ heightEnd: Vec3
+ heightBottomTickStart: Vec3
+ heightBottomTickEnd: Vec3
+ heightTopTickStart: Vec3
+ heightTopTickEnd: Vec3
+ heightLabelPosition: Vec3
+}
+
+type WallFaceLine = {
+ start: Point2D
+ end: Point2D
}
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
@@ -57,28 +73,28 @@ export function WallMeasurementLabel() {
const nodes = useScene((state) => state.nodes)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
- const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
- const wall = selectedNode?.type === 'wall' ? selectedNode : null
+ const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
+ const measurableNode =
+ selectedNode?.type === 'wall' || selectedNode?.type === 'item' ? selectedNode : null
- const [wallObjectState, setWallObjectState] = useState<{
- id: WallNode['id']
+ const [objectState, setObjectState] = useState<{
+ id: AnyNodeId
object: THREE.Object3D
} | null>(null)
- const wallObject =
- selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
+ const selectedObject = selectedId && objectState?.id === selectedId ? objectState.object : null
useFrame(() => {
- if (!selectedId || wallObject) return
+ if (!selectedId || selectedObject) return
- const nextWallObject = sceneRegistry.nodes.get(selectedId)
- if (nextWallObject) {
- setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
+ const nextObject = sceneRegistry.nodes.get(selectedId)
+ if (nextObject) {
+ setObjectState({ id: selectedId as AnyNodeId, object: nextObject })
}
})
- if (!(wall && wallObject)) return null
+ if (!(measurableNode && selectedObject)) return null
- return createPortal(, wallObject)
+ return createPortal(, selectedObject)
}
function getLevelWalls(
@@ -97,6 +113,114 @@ function getLevelWalls(
.filter((node): node is WallNode => Boolean(node && node.type === 'wall'))
}
+function pointMatchesWallPlanPoint(point: Point2D | undefined, planPoint: [number, number]) {
+ if (!point) return false
+
+ return Math.abs(point.x - planPoint[0]) < 1e-6 && Math.abs(point.y - planPoint[1]) < 1e-6
+}
+
+function getWallFaceLines(
+ wall: WallNode,
+ miterData: WallMiterData,
+): { left: WallFaceLine; right: WallFaceLine } | null {
+ if (isCurvedWall(wall)) return null
+
+ const footprint = getWallPlanFootprint(wall, miterData)
+ if (footprint.length < 4) return null
+
+ const startRight = footprint[0]
+ const endRight = footprint[1]
+ const hasEndCenterPoint = pointMatchesWallPlanPoint(footprint[2], wall.end)
+ const endLeft = footprint[hasEndCenterPoint ? 3 : 2]
+ const lastPoint = footprint[footprint.length - 1]
+ const hasStartCenterPoint = pointMatchesWallPlanPoint(lastPoint, wall.start)
+ const startLeft = footprint[hasStartCenterPoint ? footprint.length - 2 : footprint.length - 1]
+
+ if (!(startRight && endRight && endLeft && startLeft)) return null
+
+ return {
+ left: {
+ start: startLeft,
+ end: endLeft,
+ },
+ right: {
+ start: startRight,
+ end: endRight,
+ },
+ }
+}
+
+function getLineMidpoint(line: WallFaceLine): Point2D {
+ return {
+ x: (line.start.x + line.end.x) / 2,
+ y: (line.start.y + line.end.y) / 2,
+ }
+}
+
+function getLevelWallsCenter(levelWalls: WallNode[]): Point2D {
+ let minX = Number.POSITIVE_INFINITY
+ let maxX = Number.NEGATIVE_INFINITY
+ let minY = Number.POSITIVE_INFINITY
+ let maxY = Number.NEGATIVE_INFINITY
+
+ for (const candidateWall of levelWalls) {
+ minX = Math.min(minX, candidateWall.start[0], candidateWall.end[0])
+ maxX = Math.max(maxX, candidateWall.start[0], candidateWall.end[0])
+ minY = Math.min(minY, candidateWall.start[1], candidateWall.end[1])
+ maxY = Math.max(maxY, candidateWall.start[1], candidateWall.end[1])
+ }
+
+ return {
+ x: minX === Number.POSITIVE_INFINITY ? 0 : (minX + maxX) / 2,
+ y: minY === Number.POSITIVE_INFINITY ? 0 : (minY + maxY) / 2,
+ }
+}
+
+function getWallOuterFaceLine(
+ wall: WallNode,
+ miterData: WallMiterData,
+ levelWalls: WallNode[],
+): WallFaceLine | null {
+ const faceLines = getWallFaceLines(wall, miterData)
+ if (!faceLines) return null
+
+ if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
+ return faceLines.left
+ }
+
+ if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
+ return faceLines.right
+ }
+
+ const dx = wall.end[0] - wall.start[0]
+ const dy = wall.end[1] - wall.start[1]
+ const length = Math.hypot(dx, dy)
+ if (length < 1e-6) return null
+
+ const wallMidpoint = {
+ x: (wall.start[0] + wall.end[0]) / 2,
+ y: (wall.start[1] + wall.end[1]) / 2,
+ }
+ const levelCenter = getLevelWallsCenter(levelWalls)
+ const normal = { x: -dy / length, y: dx / length }
+ const fromCenter = {
+ x: wallMidpoint.x - levelCenter.x,
+ y: wallMidpoint.y - levelCenter.y,
+ }
+ const outwardNormal =
+ fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y }
+ const rightMidpoint = getLineMidpoint(faceLines.right)
+ const leftMidpoint = getLineMidpoint(faceLines.left)
+ const rightScore =
+ (rightMidpoint.x - wallMidpoint.x) * outwardNormal.x +
+ (rightMidpoint.y - wallMidpoint.y) * outwardNormal.y
+ const leftScore =
+ (leftMidpoint.x - wallMidpoint.x) * outwardNormal.x +
+ (leftMidpoint.y - wallMidpoint.y) * outwardNormal.y
+
+ return rightScore >= leftScore ? faceLines.right : faceLines.left
+}
+
function getWallMiddlePoints(
wall: WallNode,
miterData: WallMiterData,
@@ -136,7 +260,10 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
}
-function getWallExteriorOffsetSign(wall: Pick) {
+function getWallExteriorOffsetSign(
+ wall: Pick,
+ levelWalls: WallNode[],
+) {
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
return 1
}
@@ -145,10 +272,31 @@ function getWallExteriorOffsetSign(wall: Pick= 0 ? 1 : -1
}
-function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
+function getCurvedWallMeasurementPath(
+ wall: WallNode,
+ miterData: WallMiterData,
+ levelWalls: WallNode[],
+): Point2D[] | null {
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
if (!boundaryPoints) return null
@@ -156,7 +304,7 @@ function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData):
const sidePointCount = 25
if (surface.length < sidePointCount * 2) return null
- const offsetSign = getWallExteriorOffsetSign(wall)
+ const offsetSign = getWallExteriorOffsetSign(wall, levelWalls)
if (offsetSign >= 0) {
return surface.slice(sidePointCount).reverse()
}
@@ -170,14 +318,16 @@ function buildMeasurementGuide(
): MeasurementGuide | null {
const levelWalls = getLevelWalls(wall, nodes)
const miterData = calculateLevelMiters(levelWalls)
- const middlePoints = getWallMiddlePoints(wall, miterData)
- if (!middlePoints) return null
+ const measurementLine = getWallOuterFaceLine(wall, miterData, levelWalls)
+ const fallbackMiddlePoints = measurementLine ? null : getWallMiddlePoints(wall, miterData)
+ const measurementPoints = measurementLine ?? fallbackMiddlePoints
+ if (!measurementPoints) return null
const height = wall.height ?? DEFAULT_WALL_HEIGHT
- const startLocal = worldPointToWallLocal(wall, middlePoints.start)
- const endLocal = worldPointToWallLocal(wall, middlePoints.end)
+ const startLocal = worldPointToWallLocal(wall, measurementPoints.start)
+ const endLocal = worldPointToWallLocal(wall, measurementPoints.end)
const curvedMeasurementPath = isCurvedWall(wall)
- ? getCurvedWallMeasurementPath(wall, miterData)
+ ? getCurvedWallMeasurementPath(wall, miterData, levelWalls)
: null
const guidePath: Vec3[] = curvedMeasurementPath
? curvedMeasurementPath.map((point) => {
@@ -224,6 +374,38 @@ function buildMeasurementGuide(
guideStart[1],
(guideStart[2] + guideEnd[2]) / 2,
] as Vec3)
+ const rawHeightGuidePosition = [guideEnd[0], 0, guideEnd[2]] as Vec3
+ const beforeGuideEnd = guidePath[guidePath.length - 2] ?? guideStart
+ const tickDx = guideEnd[0] - beforeGuideEnd[0]
+ const tickDz = guideEnd[2] - beforeGuideEnd[2]
+ const tickLength = Math.hypot(tickDx, tickDz)
+ const tangentX = tickLength > 1e-6 ? tickDx / tickLength : 1
+ const tangentZ = tickLength > 1e-6 ? tickDz / tickLength : 0
+ const tickUnitX = -tangentZ
+ const tickUnitZ = tangentX
+ const wallEndLocal = worldPointToWallLocal(wall, { x: wall.end[0], y: wall.end[1] })
+ const endOutwardX = rawHeightGuidePosition[0] - wallEndLocal[0]
+ const endOutwardZ = rawHeightGuidePosition[2] - wallEndLocal[2]
+ const outsideSign = endOutwardX * tickUnitX + endOutwardZ * tickUnitZ >= 0 ? 1 : -1
+ const heightGuidePosition = [
+ rawHeightGuidePosition[0] + tickUnitX * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
+ 0,
+ rawHeightGuidePosition[2] + tickUnitZ * outsideSign * HEIGHT_GUIDE_OUTSIDE_OFFSET,
+ ] as Vec3
+ const getHorizontalHeightTick = (y: number): { start: Vec3; end: Vec3 } => ({
+ start: [
+ heightGuidePosition[0] - tickUnitX * HEIGHT_TICK_HALF_LENGTH,
+ y,
+ heightGuidePosition[2] - tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
+ ],
+ end: [
+ heightGuidePosition[0] + tickUnitX * HEIGHT_TICK_HALF_LENGTH,
+ y,
+ heightGuidePosition[2] + tickUnitZ * HEIGHT_TICK_HALF_LENGTH,
+ ],
+ })
+ const bottomHeightTick = getHorizontalHeightTick(0)
+ const topHeightTick = getHorizontalHeightTick(height)
return {
guidePath,
@@ -236,6 +418,37 @@ function buildMeasurementGuide(
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
+ heightStart: [heightGuidePosition[0], 0, heightGuidePosition[2]],
+ heightEnd: [heightGuidePosition[0], height, heightGuidePosition[2]],
+ heightBottomTickStart: bottomHeightTick.start,
+ heightBottomTickEnd: bottomHeightTick.end,
+ heightTopTickStart: topHeightTick.start,
+ heightTopTickEnd: topHeightTick.end,
+ heightLabelPosition: [heightGuidePosition[0], height / 2, heightGuidePosition[2]],
+ }
+}
+
+type HeightGuide = {
+ start: Vec3
+ end: Vec3
+ labelPosition: Vec3
+}
+
+function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null {
+ const [width, height, depth] = getScaledDimensions(item)
+
+ if (!Number.isFinite(height) || height < 0.01) return null
+
+ const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18
+ const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18
+
+ return {
+ height,
+ guide: {
+ start: [x, 0, z],
+ end: [x, height, z],
+ labelPosition: [x, height / 2, z],
+ },
}
}
@@ -286,6 +499,45 @@ function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
)
}
+function MeasurementLabel({
+ label,
+ position,
+ color,
+ shadowColor,
+}: {
+ label: string
+ position: Vec3
+ color: string
+ shadowColor: string
+}) {
+ return (
+
+
+ {label}
+
+
+ )
+}
+
+function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) {
+ if (node.type === 'wall') {
+ return
+ }
+
+ return
+}
+
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const nodes = useScene((state) => state.nodes)
const theme = useViewer((state) => state.theme)
@@ -316,6 +568,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
return total
}, [guide, wall])
const label = formatMeasurement(length, unit)
+ const heightLabel = `H ${formatMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}`
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
@@ -324,23 +577,50 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
+
+
+
-
-
- {label}
-
-
+ shadowColor={shadowColor}
+ />
+
+
+ )
+}
+
+function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) {
+ const theme = useViewer((state) => state.theme)
+ const unit = useViewer((state) => state.unit)
+ const isNight = theme === 'dark'
+ const color = isNight ? '#ffffff' : '#111111'
+ const shadowColor = isNight ? '#111111' : '#ffffff'
+
+ const measurement = useMemo(() => buildItemHeightGuide(item), [item])
+
+ if (!measurement) return null
+
+ return (
+
+
+
)
}
diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts
index 99ad4c06..4f1fbd8f 100644
--- a/packages/editor/src/components/tools/fence/fence-drafting.ts
+++ b/packages/editor/src/components/tools/fence/fence-drafting.ts
@@ -1,14 +1,21 @@
-import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
+import {
+ FenceNode,
+ getWallCurveFrameAt,
+ getWallCurveLength,
+ isCurvedWall,
+ useScene,
+ type WallNode,
+} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
+ findWallSnapTarget,
getWallAngleSnapStep,
getWallGridStep,
- type WallPlanPoint,
- findWallSnapTarget,
isWallLongEnough,
snapPointTo45Degrees,
snapPointToGrid,
+ type WallPlanPoint,
} from '../wall/wall-drafting'
export type FencePlanPoint = WallPlanPoint
diff --git a/packages/editor/src/components/tools/fence/fence-tool.tsx b/packages/editor/src/components/tools/fence/fence-tool.tsx
index d7091fd1..c52f9b41 100644
--- a/packages/editor/src/components/tools/fence/fence-tool.tsx
+++ b/packages/editor/src/components/tools/fence/fence-tool.tsx
@@ -7,19 +7,129 @@ import {
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
-import { useEffect, useRef } from 'react'
+import { Html } from '@react-three/drei'
+import { useEffect, useRef, useState } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
+import {
+ formatAngleRadians,
+ getAngleToSegmentReference,
+ getSegmentAngleReferenceAtPoint,
+} from '../shared/segment-angle'
import {
createFenceOnCurrentLevel,
- snapFenceDraftPoint,
type FencePlanPoint,
+ snapFenceDraftPoint,
} from './fence-drafting'
const FENCE_PREVIEW_HEIGHT = 1.8
+const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
+const DRAFT_ANGLE_LABEL_Y = 0.28
+
+type DraftAngleLabel = {
+ id: string
+ label: string
+ position: [number, number, number]
+}
+
+type DraftMeasurementState = {
+ lengthLabel: string
+ lengthPosition: [number, number, number]
+ angleLabels: DraftAngleLabel[]
+} | null
+
+type SegmentLike = {
+ id: string
+ start: FencePlanPoint
+ end: FencePlanPoint
+ curveOffset?: number
+}
+
+function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
+ if (unit === 'imperial') {
+ const feet = value * 3.280_84
+ const wholeFeet = Math.floor(feet)
+ const inches = Math.round((feet - wholeFeet) * 12)
+ if (inches === 12) return `${wholeFeet + 1}'0"`
+ return `${wholeFeet}'${inches}"`
+ }
+
+ return `${Number.parseFloat(value.toFixed(2))}m`
+}
+
+function getDraftAngleLabels(
+ start: FencePlanPoint,
+ end: FencePlanPoint,
+ segments: SegmentLike[],
+): DraftAngleLabel[] {
+ const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
+ const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
+ 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 angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
+ if (angle === null) continue
+
+ labels.push({
+ id: endpoint.id,
+ label: formatAngleRadians(angle),
+ position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
+ })
+ }
+
+ return labels
+}
+
+function getDraftMeasurementState(
+ start: FencePlanPoint,
+ end: FencePlanPoint,
+ segments: SegmentLike[],
+ unit: 'metric' | 'imperial',
+): 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: formatMeasurement(length, unit),
+ lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
+ angleLabels: getDraftAngleLabels(start, end, segments),
+ }
+}
+
+function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
+ return [
+ ...walls.map((wall) => ({
+ id: wall.id,
+ start: wall.start,
+ end: wall.end,
+ curveOffset: wall.curveOffset,
+ })),
+ ...fences.map((fence) => ({
+ id: fence.id,
+ start: fence.start,
+ end: fence.end,
+ curveOffset: fence.curveOffset,
+ })),
+ ]
+}
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
@@ -70,12 +180,14 @@ const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } =
}
export const FenceTool: React.FC = () => {
+ const unit = useViewer((state) => state.unit)
const cursorRef = useRef(null)
const previewRef = useRef(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
const shiftPressed = useRef(false)
+ const [draftMeasurement, setDraftMeasurement] = useState(null)
useEffect(() => {
let previousFenceEnd: [number, number] | null = null
@@ -107,9 +219,18 @@ export const FenceTool: React.FC = () => {
previousFenceEnd = currentFenceEnd
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
+ setDraftMeasurement(
+ getDraftMeasurementState(
+ [startingPoint.current.x, startingPoint.current.z],
+ snappedLocal,
+ getReferenceSegments(walls, fences),
+ unit,
+ ),
+ )
} else {
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
+ setDraftMeasurement(null)
}
}
@@ -123,6 +244,7 @@ export const FenceTool: React.FC = () => {
endingPoint.current.copy(startingPoint.current)
buildingState.current = 1
previewRef.current.visible = true
+ setDraftMeasurement(null)
} else {
const snappedEnd = snapFenceDraftPoint({
point: localClick,
@@ -137,6 +259,7 @@ export const FenceTool: React.FC = () => {
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
previewRef.current.visible = false
buildingState.current = 0
+ setDraftMeasurement(null)
}
}
@@ -153,6 +276,7 @@ export const FenceTool: React.FC = () => {
markToolCancelConsumed()
buildingState.current = 0
previewRef.current.visible = false
+ setDraftMeasurement(null)
}
}
@@ -169,7 +293,7 @@ export const FenceTool: React.FC = () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
- }, [])
+ }, [unit])
return (
@@ -185,6 +309,38 @@ export const FenceTool: React.FC = () => {
transparent
/>
+
+ {draftMeasurement && (
+ <>
+
+ {draftMeasurement.angleLabels.map((angleLabel) => (
+
+ ))}
+ >
+ )}
)
}
+
+function DraftMeasurementLabel({
+ label,
+ position,
+}: {
+ label: string
+ position: [number, number, number]
+}) {
+ return (
+
+
+ {label}
+
+
+ )
+}
diff --git a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx
index 8423ec4f..8ebb774e 100644
--- a/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx
+++ b/packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx
@@ -2,32 +2,113 @@
import {
type AnyNodeId,
- type FenceNode,
- type WallNode,
emitter,
+ type FenceNode,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
+ type WallNode,
} from '@pascal-app/core'
-import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer'
+import { Html } from '@react-three/drei'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
-import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
+import {
+ formatAngleRadians,
+ getAngleToSegmentReference,
+ getSegmentAngleReferenceAtPoint,
+} from '../shared/segment-angle'
import { isWallLongEnough } from '../wall/wall-drafting'
+import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
+type SegmentLike = {
+ id: string
+ start: FencePlanPoint
+ end: FencePlanPoint
+ curveOffset?: number
+}
+
+type AngleLabelState = {
+ label: string
+ position: [number, number, number]
+} | null
+
+function getEndpointAngleLabel(args: {
+ preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
+ segments: SegmentLike[]
+ nodeId: FenceNode['id']
+}): AngleLabelState {
+ const { preview, segments, nodeId } = args
+ const endpoints = [
+ {
+ point: preview.start,
+ },
+ {
+ point: preview.end,
+ },
+ ]
+ const targetSegment: SegmentLike = {
+ id: nodeId,
+ start: preview.start,
+ end: preview.end,
+ curveOffset: preview.curveOffset,
+ }
+
+ for (const endpoint of endpoints) {
+ const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
+ if (!targetReference) continue
+
+ const connectedSegment = segments.find(
+ (segment) =>
+ segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
+ )
+ if (!connectedSegment) continue
+
+ const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
+ if (!connectedReference) continue
+
+ const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
+ if (angle === null) continue
+
+ return {
+ label: formatAngleRadians(angle),
+ position: [endpoint.point[0], 0.34, endpoint.point[1]],
+ }
+ }
+
+ return null
+}
+
+function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
+ return [
+ ...walls.map((wall) => ({
+ id: wall.id,
+ start: wall.start,
+ end: wall.end,
+ curveOffset: wall.curveOffset,
+ })),
+ ...fences.map((fence) => ({
+ id: fence.id,
+ start: fence.start,
+ end: fence.end,
+ curveOffset: fence.curveOffset,
+ })),
+ ]
+}
+
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
+ curveOffset?: number
}
function getLinkedFenceSnapshots(args: {
@@ -62,6 +143,7 @@ function getLinkedFenceSnapshots(args: {
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
+ curveOffset: node.curveOffset,
})
}
@@ -77,6 +159,7 @@ function getLinkedFenceUpdates(
) {
return linkedFences.map((fence) => ({
id: fence.id,
+ curveOffset: fence.curveOffset,
start: samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
@@ -112,6 +195,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
+ const [angleLabel, setAngleLabel] = useState(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
@@ -158,27 +242,35 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
+ const linkedUpdates = detachLinkedFences
+ ? []
+ : getLinkedFenceUpdates(
+ linkedOriginalsRef.current,
+ originalStart,
+ originalEnd,
+ nextStart,
+ nextEnd,
+ )
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
- applyNodePreview([
- { id: nodeId, start: nextStart, end: nextEnd },
- ...(detachLinkedFences
- ? []
- : getLinkedFenceUpdates(
- linkedOriginalsRef.current,
- originalStart,
- originalEnd,
- nextStart,
- nextEnd,
- )),
- ])
+ setAngleLabel(
+ getEndpointAngleLabel({
+ preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
+ segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
+ nodeId,
+ }),
+ )
+ applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
}
- const restoreOriginal = () => {
+ const restoreOriginal = (clearAngleLabel = true) => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
+ if (clearAngleLabel) {
+ setAngleLabel(null)
+ }
}
const onGridMove = (event: GridEvent) => {
@@ -240,6 +332,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
+ setAngleLabel(null)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
@@ -248,6 +341,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
+ setAngleLabel(null)
markToolCancelConsumed()
exitMoveMode()
}
@@ -290,7 +384,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
return () => {
if (!wasCommitted) {
- restoreOriginal()
+ restoreOriginal(false)
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
@@ -322,6 +416,23 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =