228 lines
8.4 KiB
TypeScript
228 lines
8.4 KiB
TypeScript
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
getScaledDimensions,
|
|
type ItemNode,
|
|
isCurvedWall,
|
|
type WallNode,
|
|
} from '@pascal-app/core'
|
|
|
|
/**
|
|
* Shared helpers for the kinds whose 2D move snaps onto a wall in plan
|
|
* space (door, window, item with `attachTo === 'wall' | 'wall-side'`).
|
|
*
|
|
* The 3D move tools listen to R3F `WallEvent`s (mesh-hit with normal)
|
|
* for wall snapping. The 2D path doesn't have that — pointer events
|
|
* land on the SVG layer, not on the wall meshes. This helper does the
|
|
* equivalent plan-space projection: for each wall on the level, find
|
|
* the perpendicular projection of the pointer onto the wall line and
|
|
* pick the closest one within a reasonable range.
|
|
*
|
|
* Curved walls are excluded — the legacy door / window placement also
|
|
* rejects curved walls (mitering + arc + opening would tear in 3D).
|
|
*/
|
|
|
|
const WALL_SNAP_DISTANCE_M = 1.5
|
|
|
|
export type WallHit = {
|
|
wall: WallNode
|
|
/** Distance along the wall from `start` (clamped to [0, length]). */
|
|
localX: number
|
|
/** Signed perpendicular distance from the wall axis (+ on the "front" side). */
|
|
perpDistance: number
|
|
/** Which face of the wall the pointer was on. */
|
|
side: 'front' | 'back'
|
|
/** Wall direction unit vector, x. */
|
|
dirX: number
|
|
/** Wall direction unit vector, y (== z in plan). */
|
|
dirY: number
|
|
/** Wall length in metres. */
|
|
wallLength: number
|
|
/**
|
|
* Rotation around Y in **wall-local** space — 0 for the front face,
|
|
* π for the back. Matches the 3D `calculateItemRotation(normal)`
|
|
* convention (normal +Z → 0, normal -Z → π). Items / doors / windows
|
|
* are children of the wall mesh, so their `rotation.y` is in the
|
|
* wall's local frame; writing a world-space rotation here would mis-
|
|
* orient the node by `wallRotation` (off by 90° on vertical walls).
|
|
*/
|
|
itemRotation: number
|
|
}
|
|
|
|
export function projectWallLocalPointToPlan(
|
|
wall: WallNode,
|
|
localX: number,
|
|
localZ = 0,
|
|
): [number, number] {
|
|
const angle = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
|
const c = Math.cos(angle)
|
|
const s = Math.sin(angle)
|
|
return [wall.start[0] + localX * c + localZ * s, wall.start[1] - localX * s + localZ * c]
|
|
}
|
|
|
|
/**
|
|
* Walk every wall under `parentLevelId` and return the closest one to
|
|
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
|
|
* `excludeWallId` skips a specific wall (e.g. the current parent during
|
|
* a re-parent flow if you want a "must change" guard).
|
|
*/
|
|
export function findClosestWallInPlan(
|
|
planPoint: readonly [number, number],
|
|
nodes: Record<AnyNodeId, AnyNode>,
|
|
parentLevelId: AnyNodeId | null,
|
|
excludeWallId?: AnyNodeId,
|
|
): WallHit | null {
|
|
if (!parentLevelId) return null
|
|
const level = nodes[parentLevelId]
|
|
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
|
|
if (!Array.isArray(childIds)) return null
|
|
|
|
let best: WallHit | null = null
|
|
|
|
for (const childId of childIds) {
|
|
const node = nodes[childId]
|
|
if (!node || node.type !== 'wall') continue
|
|
if (childId === excludeWallId) continue
|
|
const wall = node as WallNode
|
|
if (isCurvedWall(wall)) continue
|
|
|
|
const sx = wall.start[0]
|
|
const sy = wall.start[1]
|
|
const dx = wall.end[0] - sx
|
|
const dy = wall.end[1] - sy
|
|
const wallLength = Math.hypot(dx, dy)
|
|
if (wallLength < 1e-6) continue
|
|
|
|
const dirX = dx / wallLength
|
|
const dirY = dy / wallLength
|
|
|
|
// Project pointer onto wall axis.
|
|
const px = planPoint[0] - sx
|
|
const py = planPoint[1] - sy
|
|
const along = px * dirX + py * dirY
|
|
const perpRaw = px * -dirY + py * dirX // signed perpendicular distance
|
|
const clampedAlong = Math.max(0, Math.min(wallLength, along))
|
|
|
|
// Distance from the pointer to the wall segment (not just the line).
|
|
const closestPointX = sx + dirX * clampedAlong
|
|
const closestPointY = sy + dirY * clampedAlong
|
|
const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY)
|
|
if (distance > WALL_SNAP_DISTANCE_M) continue
|
|
if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue
|
|
|
|
// Side determination, calibrated to the 3D wall convention. In
|
|
// wall-local space the wall extends along +X and its +Z axis is the
|
|
// front-face normal. After `mesh.rotation.y = -wallAngle`:
|
|
// - For a wall going `+X` in plan (wallAngle=0): wall-local +Z
|
|
// maps to world +Z = plan +Y, so the front face is on plan +Y.
|
|
// `perpRaw = py` is positive → front.
|
|
// - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z
|
|
// maps to world -X = plan -X, so the front face is on plan -X.
|
|
// `perpRaw = -px` is positive there → front.
|
|
// So `perpRaw >= 0` is consistently the front side. The earlier
|
|
// labelling had this flipped, which produced rotations that were
|
|
// off by 90° on non-horizontal walls.
|
|
const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back'
|
|
|
|
// Rotation in wall-local space — matches 3D `calculateItemRotation`:
|
|
// 0 when the item faces the front normal (+Z), π for the back. The
|
|
// node is parented to the wall, so this composes with the wall's
|
|
// own rotation when rendered. Don't return a world-space rotation
|
|
// here — the consumer writes this straight into `node.rotation[1]`.
|
|
const itemRotation = side === 'front' ? 0 : Math.PI
|
|
|
|
best = {
|
|
wall,
|
|
localX: clampedAlong,
|
|
perpDistance: perpRaw,
|
|
side,
|
|
dirX,
|
|
dirY,
|
|
wallLength,
|
|
itemRotation,
|
|
}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
/** Figma-style along-wall alignment threshold (meters) — parity with the
|
|
* XZ placement / move threshold. */
|
|
const ALONG_WALL_ALIGN_THRESHOLD_M = 0.08
|
|
|
|
/** The along-wall span of a wall-hosted node (door / window / wall item):
|
|
* its centre `localX` and half-width. `null` for kinds with no along-wall
|
|
* footprint. */
|
|
function wallAttachmentSpan(node: AnyNode): { center: number; half: number } | null {
|
|
if (node.type === 'door' || node.type === 'window') {
|
|
const n = node as { position: [number, number, number]; width: number }
|
|
return { center: n.position[0], half: n.width / 2 }
|
|
}
|
|
if (node.type === 'item') {
|
|
const item = node as ItemNode
|
|
const attachTo = item.asset.attachTo
|
|
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
|
|
const [w] = getScaledDimensions(item)
|
|
return { center: item.position[0], half: w / 2 }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Figma-style alignment for a wall-hosted opening / item, along the wall
|
|
* axis. Snaps the moving node's edges (or centre) to other attachments'
|
|
* edges/centres on the same wall, plus the wall ends. Edge-to-edge first,
|
|
* so two doors line up flush.
|
|
*
|
|
* Returns the adjusted `localX` when a neighbour stop is within threshold,
|
|
* or `null` when nothing aligns — callers treat `null` as "no alignment,
|
|
* fall back to the grid snap". This lets along-wall alignment COMPETE with
|
|
* the 0.5m grid (openings have arbitrary widths rarely on the grid, so
|
|
* layering on top of the grid snap would almost never trigger).
|
|
*
|
|
* Snap-only for v1 — no guide is published (the floor-plan guide layer
|
|
* renders XZ guides; an along-wall guide on a diagonal wall needs extra
|
|
* projection work, deferred).
|
|
*/
|
|
export function snapLocalXToNeighbors(args: {
|
|
wall: WallNode
|
|
localX: number
|
|
width: number
|
|
selfId: AnyNodeId
|
|
nodes: Record<AnyNodeId, AnyNode>
|
|
threshold?: number
|
|
}): number | null {
|
|
const { wall, localX, width, selfId, nodes, threshold = ALONG_WALL_ALIGN_THRESHOLD_M } = args
|
|
const half = width / 2
|
|
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
|
|
|
// Candidate stops along the wall: both ends + every other attachment's
|
|
// edges and centre.
|
|
const candidateStops: number[] = [0, wallLength]
|
|
for (const node of Object.values(nodes)) {
|
|
if (!node || node.id === selfId) continue
|
|
if ((node as { parentId?: string }).parentId !== wall.id) continue
|
|
const span = wallAttachmentSpan(node)
|
|
if (!span) continue
|
|
candidateStops.push(span.center - span.half, span.center, span.center + span.half)
|
|
}
|
|
|
|
// Moving stops: our two edges (edge-to-edge alignment) + centre.
|
|
const movingStops = [localX - half, localX, localX + half]
|
|
|
|
let bestDelta: number | null = null
|
|
let bestAbs = threshold
|
|
for (const ms of movingStops) {
|
|
for (const cs of candidateStops) {
|
|
const d = cs - ms
|
|
const ad = Math.abs(d)
|
|
if (ad <= bestAbs && (bestDelta === null || ad < bestAbs)) {
|
|
bestAbs = ad
|
|
bestDelta = d
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestDelta === null ? null : localX + bestDelta
|
|
}
|