* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * editor: per-door-type floor-plan symbols Render a distinct, static plan symbol for each door type in the registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`), independent of the door's live open/close animation: - single / hinged: fixed 90° swing with a dashed quarter-circle arc - double / french: two mirrored half-width leaves + dashed arcs - folding / bifold: static zigzag accordion (~80% span) on the wall face - sliding: bypass — two overlapping panels on parallel tracks + arrow - pocket: thin white leaf, ~60% closed, sliding into the solid wall - barn: surface-mounted panel parked over the wall, dashed closed-ghost + slide arrow The swing arc is dashed in screen-pixel units (the renderer uses non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection / slideDirection as appropriate. Also includes pre-existing working-tree changes unrelated to the door symbols: group move/rotate transform and box-select tweaks, and a regenerated ifc-converter next-env.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix recessed ceiling fixtures and draw safety * feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D) Snap the wall draft / endpoint-move point onto existing wall geometry — corners, midpoints, wall–wall intersections, and along-wall edges — and show a beacon at the snap point whose glyph encodes what it caught (square = corner, triangle = midpoint, ✕ = intersection, circle = edge). - Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested). - Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon and a 2D SVG glyph beacon, both indigo to match the alignment guides. - Gated by a new persisted "Magnetic snap" toggle in the Display menu (useEditor); honored by draw + commit + endpoint-move in both views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * editor: garage and open-doorway floor-plan symbols Extend the per-door-type plan symbols in the registry floor-plan builder (packages/nodes/src/door/floorplan.ts): - open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel (mirrors the 3D system, which renders only the cutout for openings) - garage sectional: closed leaf + side tracks into the garage + dashed parked ghost at the inner end - garage roll-up: closed leaf + coil barrel (capsule) with a coil hint - garage tilt-up: closed leaf + dashed parked panel + dashed curved up-and-over swing path - gate the swing arc to actual swing doors (hinged/double/french) so other types fall back to the plain footprint Garage mechanisms sit on the interior (door-local -z) side to match the 3D garage builders, independent of swingDirection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * arch: enforce layer boundaries — registry dispatch, store relocation, shared helper Three architectural fixes to bring the branch into full compliance: 1. **ceiling-system kind check → CeilingCutCapability** Replace `child.type === 'item'` branch in `ceiling-system` with registry dispatch. Add `CeilingCutCapability` type to `packages/core` registry types, implement `buildCeilingHole` on `itemDefinition`, and rewrite `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through `nodeRegistry` — viewer never again inspects a node's kind directly. 2. **useAlignmentGuides + useWallSnapIndicator → packages/editor** These stores are editor-only UI (snap beacons, alignment guides). Move them from `packages/core/src/store/` to `packages/editor/src/store/`, re-export from `packages/editor`, and update all 34 consumer files across `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`. 3. **findLevelAncestorId extracted to core** `item-light-system` had a private `resolveNodeLevelId` that duplicated level-ancestor traversal logic. Extract it as `findLevelAncestorId` in `packages/core` (spatial-grid-sync), export it, and replace the local copy. All four packages typecheck cleanly (zero errors). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: fix lint, untrack .claude/launch.json Run bun check --write to clear 8 Biome errors (formatting + import order + one unused import). Untrack .claude/launch.json and add it plus .claude/settings.local.json to .gitignore so local IDE/agent configs stop landing in commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
8.6 KiB
TypeScript
271 lines
8.6 KiB
TypeScript
// Pure geometry for "magnetic" wall-draft snapping — no store / viewer / React
|
|
// deps, so it's unit-testable in isolation. Coordinates are XZ plan points
|
|
// (building-local meters). `wall-drafting.ts` layers grid/angle snapping and
|
|
// scene access on top of these primitives.
|
|
|
|
import {
|
|
getWallCurveFrameAt,
|
|
getWallCurveLength,
|
|
isCurvedWall,
|
|
type WallNode,
|
|
} from '@pascal-app/core'
|
|
|
|
export type WallPlanPoint = [number, number]
|
|
|
|
/** Which kind of existing-geometry snap produced a drafted point. */
|
|
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
|
|
|
|
export type WallDraftSnapResult = {
|
|
point: WallPlanPoint
|
|
/**
|
|
* Set when `point` locked onto existing wall geometry (a corner, midpoint,
|
|
* crossing, or wall body) rather than a plain grid/angle position. This is
|
|
* the "magnetic" snap the beacon visualises; `null` for grid/angle-only.
|
|
*/
|
|
snap: WallDraftSnapKind | null
|
|
}
|
|
|
|
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
|
// Generous radius for snapping to an *existing* wall's endpoint while
|
|
// drafting. Larger than `WALL_JOIN_SNAP_RADIUS` because endpoint snap
|
|
// is the strongest user intent (closing a polygon, attaching to a
|
|
// corner) and the cursor never lands pixel-perfect on a corner.
|
|
export const WALL_ENDPOINT_SNAP_RADIUS = 0.7
|
|
// Discrete "special point" snaps taken from the raw cursor (like the
|
|
// endpoint snap) but slightly tighter — a corner is the strongest intent,
|
|
// a midpoint / crossing is the next tier down.
|
|
export const WALL_MIDPOINT_SNAP_RADIUS = 0.5
|
|
export const WALL_INTERSECTION_SNAP_RADIUS = 0.5
|
|
|
|
export function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
|
const dx = a[0] - b[0]
|
|
const dz = a[1] - b[1]
|
|
return dx * dx + dz * dz
|
|
}
|
|
|
|
export function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
|
const [x1, z1] = wall.start
|
|
const [x2, z2] = wall.end
|
|
const dx = x2 - x1
|
|
const dz = z2 - z1
|
|
const lengthSquared = dx * dx + dz * dz
|
|
if (lengthSquared < 1e-9) {
|
|
return null
|
|
}
|
|
|
|
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
|
if (t <= 0 || t >= 1) {
|
|
return null
|
|
}
|
|
|
|
return [x1 + dx * t, z1 + dz * t]
|
|
}
|
|
|
|
export function findWallSnapTarget(
|
|
point: WallPlanPoint,
|
|
walls: WallNode[],
|
|
options?: { ignoreWallIds?: string[]; radius?: number },
|
|
): WallPlanPoint | null {
|
|
const ignoreWallIds = new Set(options?.ignoreWallIds ?? [])
|
|
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
|
let bestTarget: WallPlanPoint | null = null
|
|
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
|
|
|
for (const wall of walls) {
|
|
if (ignoreWallIds.has(wall.id)) {
|
|
continue
|
|
}
|
|
|
|
const candidates: Array<WallPlanPoint | null> = [wall.start, wall.end]
|
|
|
|
if (isCurvedWall(wall)) {
|
|
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
|
|
for (let index = 0; index <= sampleCount; index += 1) {
|
|
const frame = getWallCurveFrameAt(wall, index / sampleCount)
|
|
candidates.push([frame.point.x, frame.point.y])
|
|
}
|
|
} else {
|
|
candidates.push(projectPointOntoWall(point, wall))
|
|
}
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue
|
|
}
|
|
|
|
const candidateDistanceSquared = distanceSquared(point, candidate)
|
|
if (
|
|
candidateDistanceSquared > radiusSquared ||
|
|
candidateDistanceSquared >= bestDistanceSquared
|
|
) {
|
|
continue
|
|
}
|
|
|
|
bestTarget = candidate
|
|
bestDistanceSquared = candidateDistanceSquared
|
|
}
|
|
}
|
|
|
|
return bestTarget
|
|
}
|
|
|
|
/**
|
|
* Endpoint-only snap from the *raw* cursor (no grid pre-snap), with a
|
|
* generous radius. Use this before `findWallSnapTarget` so the strong
|
|
* "attach to an existing wall corner" intent isn't accidentally pushed
|
|
* out of range by an interim grid snap that moved the cursor away from
|
|
* the endpoint.
|
|
*/
|
|
export function findWallEndpointFromRaw(
|
|
point: WallPlanPoint,
|
|
walls: WallNode[],
|
|
ignoreWallIds?: string[],
|
|
): WallPlanPoint | null {
|
|
const ignored = new Set(ignoreWallIds ?? [])
|
|
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
|
|
let best: WallPlanPoint | null = null
|
|
let bestDistSq = Number.POSITIVE_INFINITY
|
|
|
|
for (const wall of walls) {
|
|
if (ignored.has(wall.id)) continue
|
|
for (const corner of [wall.start, wall.end] as WallPlanPoint[]) {
|
|
const d = distanceSquared(point, corner)
|
|
if (d <= radiusSquared && d < bestDistSq) {
|
|
best = corner
|
|
bestDistSq = d
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/** Midpoint of a wall — curve midpoint for curved walls, segment midpoint otherwise. */
|
|
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]
|
|
}
|
|
|
|
/** Nearest wall midpoint to the raw cursor, within `WALL_MIDPOINT_SNAP_RADIUS`. */
|
|
export function findWallMidpointFromRaw(
|
|
point: WallPlanPoint,
|
|
walls: WallNode[],
|
|
ignoreWallIds?: string[],
|
|
): WallPlanPoint | null {
|
|
const ignored = new Set(ignoreWallIds ?? [])
|
|
const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
|
|
let best: WallPlanPoint | null = null
|
|
let bestDistSq = Number.POSITIVE_INFINITY
|
|
|
|
for (const wall of walls) {
|
|
if (ignored.has(wall.id)) continue
|
|
const mid = wallMidpoint(wall)
|
|
const d = distanceSquared(point, mid)
|
|
if (d <= radiusSquared && d < bestDistSq) {
|
|
best = mid
|
|
bestDistSq = d
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/** Crossing point of two straight segments, or null if they don't intersect within both. */
|
|
function segmentIntersection(
|
|
a1: WallPlanPoint,
|
|
a2: WallPlanPoint,
|
|
b1: WallPlanPoint,
|
|
b2: WallPlanPoint,
|
|
): WallPlanPoint | null {
|
|
const rx = a2[0] - a1[0]
|
|
const rz = a2[1] - a1[1]
|
|
const sx = b2[0] - b1[0]
|
|
const sz = b2[1] - b1[1]
|
|
const denom = rx * sz - rz * sx
|
|
if (Math.abs(denom) < 1e-9) return null // parallel / collinear
|
|
|
|
const qpx = b1[0] - a1[0]
|
|
const qpz = b1[1] - a1[1]
|
|
const t = (qpx * sz - qpz * sx) / denom
|
|
const u = (qpx * rz - qpz * rx) / denom
|
|
if (t < 0 || t > 1 || u < 0 || u > 1) return null
|
|
|
|
return [a1[0] + t * rx, a1[1] + t * rz]
|
|
}
|
|
|
|
/**
|
|
* Nearest point where two existing straight walls cross, within
|
|
* `WALL_INTERSECTION_SNAP_RADIUS`. Curved walls are skipped. O(n²) over the
|
|
* level's walls — fine at editor scale.
|
|
*/
|
|
export function findWallIntersectionFromRaw(
|
|
point: WallPlanPoint,
|
|
walls: WallNode[],
|
|
ignoreWallIds?: string[],
|
|
): 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
|
|
let best: WallPlanPoint | null = null
|
|
let bestDistSq = Number.POSITIVE_INFINITY
|
|
|
|
for (let i = 0; i < straight.length; i += 1) {
|
|
for (let j = i + 1; j < straight.length; j += 1) {
|
|
const crossing = segmentIntersection(
|
|
straight[i]!.start,
|
|
straight[i]!.end,
|
|
straight[j]!.start,
|
|
straight[j]!.end,
|
|
)
|
|
if (!crossing) continue
|
|
const d = distanceSquared(point, crossing)
|
|
if (d <= radiusSquared && d < bestDistSq) {
|
|
best = crossing
|
|
bestDistSq = d
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/** Pick the candidate nearest to `point`, ignoring nulls. */
|
|
function nearestCandidate(
|
|
point: WallPlanPoint,
|
|
candidates: Array<WallDraftSnapResult | null | false>,
|
|
): WallDraftSnapResult | null {
|
|
let best: WallDraftSnapResult | null = null
|
|
let bestDistSq = Number.POSITIVE_INFINITY
|
|
for (const candidate of candidates) {
|
|
if (!candidate) continue
|
|
const d = distanceSquared(point, candidate.point)
|
|
if (d < bestDistSq) {
|
|
best = candidate
|
|
bestDistSq = d
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
/**
|
|
* Discrete "special point" snap from the raw cursor, in priority order:
|
|
* 1. corners (endpoints) — strongest intent, largest radius
|
|
* 2. midpoints / crossings — next tier; the nearer of the two wins
|
|
* A corner within range always wins over a midpoint/crossing. Returns null
|
|
* when no special point is in range (caller falls back to grid/edge snap).
|
|
*/
|
|
export function findWallSpecialPointSnap(
|
|
point: WallPlanPoint,
|
|
walls: WallNode[],
|
|
ignoreWallIds?: string[],
|
|
): WallDraftSnapResult | null {
|
|
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
|
|
if (endpoint) return { point: endpoint, snap: 'endpoint' }
|
|
|
|
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
|
|
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
|
|
return nearestCandidate(point, [
|
|
midpoint && { point: midpoint, snap: 'midpoint' },
|
|
intersection && { point: intersection, snap: 'intersection' },
|
|
])
|
|
}
|