diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 959df800..8304e0d9 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -64,6 +64,7 @@ import { import { createPortal } from 'react-dom' import { Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' +import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, buildFloorplanItemEntry, @@ -8425,17 +8426,22 @@ export function FloorplanPanel() { if (isCeilingBuildActive) { // Polygon vertex: grid (snapToHalf) + optional 45° angle snap from - // the previous vertex. Alignment runs only when angle snap is OFF - // (first vertex, or Shift held) — when the angle is being locked, - // pulling the vertex sideways would break it. + // the previous vertex. Wall magnetic snap may still win, while + // generic alignment runs only when angle snap is OFF (first vertex, + // or Shift held) so it does not pull a locked angle sideways. const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed - let snappedPoint = snapPolygonDraftPoint({ + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], angleSnap, }) - if (angleSnap) useAlignmentGuides.getState().clear() - else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) + const snappedPoint = resolveCeilingPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -8676,6 +8682,7 @@ export function FloorplanPanel() { isPolygonBuildActive, isRoofBuildActive, isWallBuildActive, + levelId, publishFloorplanNavigationPose, smoothFloorplanNavigationView, referenceScaleDraft, @@ -8934,6 +8941,7 @@ export function FloorplanPanel() { isRoofBuildActive, isWallBuildActive, isZoneBuildActive, + levelId, roofDraftStart, setCursorPoint, setFenceDraftEnd, @@ -9110,25 +9118,33 @@ export function FloorplanPanel() { return } - const snappedPoint = snapPolygonDraftPoint({ + const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], - angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed, + angleSnap, }) if (isCeilingBuildActive) { - emitFloorplanGridEvent('double-click', planPoint, event) + const snappedPoint = resolveCeilingPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point + emitFloorplanGridEvent('double-click', snappedPoint, event) handleCeilingPlacementConfirm(snappedPoint) return } if (isZoneBuildActive) { - handleZonePlacementConfirm(snappedPoint) + handleZonePlacementConfirm(fallbackPoint) } else { // Slab is registry-driven: forward the double-click so the 3D tool // commits the node (zone has no registry tool, so it commits locally). emitFloorplanGridEvent('double-click', planPoint, event) - handleSlabPlacementConfirm(snappedPoint) + handleSlabPlacementConfirm(fallbackPoint) } }, [ @@ -9142,6 +9158,7 @@ export function FloorplanPanel() { isPolygonDraftBuildActive, isRoofBuildActive, isZoneBuildActive, + levelId, shiftPressed, ], ) diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 31c013b6..6dd16c43 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -2,6 +2,7 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core' import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' +import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { @@ -51,6 +52,7 @@ type UseFloorplanBackgroundPlacementArgs = { isRoofBuildActive: boolean isWallBuildActive: boolean isZoneBuildActive: boolean + levelId: string | null roofDraftStart: WallPlanPoint | null setCursorPoint: React.Dispatch> setFenceDraftEnd: React.Dispatch> @@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({ isRoofBuildActive, isWallBuildActive, isZoneBuildActive, + levelId, roofDraftStart, setCursorPoint, setFenceDraftEnd, @@ -149,17 +152,22 @@ export function useFloorplanBackgroundPlacement({ if (isCeilingBuildActive) { // Align the committed vertex the same way the move-preview did, so - // the placed point matches what the user saw. Skip when angle snap - // owns the vertex (matches the move branch). + // the placed point matches what the user saw. Wall magnetic snap may + // still win; generic alignment is skipped when angle snap owns the + // vertex (matches the move branch). const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed - let snappedPoint = snapPolygonDraftPoint({ + const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, start: ceilingDraftPoints[ceilingDraftPoints.length - 1], angleSnap, }) - if (!angleSnap) { - snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) - } + const snappedPoint = resolveCeilingPlanPointSnap({ + rawPoint: planPoint, + fallbackPoint, + levelId, + altKey: event.altKey, + align: !angleSnap, + }).point emitFloorplanGridEvent('click', snappedPoint, event) handleCeilingPlacementPoint(snappedPoint) @@ -322,6 +330,7 @@ export function useFloorplanBackgroundPlacement({ isRoofBuildActive, isWallBuildActive, isZoneBuildActive, + levelId, roofDraftStart, setCursorPoint, setFenceDraftEnd, diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index 278cab1d..9d3c2ed0 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -1,10 +1,23 @@ 'use client' -import { sceneRegistry } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + DEFAULT_WALL_HEIGHT, + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + isCurvedWall, + resolveLevelId, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, +} from '@pascal-app/core' import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' -import { memo, useRef } from 'react' +import { memo, useMemo, useRef } from 'react' import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' @@ -34,6 +47,14 @@ const BEACON_HEIGHT = 2.5 // world-meter height of the pillar const BEACON_RADIUS = 0.018 // world-meter radius of the pillar const MARKER = 0.13 // world-meter base size of the floor glyph const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid +const WALL_TOP_HIGHLIGHT_LIFT = 0.035 +const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018 +const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14 +const WALL_TOP_GLOW_HEIGHT = 0.026 +const WALL_TOP_GLOW_OVERHANG = 0.36 +const WALL_TOP_END_OVERHANG = 0.08 +const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45 +const NO_RAYCAST = () => null // Shared resources — one material + unit geometries, so snap churn during a // drag doesn't rebuild GPU buffers (mirrors the alignment guide layer). @@ -45,17 +66,41 @@ const beaconMaterial = new MeshBasicNodeMaterial({ transparent: true, opacity: 0.9, }) +const wallTopHighlightMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.88, +}) +const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({ + color: BEACON_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.26, +}) const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8) // Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as // a flat plate; circles/triangles lie flat via an X rotation at the mesh. const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1) +const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1) const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3) const CIRCLE_GEOMETRY = new CircleGeometry(1, 28) export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { const point = useWallSnapIndicator((s) => s.point) const levelId = useViewer((s) => s.selection.levelId) + const nodes = useScene((s) => s.nodes) const groupRef = useRef(null) + const highlightedWalls = useMemo(() => { + if (!point?.wallIds?.length) return [] + return point.wallIds + .map((wallId) => nodes[wallId as AnyNodeId]) + .filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false) + }, [nodes, point?.wallIds]) // Track the active level's building-local Y each frame so the beacon stands // on the floor being edited, not the building base — same source the @@ -70,6 +115,9 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { if (!point) return null return ( + {highlightedWalls.map((wall) => ( + + ))} >) { + const levelId = resolveLevelId(wall, nodes as Record) + const slabElevation = spatialGridManager.getSlabElevationForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + ) + const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT +} + +function buildHighlightSegment(start: [number, number], end: [number, number]) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 1e-6) return null + + return { + angle: -Math.atan2(dz, dx), + center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number], + length, + } +} + +function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] { + if (!isCurvedWall(wall)) { + const segment = buildHighlightSegment(wall.start, wall.end) + return segment ? [segment] : [] + } + + const sampleCount = Math.max( + 8, + Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH), + ) + const segments: WallTopHighlightSegment[] = [] + let previous = getWallCurveFrameAt(wall, 0).point + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(wall, index / sampleCount).point + const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y]) + if (segment) segments.push(segment) + previous = current + } + return segments +} + +function WallTopHighlight({ + nodes, + wall, +}: { + nodes: Readonly> + wall: WallNode +}) { + const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall]) + const y = getWallTopY(wall, nodes) + const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24) + const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42) + + return ( + <> + {segments.map((segment, index) => ( + + + + + ))} + + ) +} + /** Floor glyph whose shape encodes which kind of geometry the point snapped to. */ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) { const y = FLOOR_LIFT diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 218beccd..c45fb78d 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -13,6 +13,10 @@ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' +import { + clearCeilingSnapFeedback, + resolveCeilingPlanPointSnap, +} from '../../../lib/ceiling-plan-snap' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { snapToHalf } from '../../tools/item/placement-math' @@ -84,6 +88,7 @@ function clearCornerDragPreview(drag: CornerDragState) { if (drag.inputDraggingSet) { useViewer.getState().setInputDragging(drag.previousInputDragging) } + clearCeilingSnapFeedback() } export const CeilingSelectionAffordanceSystem = () => { @@ -284,10 +289,21 @@ const CeilingSelectionAffordance = ({ const initialCorner = drag.initialPolygon[drag.cornerIndex] if (!initialCorner) return - const nextPosition: [number, number] = [ + const rawNextPosition: [number, number] = [ + initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]), + initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]), + ] + const gridNextPosition: [number, number] = [ initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), ] + const nextPosition = resolveCeilingPlanPointSnap({ + rawPoint: rawNextPosition, + fallbackPoint: gridNextPosition, + levelId, + excludeId: drag.ceilingId, + altKey: event.altKey, + }).point if ( drag.previousSnappedPosition && @@ -354,7 +370,7 @@ const CeilingSelectionAffordance = ({ dragRef.current = null clearCornerDragPreview(drag) } - }, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit]) + }, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit]) useEffect(() => { let frameId = 0 diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 2d651f39..5d9472ec 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -79,6 +79,17 @@ type DragState = { pointerId: number } +export type PolygonEditorPlanPointSnapContext = { + rawPoint: [number, number] + gridPoint: [number, number] + mode: DragState['mode'] + vertexIndex: number | null + edgeIndex?: number + initialPosition: [number, number] + initialPolygon: Array<[number, number]> + nativeEvent?: GridEvent['nativeEvent'] +} + export interface PolygonEditorProps { polygon: Array<[number, number]> color?: string @@ -120,6 +131,8 @@ export interface PolygonEditorProps { showMidpointHandles?: boolean /** Whether hovering a handle should also tint its connected edges and endpoint handles. */ highlightConnectedHandles?: boolean + /** Optional host-owned point snapper. Defaults to the existing half-grid snap. */ + resolvePlanPoint?: (context: PolygonEditorPlanPointSnapContext) => [number, number] /** Optional vertex handle renderer for host-specific affordances. */ renderVertexHandle?: PolygonVertexHandleRenderer /** Optional midpoint handle renderer for host-specific add-vertex affordances. */ @@ -397,6 +410,7 @@ export const PolygonEditor: React.FC = ({ showBorderLine = true, showMidpointHandles = true, highlightConnectedHandles = false, + resolvePlanPoint, renderMidpointHandle, renderVertexHandle, }) => { @@ -731,9 +745,21 @@ export const PolygonEditor: React.FC = ({ useEffect(() => { const onGridMove = (event: GridEvent) => { const point = levelNode ? event.localPosition : event.position - const gridX = snapToHalf(point[0]) - const gridZ = snapToHalf(point[2]) - const newPosition: [number, number] = [gridX, gridZ] + const rawPoint: [number, number] = [point[0], point[2]] + const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])] + const newPosition = + dragState?.isDragging && resolvePlanPoint + ? resolvePlanPoint({ + rawPoint, + gridPoint, + mode: dragState.mode, + vertexIndex: dragState.vertexIndex, + edgeIndex: dragState.edgeIndex, + initialPosition: dragState.initialPosition, + initialPolygon: dragState.initialPolygon, + nativeEvent: event.nativeEvent, + }) + : gridPoint // Play snap sound when cursor moves to a new grid cell during drag if ( @@ -788,7 +814,7 @@ export const PolygonEditor: React.FC = ({ return () => { emitter.off('grid:move', onGridMove) } - }, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon]) + }, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon]) // Set up pointer up listener for ending drag useEffect(() => { diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index fe1982fa..4491c70f 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -20,6 +20,7 @@ import { WALL_JOIN_SNAP_RADIUS, type WallDraftSnapResult, type WallPlanPoint, + type WallSnapRadii, } from './wall-snap-geometry' // The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so @@ -30,6 +31,7 @@ export { type WallDraftSnapKind, type WallDraftSnapResult, type WallPlanPoint, + type WallSnapRadii, } from './wall-snap-geometry' export const WALL_GRID_STEP = 0.5 @@ -345,6 +347,8 @@ type SnapWallDraftArgs = { * local-axis grid at `step`. */ gridSnap?: (point: WallPlanPoint) => WallPlanPoint + /** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */ + snapRadii?: WallSnapRadii } export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult { @@ -357,13 +361,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn step: overrideStep, magnetic = true, gridSnap, + snapRadii, } = args // Discrete special points (corner / midpoint / crossing) are taken from the // raw cursor so an interim grid snap can't mask them. A corner always wins, // then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`. if (magnetic) { - const special = findWallSpecialPointSnap(point, walls, ignoreWallIds) + const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii) if (special) return special } @@ -377,7 +382,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn : snapPointToGrid(point, step) if (magnetic) { - const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds }) + const wallSnap = findWallSnapTarget(basePoint, walls, { + ignoreWallIds, + radius: snapRadii?.wall, + }) if (wallSnap) return { point: wallSnap, snap: 'wall' } } diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts index 4aadb80d..35829dcd 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.test.ts @@ -62,6 +62,13 @@ describe('findWallSpecialPointSnap', () => { // handled separately by findWallSnapTarget, not a special point. expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull() }) + + test('honors tighter per-call radii without changing defaults', () => { + const walls = [makeWall([0, 0], [4, 0])] + + expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint') + expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull() + }) }) describe('findWallSnapTarget (edge / along-wall)', () => { @@ -76,4 +83,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => { const walls = [makeWall([0, 0], [4, 0])] expect(findWallSnapTarget([1.2, 2], walls)).toBeNull() }) + + test('honors a tighter wall-body radius', () => { + const walls = [makeWall([0, 0], [4, 0])] + + expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull() + }) }) diff --git a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts index 049e7a11..b94b7000 100644 --- a/packages/editor/src/components/tools/wall/wall-snap-geometry.ts +++ b/packages/editor/src/components/tools/wall/wall-snap-geometry.ts @@ -15,6 +15,8 @@ export type WallPlanPoint = [number, number] /** Which kind of existing-geometry snap produced a drafted point. */ export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall' +export type WallSnapRadii = Partial> + export type WallDraftSnapResult = { point: WallPlanPoint /** @@ -119,9 +121,10 @@ export function findWallEndpointFromRaw( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radius = WALL_ENDPOINT_SNAP_RADIUS, ): WallPlanPoint | null { const ignored = new Set(ignoreWallIds ?? []) - const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2 + const radiusSquared = radius ** 2 let best: WallPlanPoint | null = null let bestDistSq = Number.POSITIVE_INFINITY @@ -152,9 +155,10 @@ export function findWallMidpointFromRaw( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radius = WALL_MIDPOINT_SNAP_RADIUS, ): WallPlanPoint | null { const ignored = new Set(ignoreWallIds ?? []) - const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2 + const radiusSquared = radius ** 2 let best: WallPlanPoint | null = null let bestDistSq = Number.POSITIVE_INFINITY @@ -202,10 +206,11 @@ export function findWallIntersectionFromRaw( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radius = WALL_INTERSECTION_SNAP_RADIUS, ): WallPlanPoint | null { const ignored = new Set(ignoreWallIds ?? []) const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall)) - const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2 + const radiusSquared = radius ** 2 let best: WallPlanPoint | null = null let bestDistSq = Number.POSITIVE_INFINITY @@ -257,12 +262,13 @@ export function findWallSpecialPointSnap( point: WallPlanPoint, walls: WallNode[], ignoreWallIds?: string[], + radii?: WallSnapRadii, ): WallDraftSnapResult | null { - const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds) + const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint) if (endpoint) return { point: endpoint, snap: 'endpoint' } - const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds) - const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds) + const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint) + const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection) return nearestCandidate(point, [ midpoint && { point: midpoint, snap: 'midpoint' }, intersection && { point: intersection, snap: 'intersection' }, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5bc28f58..16a81e18 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -65,6 +65,7 @@ export { useFreshPlacementVisibility } from './components/tools/shared/fresh-pla // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, + type PolygonEditorPlanPointSnapContext, type PolygonEditorProps, } from './components/tools/shared/polygon-editor' export { @@ -108,6 +109,7 @@ export { type WallDraftSnapKind, type WallDraftSnapResult, type WallPlanPoint, + type WallSnapRadii, } from './components/tools/wall/wall-drafting' // `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the // existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the @@ -172,6 +174,13 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). export { markToolCancelConsumed } from './hooks/use-keyboard' export { type Selection, useSelection } from './hooks/use-selection' +export { + CEILING_ALIGNMENT_THRESHOLD_M, + type CeilingPlanSnapInput, + type CeilingPlanSnapResult, + clearCeilingSnapFeedback, + resolveCeilingPlanPointSnap, +} from './lib/ceiling-plan-snap' export { EDITOR_LAYER } from './lib/constants' // Helper libs used by the kind-owned roof / stair / elevator panels. export { diff --git a/packages/editor/src/lib/ceiling-plan-snap.ts b/packages/editor/src/lib/ceiling-plan-snap.ts new file mode 100644 index 00000000..d5351cf2 --- /dev/null +++ b/packages/editor/src/lib/ceiling-plan-snap.ts @@ -0,0 +1,232 @@ +import { + type AlignmentAnchor, + type AlignmentGuide, + type AnyNode, + collectAlignmentAnchors, + getWallCurveFrameAt, + getWallCurveLength, + isCurvedWall, + resolveAlignment, + resolveLevelId, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + getSegmentGridStep, + snapWallDraftPointDetailed, + type WallDraftSnapKind, + type WallPlanPoint, + type WallSnapRadii, +} from '../components/tools/wall/wall-drafting' +import useAlignmentGuides from '../store/use-alignment-guides' +import useEditor from '../store/use-editor' +import useWallSnapIndicator from '../store/use-wall-snap-indicator' + +const CEILING_SNAP_MOVING_ID = '__ceiling_snap__' +export const CEILING_ALIGNMENT_THRESHOLD_M = 0.08 +const CEILING_WALL_SNAP_RADII = { + endpoint: 0.38, + midpoint: 0.28, + intersection: 0.28, + wall: 0.18, +} satisfies WallSnapRadii +const WALL_SOURCE_MATCH_EPSILON = 0.035 + +export type CeilingPlanSnapInput = { + rawPoint: WallPlanPoint + fallbackPoint?: WallPlanPoint + levelId?: string | null + excludeId?: string | null + movingId?: string + nodes?: Readonly> + walls?: readonly WallNode[] + candidates?: readonly AlignmentAnchor[] + threshold?: number + altKey?: boolean + magnetic?: boolean + align?: boolean + step?: number + snapRadii?: WallSnapRadii +} + +export type CeilingPlanSnapResult = { + point: WallPlanPoint + wallSnap: WallDraftSnapKind | null + guides: AlignmentGuide[] + wallIds: string[] +} + +function getLevelWalls( + nodes: Readonly>, + levelId: string | null | undefined, + walls?: readonly WallNode[], +): WallNode[] { + const source = + walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall') + if (!levelId) return source.filter((wall) => wall.visible !== false) + + return source.filter( + (wall) => + wall.visible !== false && resolveLevelId(wall, nodes as Record) === levelId, + ) +} + +function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +function wallMidpoint(wall: WallNode): WallPlanPoint { + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, 0.5) + return [frame.point.x, frame.point.y] + } + return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] +} + +function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-9) return distanceSquared(point, start) + + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t] + return distanceSquared(point, projected) +} + +function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) { + if (!isCurvedWall(wall)) { + return distanceToSegmentSquared(point, wall.start, wall.end) + } + + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3)) + let bestDistanceSquared = Number.POSITIVE_INFINITY + let previous = getWallCurveFrameAt(wall, 0).point + for (let index = 1; index <= sampleCount; index += 1) { + const current = getWallCurveFrameAt(wall, index / sampleCount).point + const distance = distanceToSegmentSquared( + point, + [previous.x, previous.y], + [current.x, current.y], + ) + bestDistanceSquared = Math.min(bestDistanceSquared, distance) + previous = current + } + return bestDistanceSquared +} + +function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) { + return walls + .map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) })) + .sort((a, b) => a.distance - b.distance) + .slice(0, count) + .map(({ id }) => id) +} + +function findSnapSourceWallIds( + point: WallPlanPoint, + kind: WallDraftSnapKind, + walls: readonly WallNode[], +): string[] { + const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2 + + if (kind === 'endpoint') { + const endpointMatches = walls.filter( + (wall) => + distanceSquared(point, wall.start) <= epsilonSquared || + distanceSquared(point, wall.end) <= epsilonSquared, + ) + if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id) + return closestWallIds(point, walls, 1) + } + + if (kind === 'midpoint') { + const midpointMatches = walls.filter( + (wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared, + ) + if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id) + return closestWallIds(point, walls, 1) + } + + if (kind === 'intersection') { + const crossingMatches = walls.filter( + (wall) => distanceToWallSquared(point, wall) <= epsilonSquared, + ) + if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2) + return closestWallIds(point, walls, 2) + } + + return closestWallIds(point, walls, 1) +} + +export function clearCeilingSnapFeedback() { + useAlignmentGuides.getState().clear() + useWallSnapIndicator.getState().clear() +} + +export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult { + const nodes = input.nodes ?? useScene.getState().nodes + const walls = getLevelWalls(nodes, input.levelId, input.walls) + const fallbackPoint = input.fallbackPoint + const magnetic = input.magnetic ?? useEditor.getState().magneticSnap + + const wallSnap = snapWallDraftPointDetailed({ + point: input.rawPoint, + walls, + step: input.step ?? getSegmentGridStep(), + magnetic, + snapRadii: input.snapRadii ?? CEILING_WALL_SNAP_RADII, + gridSnap: fallbackPoint ? () => fallbackPoint : undefined, + }) + + if (wallSnap.snap) { + const wallIds = findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls) + useWallSnapIndicator + .getState() + .set({ x: wallSnap.point[0], z: wallSnap.point[1], kind: wallSnap.snap, wallIds }) + useAlignmentGuides.getState().clear() + return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds } + } + + useWallSnapIndicator.getState().clear() + + const basePoint = fallbackPoint ?? wallSnap.point + if (input.align === false || input.altKey) { + useAlignmentGuides.getState().clear() + return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } + } + + const movingId = input.movingId ?? CEILING_SNAP_MOVING_ID + const candidates = + input.candidates ?? + collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null) + + if (candidates.length === 0) { + useAlignmentGuides.getState().clear() + return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } + } + + const alignment = resolveAlignment({ + moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }], + candidates, + threshold: input.threshold ?? CEILING_ALIGNMENT_THRESHOLD_M, + }) + + useAlignmentGuides.getState().set(alignment.guides) + + if (!alignment.snap) { + return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] } + } + + return { + point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz], + wallSnap: null, + guides: alignment.guides, + wallIds: [], + } +} diff --git a/packages/editor/src/store/use-wall-snap-indicator.ts b/packages/editor/src/store/use-wall-snap-indicator.ts index cd23e29c..9cdbd4a9 100644 --- a/packages/editor/src/store/use-wall-snap-indicator.ts +++ b/packages/editor/src/store/use-wall-snap-indicator.ts @@ -15,6 +15,8 @@ export type WallSnapPoint = { x: number z: number kind: WallSnapKind + /** Optional wall ids whose geometry produced this snap. */ + wallIds?: string[] } type WallSnapIndicatorState = { diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 0ae77a9d..9093b7fe 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -1,7 +1,13 @@ 'use client' import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' -import { PolygonEditor, triggerSFX } from '@pascal-app/editor' +import { + clearCeilingSnapFeedback, + PolygonEditor, + type PolygonEditorPlanPointSnapContext, + resolveCeilingPlanPointSnap, + triggerSFX, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -36,9 +42,13 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = () => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), [ceiling, liveOverride], ) + const ceilingLevelId = effectiveCeiling + ? resolveLevelId(effectiveCeiling, useScene.getState().nodes) + : null const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { + clearCeilingSnapFeedback() updateNode(ceilingId, { polygon: newPolygon }) setSelection({ selectedIds: [ceilingId] }) }, @@ -87,12 +97,18 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = (isDragging: boolean) => { if (!isDragging) { ownsPolygonPreviewRef.current = false + clearCeilingSnapFeedback() } setCeilingHandleHover(isDragging) }, [setCeilingHandleHover], ) + const handlePolygonEditorDragCommit = useCallback(() => { + triggerSFX('sfx:item-place') + clearCeilingSnapFeedback() + }, []) + const handlePolygonEditorDragStart = useCallback(() => { ownsPolygonPreviewRef.current = true triggerSFX('sfx:item-pick') @@ -102,8 +118,21 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = ownsPolygonPreviewRef.current = true }, []) + const resolvePolygonEditorPlanPoint = useCallback( + (context: PolygonEditorPlanPointSnapContext) => + resolveCeilingPlanPointSnap({ + rawPoint: context.rawPoint, + fallbackPoint: context.gridPoint, + levelId: ceilingLevelId, + excludeId: ceilingId, + altKey: context.nativeEvent?.altKey === true, + }).point, + [ceilingId, ceilingLevelId], + ) + useEffect(() => { return () => { + clearCeilingSnapFeedback() useLiveNodeOverrides.getState().clear(ceilingId) useScene.getState().markDirty(ceilingId) ownsPolygonPreviewRef.current = false @@ -121,18 +150,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = allowEdgeMove color="#d4d4d4" highlightConnectedHandles - levelId={resolveLevelId(effectiveCeiling, useScene.getState().nodes)} + levelId={ceilingLevelId ?? undefined} minVertices={3} onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag} - onDragStateChange={handleDragStateChange} - onDragCommit={() => triggerSFX('sfx:item-place')} + onDragCommit={handlePolygonEditorDragCommit} onDragStart={handlePolygonEditorDragStart} + onDragStateChange={handleDragStateChange} onEdgeHoverChange={handleHandleHoverChange} onMidpointHoverChange={handleHandleHoverChange} onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} onVertexHoverChange={handleHandleHoverChange} polygon={effectiveCeiling.polygon} + resolvePlanPoint={resolvePolygonEditorPlanPoint} surfaceHeight={effectiveCeiling.height ?? 2.5} /> ) diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index 77648015..d70c560f 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -1,19 +1,13 @@ 'use client' -import { - collectAlignmentAnchors, - emitter, - type GridEvent, - type LevelNode, - resolveAlignment, - useScene, -} from '@pascal-app/core' +import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core' import { CursorSphere, + clearCeilingSnapFeedback, EDITOR_LAYER, markToolCancelConsumed, + resolveCeilingPlanPointSnap, triggerSFX, - useAlignmentGuides, useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -33,8 +27,6 @@ import { CeilingNode } from './schema' const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 -/** Figma-style alignment-snap threshold (meters), matching the move tools. */ -const ALIGNMENT_THRESHOLD_M = 0.08 function calculateSnapPoint( lastPoint: [number, number], @@ -93,10 +85,7 @@ export const CeilingTool: React.FC = () => { // draw isn't built with a stale preset's parameters. Unmount-only. useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), []) - // Clear alignment guides on unmount ONLY. The main drawing effect re-runs - // on every cursor move (cursorPosition is in its deps), so clearing guides - // in its cleanup would wipe the guide the instant after each move sets it. - useEffect(() => () => useAlignmentGuides.getState().clear(), []) + useEffect(() => () => clearCeilingSnapFeedback(), []) const verticalGeo = useMemo( () => @@ -115,44 +104,6 @@ export const CeilingTool: React.FC = () => { useEffect(() => { if (!currentLevelId) return - // Alignment candidates — anchors of every OTHER alignable object. The - // ceiling's own in-progress vertices are intentionally excluded (no - // self-alignment while drawing). - const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') - // Snap the drafted vertex onto another object's nearest real anchor and - // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped - // point: resolving against the grid point would only ever catch anchors - // that happen to sit on a grid line, so off-grid items (furniture, angled - // walls) would never surface a guide. The matched axis locks exactly to the - // candidate's coordinate; the other axis keeps its grid/ortho snap. Alt - // bypasses. - const alignPoint = ( - fallback: [number, number], - raw: [number, number], - bypass: boolean, - ): [number, number] => { - if (bypass || alignmentCandidates.length === 0) { - useAlignmentGuides.getState().clear() - return fallback - } - const ar = resolveAlignment({ - moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }], - candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (ar.guides.length === 0) { - useAlignmentGuides.getState().clear() - return fallback - } - useAlignmentGuides.getState().set(ar.guides) - let [x, z] = fallback - for (const guide of ar.guides) { - if (guide.axis === 'x') x = guide.coord - else z = guide.coord - } - return [x, z] - } - const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && gridCursorRef.current)) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] @@ -168,7 +119,12 @@ export const CeilingTool: React.FC = () => { shiftPressed.current || !lastPoint ? gridPosition : calculateSnapPoint(lastPoint, gridPosition) - const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true) + const displayPoint = resolveCeilingPlanPointSnap({ + rawPoint, + fallbackPoint: orthoPoint, + levelId: currentLevelId, + altKey: event.nativeEvent?.altKey === true, + }).point setSnappedCursorPosition(displayPoint) if ( points.length > 0 && @@ -199,7 +155,7 @@ export const CeilingTool: React.FC = () => { const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) setPoints([]) - useAlignmentGuides.getState().clear() + clearCeilingSnapFeedback() } else { // Every non-closing vertex is a "start" tick; the closing click above // fires the structure-build (end) cue. @@ -214,14 +170,14 @@ export const CeilingTool: React.FC = () => { const ceilingId = commitCeilingDrawing(currentLevelId, points) setSelection({ selectedIds: [ceilingId] }) setPoints([]) - useAlignmentGuides.getState().clear() + clearCeilingSnapFeedback() } } const onCancel = () => { if (points.length > 0) markToolCancelConsumed() setPoints([]) - useAlignmentGuides.getState().clear() + clearCeilingSnapFeedback() } const onKeyDown = (e: KeyboardEvent) => {