Merge origin/main (#407 placement restructure) into opening-proximity-guides

#407 ("Always-visible placement ghosts + true-nearest 2D opening snap")
restructured the door/window placement tools: it split the old
create-in-resolve into a pure resolveWallPlacement() + side-effecting
applyWallTarget(), added an off-host floating ghost (fallbackPose / showGhostAt),
unified wall hover into onWallHover, and extracted commit{Door,Window}AtWall.

Conflict resolution (door/tool.tsx, window/tool.tsx):
- Re-homed the single publishOpeningGuidesForWallEvent() call into applyWallTarget
  (after the draft update + updateCursor), using that scope (wall,
  getSlabElevationForWall(wall)); door includeVertical:false, window true.
- Routed clearOpeningGuides3D() through showGhostAt so every off-host fallback
  path clears; kept clears in hideCursor, commit helpers, onRoofHover, teardown.
- Made the window sill snap (resolvePlacementY) event-free and call it from the
  pure resolveWallPlacement, so hover + click both get sill/centre/top snapping;
  Shift bypasses, the moving draft is excluded via ignoreId.
- Dropped the branch's inline onWallClick in favour of #407's onWallClick +
  commitWindowAtWall (no behavior lost).
- Reconstructed both files' import blocks, which the auto-merge had truncated to
  stubs (only tsc caught it).

All other conflicts auto-merged (registry types, floorplan-registry-layer,
both move-tools). Verified: typecheck 9/9, biome clean, nodes 169 + core 594
tests pass, editor `bun run build` 7/7. Merge resolution reviewed by Codex
(adversarial): no semantic regressions; all #407 behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-15 12:58:00 -04:00
co-authored by Claude Opus 4.8
59 changed files with 2212 additions and 807 deletions
@@ -0,0 +1,75 @@
'use client'
import type { Material, Mesh, Object3D, Raycaster } from 'three'
export const INVALID_GHOST_COLOR = 0xef_44_44
const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {}
/**
* Apply ghost material treatment to a preview mesh tree.
*
* Traverses the object tree, disables raycasting on all descendants (prevents
* cursor-ray starvation), and clones visible mesh materials to set translucency.
*
* When `invalid` is true, sets color/emissive to INVALID_GHOST_COLOR and opacity ~0.4.
* Otherwise sets opacity ~0.5 while preserving the original color.
*
* Skips: meshes whose material.visible === false (door/window root hitbox) and
* children named 'cutout'.
*
* Returns cleanup that disposes only the cloned materials (never originals or geometry).
*
* @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh)
* @param opts - { invalid?: boolean } whether to tint red for invalid placement
* @returns Cleanup function that disposes the cloned materials
*/
export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () => void {
const invalid = opts?.invalid ?? false
const cloned: Material[] = []
root.traverse((obj) => {
// Disable raycast on every descendant to prevent cursor-ray starvation.
obj.raycast = NO_RAYCAST
const mesh = obj as Mesh
if (!mesh.isMesh) return
if (mesh.name === 'cutout') return
const original = mesh.material
const wasArray = Array.isArray(original)
const cloneOne = (mat: Material): Material | null => {
// Skip invisible materials (door/window root hitbox).
if ((mat as { visible?: boolean }).visible === false) return null
const clone = mat.clone()
clone.transparent = true
clone.depthWrite = false
if (invalid) {
;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(INVALID_GHOST_COLOR)
;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex(
INVALID_GHOST_COLOR,
)
clone.opacity = 0.4
} else {
clone.opacity = 0.5
}
cloned.push(clone)
return clone
}
if (wasArray) {
const clonedMats = original.map(cloneOne).filter((m): m is Material => m !== null)
if (clonedMats.length > 0) mesh.material = clonedMats
} else {
const clone = cloneOne(original)
if (clone) mesh.material = clone
}
})
return () => {
for (const mat of cloned) {
mat.dispose()
}
}
}
@@ -9,7 +9,7 @@ import {
sceneRegistry,
} from '@pascal-app/core'
import { DragBoundingBox } from '@pascal-app/editor'
import { useEffect, useRef, useState } from 'react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { Vector3 } from 'three'
const INVALID_PREVIEW_COLOR = 0xef_44_44
@@ -17,6 +17,7 @@ type ValidTarget = 'roof' | 'gutter'
export function RoofAttachmentFallbackPreview({
activeBuildingId,
ghost,
isValidRoofTarget,
lift = 0,
onInvalidTarget,
@@ -24,10 +25,11 @@ export function RoofAttachmentFallbackPreview({
validTarget = 'roof',
}: {
activeBuildingId: string | null | undefined
ghost?: ReactNode
isValidRoofTarget?: (event: RoofEvent) => boolean
lift?: number
onInvalidTarget?: () => void
size: [number, number, number]
size?: [number, number, number]
validTarget?: ValidTarget
}) {
const [position, setPosition] = useState<[number, number, number] | null>(null)
@@ -100,6 +102,13 @@ export function RoofAttachmentFallbackPreview({
if (!(activeBuildingId && position)) return null
// When ghost is provided, render the ghost instead of DragBoundingBox
if (ghost) {
return <group position={position}>{ghost}</group>
}
// Fallback to DragBoundingBox for callers not yet migrated
if (!size) return null
return (
<DragBoundingBox
color={INVALID_PREVIEW_COLOR}
@@ -84,6 +84,32 @@ export function getRoofHostedOpeningLevelId(
return (roof.parentId as AnyNodeId | null) ?? null
}
/**
* The level that owns the wall-snap candidates for an opening (door /
* window), across all three parentings the 2D move can start from:
* - roof-hosted: opening → segment → roof → level (`getRoofHostedOpeningLevelId`).
* - wall-hosted (existing opening): parent is a wall → its parent is the level.
* - fresh placement (preset/catalog): the clone is parented straight to the
* LEVEL (`place-preset` sets `parentId: levelId`), so the parent IS the level.
*
* The fresh-placement case is the subtle one: treating the parent as always a
* wall (`parent.parentId`) resolves a fresh opening's level to the BUILDING,
* and `collectLevelWallSegments(building)` finds no walls — so a new door /
* window never snapped in 2D. Returns null when the parent chain is none of
* the above.
*/
export function getOpeningHostLevelId(
node: { parentId: string | null },
nodes: Record<string, AnyNode | undefined>,
): AnyNodeId | null {
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
if (roofLevelId) return roofLevelId
const parent = node.parentId ? nodes[node.parentId] : undefined
if (!parent) return null
if (parent.type === 'level') return parent.id as AnyNodeId
return (parent.parentId as AnyNodeId | null) ?? null
}
/**
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
* through the face frame, then composed through the segment's and roof's
+38 -76
View File
@@ -1,9 +1,11 @@
import {
type AnyNode,
type AnyNodeId,
collectLevelWallSegments,
getScaledDimensions,
type ItemNode,
isCurvedWall,
nearestWallSegment,
WALL_SNAP_DISTANCE_M,
type WallNode,
} from '@pascal-app/core'
@@ -22,8 +24,6 @@ import {
* 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]). */
@@ -61,10 +61,14 @@ export function projectWallLocalPointToPlan(
}
/**
* 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).
* Return the single closest wall under `parentLevelId` to `planPoint` — the
* wall whose segment-Voronoi cell the point lies in — or `null` if nothing is
* within `WALL_SNAP_DISTANCE_M`. `excludeWallId` skips a specific wall.
*
* The nearest-segment scan + curved-wall filter live in core
* (`collectLevelWallSegments` / `nearestWallSegment`) so the editor's 2D
* Voronoi debug overlay classifies points with the exact same math — the
* overlay is then a faithful picture of where this snaps.
*/
export function findClosestWallInPlan(
planPoint: readonly [number, number],
@@ -72,78 +76,36 @@ export function findClosestWallInPlan(
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
const segments = collectLevelWallSegments(nodes, parentLevelId)
const closest = nearestWallSegment(
segments,
planPoint[0],
planPoint[1],
WALL_SNAP_DISTANCE_M,
excludeWallId,
)
if (!closest) return null
let best: WallHit | null = null
const { segment, along, perp } = closest
// 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;
// `perp >= 0` is consistently the front side (see `closestOnSegment`).
const side: 'front' | 'back' = perp >= 0 ? 'front' : 'back'
// Wall-local rotation matching 3D `calculateItemRotation`: 0 front, π back.
// The node is parented to the wall, so this composes with the wall's own
// rotation at render — never a world-space rotation here.
const itemRotation = side === 'front' ? 0 : Math.PI
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 {
wall: segment.wall,
localX: along,
perpDistance: perp,
side,
dirX: segment.dirX,
dirY: segment.dirY,
wallLength: segment.length,
itemRotation,
}
return best
}
/** Figma-style along-wall alignment threshold (meters) — parity with the