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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user