diff --git a/apps/editor/public/icons/elevator.png b/apps/editor/public/icons/elevator.png new file mode 100644 index 00000000..2250a678 Binary files /dev/null and b/apps/editor/public/icons/elevator.png differ diff --git a/packages/editor/src/components/tools/shared/segment-angle.ts b/packages/editor/src/components/tools/shared/segment-angle.ts index 0e39a570..d72cfcde 100644 --- a/packages/editor/src/components/tools/shared/segment-angle.ts +++ b/packages/editor/src/components/tools/shared/segment-angle.ts @@ -15,6 +15,13 @@ export type SegmentAngleReference = { orientation: 'directed' | 'axis' } +export type SegmentAngleArc = { + angle: number + startAngle: number + endAngle: number + midAngle: number +} + const POINT_MATCH_TOLERANCE = 1e-5 const SEGMENT_POINT_TOLERANCE = 0.15 const CURVE_TANGENT_SAMPLE_SPACING = 0.08 @@ -92,6 +99,36 @@ export function getAngleBetweenVectors(first: PlanPoint, second: PlanPoint): num return Math.acos(cosine) } +function normalizeSignedAngle(angle: number) { + let nextAngle = angle + + while (nextAngle <= -Math.PI) { + nextAngle += Math.PI * 2 + } + + while (nextAngle > Math.PI) { + nextAngle -= Math.PI * 2 + } + + return nextAngle +} + +function getSignedAngleArc(vector: PlanPoint, referenceVector: PlanPoint): SegmentAngleArc | null { + const angle = getAngleBetweenVectors(vector, referenceVector) + if (angle === null) return null + + const startAngle = Math.atan2(referenceVector[1], referenceVector[0]) + const vectorAngle = Math.atan2(vector[1], vector[0]) + const signedDelta = normalizeSignedAngle(vectorAngle - startAngle) + + return { + angle: Math.abs(signedDelta), + startAngle, + endAngle: startAngle + signedDelta, + midAngle: startAngle + signedDelta / 2, + } +} + export function getAngleToSegmentReference( vector: PlanPoint, reference: SegmentAngleReference, @@ -111,6 +148,25 @@ export function getAngleToSegmentReference( return Math.min(angle, reverseAngle) } +export function getAngleArcToSegmentReference( + vector: PlanPoint, + reference: SegmentAngleReference, +): SegmentAngleArc | null { + const directArc = getSignedAngleArc(vector, reference.vector) + + if (!directArc || reference.orientation === 'directed') { + return directArc + } + + const reverseArc = getSignedAngleArc(vector, [-reference.vector[0], -reference.vector[1]]) + + if (!reverseArc) { + return directArc + } + + return reverseArc.angle < directArc.angle ? reverseArc : directArc +} + export function getSegmentAngleReferenceAtPoint( point: PlanPoint, segment: SegmentAngleLike, diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index aaf05805..9ef91351 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -25,7 +25,7 @@ export const tools: ToolConfig[] = [ { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, - { id: 'elevator', iconSrc: '/icons/elevator.svg', label: 'Elevator' }, + { id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index c63c512a..1613654f 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -1,22 +1,32 @@ -'use client' - -import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core' +import { + calculateLevelMiters, + emitter, + type GridEvent, + getWallMiterBoundaryPoints, + type LevelNode, + type Point2D, + useScene, + type WallMiterData, + type WallNode, +} from '@pascal-app/core' import { CursorSphere, createWallOnCurrentLevel, EDITOR_LAYER, formatAngleRadians, + getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, markToolCancelConsumed, snapWallDraftPoint, triggerSFX, + type SegmentAngleReference, type WallPlanPoint, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { useEffect, useRef, useState } from 'react' -import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' +import { useEffect, useMemo, useRef, useState } from 'react' +import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' /** * Phase 5 Stage D — wall placement tool (kind-owned). @@ -32,13 +42,25 @@ import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from * Mounted via `def.tool` from `wall/definition.ts`. */ const WALL_HEIGHT = 2.5 +const DRAFT_WALL_THICKNESS = 0.1 const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22 -const DRAFT_ANGLE_LABEL_Y = 0.28 +const DRAFT_ANGLE_LABEL_Y = WALL_HEIGHT + 0.08 +const DRAFT_ANGLE_ARC_Y = WALL_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 = { id: string label: string position: [number, number, number] + arc: { + center: WallPlanPoint + radius: number + startAngle: number + endAngle: number + y: number + } } type DraftMeasurementState = { @@ -47,6 +69,24 @@ type DraftMeasurementState = { angleLabels: DraftAngleLabel[] } | null +type FaceAngleCandidate = { + index: number + point: WallPlanPoint + vector: WallPlanPoint +} + +type FaceAnglePair = { + draft: FaceAngleCandidate + connected: FaceAngleCandidate + distance: number +} + +type AngleSource = { + arcCenter: WallPlanPoint + connectedVector: WallPlanPoint + draftVector: WallPlanPoint +} + function formatMeasurement(value: number, unit: 'metric' | 'imperial') { if (unit === 'imperial') { const feet = value * 3.280_84 @@ -58,13 +98,171 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') { 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: WallPlanPoint, b: WallPlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + + return dx * dx + dz * dz +} + +function pointMatches(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-5) { + return distanceSquared(a, b) <= tolerance * tolerance +} + +function toWallPlanPoint(point: Point2D): WallPlanPoint { + return [point.x, point.y] +} + +function getWallEndpointKind(point: WallPlanPoint, wall: WallNode): 'start' | 'end' | null { + if (pointMatches(point, wall.start)) return 'start' + if (pointMatches(point, wall.end)) return 'end' + + return null +} + +function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode { + return { + object: 'node', + id: 'wall_draft' as WallNode['id'], + type: 'wall', + name: 'Draft wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start, + end, + thickness: DRAFT_WALL_THICKNESS, + frontSide: 'unknown', + backSide: 'unknown', + } +} + +function getWallFaceAngleCandidates( + point: WallPlanPoint, + wall: WallNode, + miterData: WallMiterData, +): FaceAngleCandidate[] { + const endpoint = getWallEndpointKind(point, wall) + const reference = getSegmentAngleReferenceAtPoint(point, wall) + if (!(endpoint && reference)) return [] + + const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData) + if (!boundaryPoints) return [] + + const points = + endpoint === 'start' + ? [boundaryPoints.startLeft, boundaryPoints.startRight] + : [boundaryPoints.endLeft, boundaryPoints.endRight] + + return points.map((facePoint, index) => ({ + index, + point: toWallPlanPoint(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() + const usedConnectedIndexes = new Set() + 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: WallPlanPoint, + endpointDraftVector: WallPlanPoint, + connectedReference: SegmentAngleReference, + facePairs: FaceAnglePair[], +): AngleSource { + if (facePairs.length === 0) { + return { + arcCenter: endpointPoint, + connectedVector: connectedReference.vector, + draftVector: endpointDraftVector, + } + } + + const arc = getAngleArcToSegmentReference(endpointDraftVector, connectedReference) + const angleDirection: WallPlanPoint = arc + ? [Math.cos(arc.midAngle), Math.sin(arc.midAngle)] + : [endpointDraftVector[0], endpointDraftVector[1]] + const bestPair = + facePairs + .map((pair) => { + const arcCenter: WallPlanPoint = [ + (pair.draft.point[0] + pair.connected.point[0]) / 2, + (pair.draft.point[1] + pair.connected.point[1]) / 2, + ] + const fromEndpoint: WallPlanPoint = [ + arcCenter[0] - endpointPoint[0], + arcCenter[1] - endpointPoint[1], + ] + + return { + arcCenter, + 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: WallPlanPoint, end: WallPlanPoint, walls: WallNode[], + baseY: number, ): DraftAngleLabel[] { const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]] const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]] + const draftWall = buildDraftWall(start, end) + const miterData = calculateLevelMiters([...walls, draftWall]) const endpoints = [ { id: 'start', point: start, draftVector: draftFromStart }, { id: 'end', point: end, draftVector: draftFromEnd }, @@ -78,12 +276,52 @@ function getDraftAngleLabels( if (!connectedWall) continue const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall) if (!connectedReference) continue - const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference) + + const draftFaceCandidates = getWallFaceAngleCandidates(endpoint.point, draftWall, miterData) + const connectedFaceCandidates = getWallFaceAngleCandidates( + endpoint.point, + connectedWall, + 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: [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, + }, }) } @@ -95,6 +333,7 @@ function getDraftMeasurementState( end: WallPlanPoint, walls: WallNode[], unit: 'metric' | 'imperial', + baseY: number, ): DraftMeasurementState { const dx = end[0] - start[0] const dz = end[1] - start[1] @@ -102,8 +341,8 @@ function getDraftMeasurementState( 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, walls), + lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2], + angleLabels: getDraftAngleLabels(start, end, walls, baseY), } } @@ -117,20 +356,15 @@ function updateWallPreview(mesh: Mesh, start: Vector3, end: Vector3) { mesh.visible = true direction.normalize() - const shape = new Shape() - shape.moveTo(0, 0) - shape.lineTo(length, 0) - shape.lineTo(length, WALL_HEIGHT) - shape.lineTo(0, WALL_HEIGHT) - shape.closePath() + const geometry = new BoxGeometry(length, WALL_HEIGHT, DRAFT_WALL_THICKNESS) + const angle = Math.atan2(direction.z, direction.x) - const geometry = new ShapeGeometry(shape) - const angle = -Math.atan2(direction.z, direction.x) + mesh.position.set((start.x + end.x) / 2, start.y + WALL_HEIGHT / 2, (start.z + end.z) / 2) + mesh.rotation.y = -angle - mesh.position.set(start.x, start.y, start.z) - mesh.rotation.y = angle - - if (mesh.geometry) mesh.geometry.dispose() + if (mesh.geometry) { + mesh.geometry.dispose() + } mesh.geometry = geometry } @@ -147,6 +381,7 @@ function getCurrentLevelWalls(): WallNode[] { export const WallTool: React.FC = () => { const unit = useViewer((state) => state.unit) + const theme = useViewer((state) => state.theme) const cursorRef = useRef(null) const wallPreviewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) @@ -154,6 +389,8 @@ export const WallTool: React.FC = () => { const buildingState = useRef(0) const shiftPressed = useRef(false) const [draftMeasurement, setDraftMeasurement] = useState(null) + const measurementColor = theme === 'dark' ? '#ffffff' : '#111111' + const measurementShadowColor = theme === 'dark' ? '#111111' : '#ffffff' useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] @@ -192,6 +429,7 @@ export const WallTool: React.FC = () => { snappedLocal, walls, unit, + startingPoint.current.y, ), ) } else { @@ -278,15 +516,21 @@ export const WallTool: React.FC = () => { {draftMeasurement && ( <> {draftMeasurement.angleLabels.map((angleLabel) => ( - + + + + ))} )} @@ -294,16 +538,67 @@ export const WallTool: 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. + + + + ) +} + function DraftMeasurementLabel({ + color, label, position, + shadowColor, }: { + color: string label: string position: [number, number, number] + shadowColor: string }) { return ( - -
+ +
{label}