ai-chatbot/packages/editor/src/lib/surface-plan-snap.ts
Wassim SAMAD 04f1b0d59e feat(editor): node-declared per-context snapping + contextual HUD + painter scope
Generify the snapping/modifier HUD off the FSM scope and node declarations
instead of wall-creation-shaped, leaking pills.

- Per-context snapping (`snappingModeByContext`, persisted): wall / item /
  polygon mode-sets with exclusive modes (grid | lines | angles | off), each
  doing exactly what its chip says. Context is node-declared via the new
  `NodeDefinition.snapProfile` ('item' | 'structural'); the resolver maps
  (profile × action) → context with no per-kind switch.
- Scope-driven HUD: helper-manager reads the interaction scope; reshaping
  (endpoint/curve/boundary) and item move get their own chip, no select-hint
  leak. Rotate R/T rounds to 45°; Alt = force-place only (hidden for
  structural kinds); Shift = cycle everywhere.
- Slab/ceiling drafting: Shift=cycle, mode-aware grid/angle, Enter finishes
  (minDraftVertices); polygon boundary vertex/edge drag begins a reshaping
  scope. Fix grid/angle being ignored on boundary edit + slab creation:
  make resolveSurfacePlanPointSnap exclusive (alignment gated on magnetic) so
  grid/angles keep the snapped fallback instead of the raw cursor.
- Painter application scope: node-derived (single/object/matching/room) from
  the hovered node, cyclable via Shift, single-source HUD chip.
- Remove the redundant GridSnapControl from view-toggles (grid step lives in
  the contextual HUD now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:23:15 -04:00

250 lines
7.7 KiB
TypeScript

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 { isMagneticSnapActive } 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<Record<string, AnyNode>>
walls?: readonly WallNode[]
candidates?: readonly AlignmentAnchor[]
threshold?: number
altKey?: boolean
shiftKey?: 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<Record<string, AnyNode>>,
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<string, AnyNode>) === 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 {
if (input.shiftKey) {
useWallSnapIndicator.getState().clear()
useAlignmentGuides.getState().clear()
return { point: input.rawPoint, wallSnap: null, guides: [], wallIds: [] }
}
const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? isMagneticSnapActive()
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()
// Alignment is the magnetic ("lines") guide. Modes are exclusive, so it runs
// only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw
// `fallbackPoint` instead of being pulled onto an alignment axis.
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey || !magnetic) {
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: [],
}
}