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 SURFACE_SNAP_MOVING_ID = '__surface_snap__' export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08 const SURFACE_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 SurfacePlanSnapInput = { 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 highlightWalls?: boolean step?: number snapRadii?: WallSnapRadii } export type SurfacePlanSnapResult = { 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 clearSurfacePlanSnapFeedback() { useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() } export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult { 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 ?? SURFACE_WALL_SNAP_RADII, gridSnap: fallbackPoint ? () => fallbackPoint : undefined, }) if (wallSnap.snap) { const wallIds = input.highlightWalls === false ? [] : findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls) useWallSnapIndicator.getState().set({ x: wallSnap.point[0], z: wallSnap.point[1], kind: wallSnap.snap, ...(wallIds.length > 0 ? { 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 ?? SURFACE_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 ?? SURFACE_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: [], } }