Merge remote-tracking branch 'origin/main' into feat/paint-slots

# Conflicts:
#	packages/core/src/store/use-scene.ts
#	packages/editor/src/components/editor/index.tsx
This commit is contained in:
Wassim SAMAD
2026-06-18 12:22:26 -04:00
415 changed files with 22805 additions and 1414 deletions
+26 -1
View File
@@ -6,6 +6,7 @@ import type {
WallNode,
WindowNode as WindowNodeType,
} from '@pascal-app/core'
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { buildWindowFloorplan } from './floorplan'
@@ -20,6 +21,9 @@ const SIDE_HANDLE_OFFSET = 0.24
const HEIGHT_HANDLE_OFFSET = 0.24
const MIN_WINDOW_HEIGHT = 0.3
const MIN_WINDOW_WIDTH = 0.3
// How far the move cross floats off the wall face (+Z, the window's facing
// normal) so it's grabbable instead of buried in the sash/frame.
const MOVE_HANDLE_LIFT = 0.12
function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
if (!w.wallId) return Number.POSITIVE_INFINITY
@@ -49,6 +53,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeT
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
onDrag: (node) => publishOpeningResizeGuides(node, true),
apply: (initial, newWidth) => {
const rotY = initial.rotation[1]
const armX = Math.cos(rotY)
@@ -96,6 +101,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
: Math.max(MIN_WINDOW_HEIGHT, anchored)
},
currentValue: (n) => n.height,
onDrag: (node) => publishOpeningResizeGuides(node, true),
apply: (initial, newHeight) => {
// Anchored edge stays in wall-local Y; opposite edge moves.
const anchorY =
@@ -115,7 +121,26 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
}
}
// Press-drag move grip at the window centre, standing in the wall face. Routes
// through the same move tool as the floating Move button (3D
// `affordanceTools.move`, 2D `floorplanMoveTarget`) — slide within the wall
// plane + re-host onto another wall — committing on release, no second click.
function windowMoveHandle(): HandleDescriptor<WindowNodeType> {
return {
kind: 'tap-action',
shape: 'move-cross',
plane: 'node-normal',
portal: 'grandparent',
cursor: 'move',
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
placement: {
position: () => [0, 0, MOVE_HANDLE_LIFT],
},
}
}
const windowHandles: HandleDescriptor<WindowNodeType>[] = [
windowMoveHandle(),
windowWidthHandle('left'),
windowWidthHandle('right'),
windowHeightHandle('top'),
@@ -210,7 +235,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
presentation: {
label: 'Window',
description: 'A window cut into a wall. Animated open/close for opening windows.',
icon: { kind: 'url', src: '/icons/window.png' },
icon: { kind: 'url', src: '/icons/window.webp' },
paletteSection: 'structure',
paletteOrder: 60,
},
@@ -0,0 +1,111 @@
'use client'
import { EDITOR_LAYER } from '@pascal-app/editor'
import { useEffect, useMemo } from 'react'
import { BufferGeometry, Float32BufferAttribute, LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
/**
* Floor "shadow" projection for a window during placement / move.
*
* Windows sit elevated above the floor, so over open ground (and on a wall)
* it's hard to read where the window actually is in plan. This draws its
* projection on the floor: a small footprint segment directly below the window
* (its plan extent along the wall) plus a DASHED vertical line dropping from the
* window centre to that footprint — like a shadow tether. Placement aid only;
* never shown on a committed window.
*
* Rendered in WORLD space (the tool positions the ghost in world space too), so
* it's mounted as a sibling of the ghost — NOT inside the ghost's rotated/offset
* group. `centerY` is the window centre's world Y; `floorY` is the level floor.
*/
const FOOTPRINT_COLOR = 0x38_bd_f8
const DROP_COLOR = 0x38_bd_f8
const footprintMaterial = new LineBasicNodeMaterial({
color: FOOTPRINT_COLOR,
transparent: true,
opacity: 0.85,
depthWrite: false,
})
const dropMaterial = new LineBasicNodeMaterial({
color: DROP_COLOR,
transparent: true,
opacity: 0.6,
depthWrite: false,
})
// Dash geometry for the vertical drop: alternating on/off segments so it reads
// as a dashed tether without a dashed-line material (unavailable in three/webgpu).
const DASH = 0.12
const GAP = 0.08
export function WindowFloorProjection({
centerX,
centerZ,
centerY,
floorY,
width,
rotationY,
}: {
centerX: number
centerZ: number
centerY: number
floorY: number
width: number
rotationY: number
}) {
// Footprint: a short segment of length `width` along the window's wall axis,
// centred under the window on the floor. The window faces `rotationY` about Y
// (its width runs along the wall), so the along-wall direction is
// (cos, -sin) in XZ.
const footprint = useMemo(() => {
const half = width / 2
const dirX = Math.cos(rotationY)
const dirZ = -Math.sin(rotationY)
const position = new Float32BufferAttribute(new Float32Array(6), 3)
const geometry = new BufferGeometry()
geometry.setAttribute('position', position)
const line = new LineSegments(geometry, footprintMaterial)
line.frustumCulled = false
line.layers.set(EDITOR_LAYER)
line.renderOrder = 1000
line.raycast = () => {}
position.setXYZ(0, centerX - dirX * half, floorY + 0.002, centerZ - dirZ * half)
position.setXYZ(1, centerX + dirX * half, floorY + 0.002, centerZ + dirZ * half)
position.needsUpdate = true
return line
}, [centerX, centerZ, floorY, width, rotationY])
// Dashed vertical tether from the window centre down to the footprint.
const drop = useMemo(() => {
const span = Math.max(centerY - floorY, 0)
const segs: number[] = []
let y = floorY
while (y < floorY + span) {
const top = Math.min(y + DASH, floorY + span)
segs.push(centerX, y, centerZ, centerX, top, centerZ)
y += DASH + GAP
}
const position = new Float32BufferAttribute(new Float32Array(segs), 3)
const geometry = new BufferGeometry()
geometry.setAttribute('position', position)
const line = new LineSegments(geometry, dropMaterial)
line.frustumCulled = false
line.layers.set(EDITOR_LAYER)
line.renderOrder = 1000
line.raycast = () => {}
return line
}, [centerX, centerZ, centerY, floorY])
useEffect(() => () => footprint.geometry.dispose(), [footprint])
useEffect(() => () => drop.geometry.dispose(), [drop])
return (
<>
<primitive object={footprint} />
<primitive object={drop} />
</>
)
}
+129 -22
View File
@@ -2,22 +2,22 @@ import {
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
useLiveTransforms,
useScene,
type WallNode,
WallNode as WallNodeSchema,
type WindowNode,
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
getRoofHostedOpeningLevelId,
getRoofHostedOpeningPlanPoint,
} from '../shared/roof-opening-host'
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
resolveOpeningPlacement,
snapLocalXToNeighbors,
} from '../shared/wall-attach-target'
import { clampToWall, hasWallChildOverlap } from './window-math'
import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './window-math'
/**
* 2D floor-plan move handler for window. Same shape as door (see
@@ -32,15 +32,9 @@ import { clampToWall, hasWallChildOverlap } from './window-math'
*/
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
const startLevelId = (() => {
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
// segment → roof → level.
const nodes = useScene.getState().nodes
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
if (roofLevelId) return roofLevelId
const wall = nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
// The level that owns the wall-snap candidates — resolves the wall-hosted,
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
const originalWall = node.parentId
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
: undefined
@@ -50,12 +44,21 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
? projectWallLocalPointToPlan(originalWall, node.position[0])
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
metadata: node.metadata,
// Absolute: query the wall snap with the TRUE cursor (see the matching
// comment in `doorFloorplanMoveTarget`). Relative mode anchored the search
// to the original wall, which let the window snap to a farther wall across
// a thin gap instead of the one under the cursor.
mode: 'absolute',
})
// Preserve the source window's local Y — 2D move doesn't have a way
// to express vertical motion, so we keep whatever vertical position
// the window had when the move started.
const startLocalY = node.position[1]
// the window had when the move started. A fresh preset/catalog clone is
// created at y=0, which would sit the window's centre on the floor (half
// below ground); default those to a realistic sill so it floats above
// the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`).
const startLocalY =
node.position[1] > 0.1 ? node.position[1] : DEFAULT_WINDOW_SILL_M + node.height / 2
// Track the last successful placement so `commit()` can write it
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
@@ -69,13 +72,100 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
roofFace: undefined
} | null = null
// R flips the window's facing (front ↔ back) mid-placement — see
// `doorFloorplanMoveTarget`. `apply` re-derives the side each move, so the
// flip is a persistent XOR plus a π rotation offset.
let flipped = false
let lastApply: {
planPoint: readonly [number, number]
modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean }
} | null = null
// See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor
// as a ghost and isn't committable (it needs a wall). Starts true.
let onWall = true
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
// overlapping placement, matching the 3D move.
let forcePlace = false
// Move SFX — parity with the 3D `MoveWindowTool` (see `doorFloorplanMoveTarget`):
// ONE soft `sfx:grid-snap` click per grid step, identical free-following or on a
// wall, keyed on the RAW cursor. No separate floor→wall cue (that was the
// "double"). 2D `apply` runs once per pointermove, so the step-key dedup suffices.
const STEP_M = 0.1
let lastStepKey: string | null = null
const tickGridStep = (...coords: number[]) => {
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key !== lastStepKey) {
lastStepKey = key
triggerSFX('sfx:grid-snap')
}
}
const freeFollow = (planPoint: readonly [number, number]) => {
onWall = false
lastValid = null
if ((useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible) {
useScene.getState().updateNode(node.id as AnyNodeId, { visible: false })
}
const half = node.width / 2 + 0.5
const wall = WallNodeSchema.parse({
start: [planPoint[0] - half, planPoint[1]],
end: [planPoint[0] + half, planPoint[1]],
thickness: 0.1,
})
// Reflect the R-flip on the floating ghost so it faces the side that will
// be committed (see `doorFloorplanMoveTarget.freeFollow`).
const ghostSide: WindowNode['side'] = flipped
? node.side === 'front'
? 'back'
: 'front'
: node.side
const ghost = {
...node,
side: ghostSide,
parentId: wall.id,
wallId: wall.id,
roofSegmentId: undefined,
roofFace: undefined,
position: [half, startLocalY, 0] as [number, number, number],
rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number],
visible: true,
} as WindowNode
usePlacementPreview.getState().set(ghost, wall)
}
const session: FloorplanMoveTargetSession = {
affectedIds: [node.id as AnyNodeId],
flipSide() {
flipped = !flipped
if (lastApply) this.apply(lastApply)
},
apply({ planPoint, modifiers }) {
lastApply = { planPoint, modifiers }
forcePlace = modifiers.shiftKey === true
// Drop any stale live transform left by the 3D `MoveWindowTool` — see
// `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer
// keeps rendering the window at the 3D tool's last hover (it prefers
// `useLiveTransforms` over the scene node for door/window), so the 2D
// slide — which writes the scene node — wouldn't show. Guarded on
// existence: `clear` allocates a new Map + re-renders.
if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) {
useLiveTransforms.getState().clear(node.id as AnyNodeId)
}
const nodes = useScene.getState().nodes
const resolvedPlanPoint = resolveCursor(planPoint)
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
if (!hit) return
if (!hit) {
// Off any wall — free-follow. Click per grid cell over open floor.
tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1])
freeFollow(resolvedPlanPoint)
return
}
onWall = true
usePlacementPreview.getState().clear()
if ((nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible === false) {
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
}
// Figma-style along-wall alignment first (edge-to-edge with other
// openings / wall ends), winning over the 0.5m grid snap; falls back
@@ -99,10 +189,22 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
node.height,
)
// One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`)
// so the wall slide ticks at the same cadence as the off-wall ghost — same
// SFX, no separate snap cue.
tickGridStep(hit.localX)
const side: WindowNode['side'] = flipped
? hit.side === 'front'
? 'back'
: 'front'
: hit.side
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
lastValid = {
position: [clampedX, clampedY, 0],
rotation: [0, hit.itemRotation, 0],
side: hit.side,
rotation: [0, itemRotation, 0],
side,
parentId: hit.wall.id,
wallId: hit.wall.id,
// Re-anchoring to a wall ends any roof-segment hosting; the
@@ -119,9 +221,14 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
])
},
canCommit() {
// Off-wall the window is free-following — not placeable; the overlay
// reverts to the pre-move snapshot. Matches the 3D move.
if (!onWall) return false
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
if (!live || live.type !== 'window') return false
const overlapping = hasWallChildOverlap(
// Block on overlap UNLESS Shift force-places — same `placeable` rule as
// the 3D move + the shared `resolveOpeningPlacement`.
const collides = hasWallChildOverlap(
live.parentId as string,
live.position[0],
live.position[1],
@@ -129,7 +236,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
live.height,
live.id,
)
return !overlapping
return resolveOpeningPlacement({ collides, forcePlace }).placeable
},
commit() {
// Own the atomic write so the overlay takes the deterministic
+375 -60
View File
@@ -2,6 +2,7 @@ import {
type AnyNodeId,
collectAlignmentAnchors,
emitter,
type GridEvent,
isCurvedWall,
type RoofEvent,
type RoofNode,
@@ -13,7 +14,6 @@ import {
WindowNode,
} from '@pascal-app/core'
import {
calculateCursorRotation,
calculateItemRotation,
consumePlacementDragRelease,
EDITOR_LAYER,
@@ -26,16 +26,29 @@ import {
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
clearOpeningGuides3D,
publishOpeningGuidesForWallEvent,
resolveSillSnap,
} from '../shared/opening-guides-runtime'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
import { WindowFloorProjection } from './floor-projection'
import WindowPreview from './preview'
import {
clampToWall,
DEFAULT_WINDOW_SILL_M,
hasWallChildOverlap,
wallLocalToWorld,
} from './window-math'
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef_44_44,
@@ -59,6 +72,38 @@ const edgeMaterial = new LineBasicNodeMaterial({
const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
const cursorGroupRef = useRef<Group>(null!)
// The window preview ghost. Shown for the WHOLE move so the user always sees
// a translucent window tinted by placement state — red off-wall or colliding,
// green on a valid wall. The real node stays hidden until commit (the wall
// still cuts its hole from the node data). `null` = not previewing. See the
// matching `WindowPreview` tint and `MoveDoorTool` for the full rationale.
const [ghostPose, setGhostPose] = useState<{
position: [number, number, number]
rotationY: number
tint: 'valid' | 'invalid'
// Level floor world-Y, for the floor "shadow" projection (drop-line + footprint).
floorY: number
// Live facing side — R-flip changes it and the window geometry depends on it,
// so the ghost must rebuild with the live side (see `MoveDoorTool`).
side: WindowNode['side']
} | null>(null)
// Ghost preview node: the moving window with a zeroed transform + the live
// facing side (the ghost is positioned by the `<group position>` wrapper;
// `updateWindowMesh` bakes the node's own position/rotation in, so passing the
// live node would double-offset). Rebuilds on an R-flip so the preview matches
// what commit will place.
const ghostSide = ghostPose?.side ?? movingWindowNode.side
const ghostNode = useMemo(
() => ({
...movingWindowNode,
side: ghostSide,
position: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}),
[movingWindowNode, ghostSide],
)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
@@ -85,6 +130,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: movingWindowNode.roofSegmentId,
roofFace: movingWindowNode.roofFace,
metadata: movingWindowNode.metadata,
// Free-follow hides the node (visible:false); revert paths restore this.
visible: movingWindowNode.visible,
}
// In move mode (existing window) mark it transient so its mesh skips the live wall CSG
@@ -101,6 +148,38 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
let currentHostId: string | null = movingWindowNode.parentId
let committed = false
// Off-wall free-follow: over empty floor the window is parented to the
// level and tracks the cursor like an item. `freeFollowing` marks that
// state; `lastMeshEventTime` defers the floor handler whenever a wall/roof
// mesh event owns the same pointermove — that's the only thing that snaps.
let freeFollowing = false
let lastMeshEventTime = -1
// Last open-floor cursor point (level-local X/Z), so an R-flip while free-
// following can re-run the ghost at the same spot with the new facing.
let lastFloorPoint: [number, number] | null = null
// Live Shift state (force-place) — lets the preview tint re-evaluate when
// Shift is pressed/released with the pointer stationary (see `MoveDoorTool`).
let shiftHeld = false
// Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical
// whether free-following over floor or sliding along a wall (the user's
// ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the
// snapped along-wall value. Guards: `lastStepKey` (cell change) +
// `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a
// distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`.
const STEP_M = 0.1
let lastStepKey: string | null = null
let lastTickFrame = -1
const tickGridStep = (frame: number, ...coords: number[]) => {
if (frame === lastTickFrame) return
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
if (key === lastStepKey) return
lastStepKey = key
lastTickFrame = frame
triggerSFX('sfx:grid-snap')
}
// The window's chosen facing side. R flips it mid-placement (front ↔ back),
// matching the committed-selected R flip. Initialised from the moving node.
let sideOverride: WindowNode['side'] = movingWindowNode.side
let dragAnchor: {
wallId: string
rawX: number
@@ -113,7 +192,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: string
side: WindowNode['side']
itemRotation: number
cursorRotation: number
clampedX: number
clampedY: number
valid: boolean
@@ -141,6 +219,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
// Sill-center height used while the window isn't on a wall (free-follow and
// proximity). Fresh preset clones are created at position [0,0,0], which
// would bury half the window below the floor; default such windows to a
// small sill so the ghost floats slightly above the ground. An existing
// window keeps its own sill.
const getSillCenterY = () => {
const y = movingWindowNode.position[1]
return y > 0.1 ? y : DEFAULT_WINDOW_SILL_M + movingWindowNode.height / 2
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
@@ -151,6 +239,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setGhostPose(null)
}
// Alignment candidates — anchors of every OTHER alignable object (the
@@ -183,9 +273,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const faceSide = getSideFromNormal(event.normal)
const side = sideOverride ?? faceSide
const rotationOffset = side !== faceSide ? Math.PI : 0
const itemRotation = calculateItemRotation(event.normal) + rotationOffset
const rawLocalX = event.localPosition[0]
const rawLocalY = event.localPosition[1]
@@ -206,15 +297,30 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY)
const targetLocalY =
event.nativeEvent?.shiftKey === true ? targetRawLocalY : snapToHalf(targetRawLocalY)
// Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins
// over the 0.5m grid when within threshold; Shift bypasses both.
const bypassY = event.nativeEvent?.shiftKey === true
const sillSnapped = bypassY
? null
: resolveSillSnap({
wall: event.node,
movingId: movingWindowNode.id,
localX: targetLocalX,
localY: targetRawLocalY,
width: movingWindowNode.width,
height: movingWindowNode.height,
nodes: useScene.getState().nodes,
})
const targetLocalY = bypassY ? targetRawLocalY : (sillSnapped ?? snapToHalf(targetRawLocalY))
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: targetLocalX,
width: movingWindowNode.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
// Alt still hard-disables alignment (no guides). Shift = free-place:
// land at the raw cursor but keep showing the along-wall guides.
bypass: event.nativeEvent?.altKey === true,
freePlace: event.nativeEvent?.shiftKey === true,
})
const { clampedX, clampedY } = clampToWall(
event.node,
@@ -238,7 +344,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: event.node.id,
side,
itemRotation,
cursorRotation,
clampedX,
clampedY,
valid,
@@ -247,6 +352,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
// Same click as the off-wall ghost: one grid-snap tick per grid step,
// keyed on the RAW cursor along-wall position (not the snapped clampedX).
// Per-frame guard collapses duplicate wall events on the same pointermove.
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
// Keep the REAL node hidden and show a tinted ghost in the wall opening —
// green when placeable, red when it collides — matching the free-follow
// ghost so validity reads at a glance (see MoveDoorTool). The node position
// is still written so the wall cuts the hole at the right spot.
if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingWindowNode.id, {
position: [target.clampedX, target.clampedY, 0],
@@ -256,6 +369,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
markHostDirty(currentHostId)
currentHostId = target.wallId
@@ -273,25 +387,50 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
})
markHostDirtyThrottled(target.wallId)
updateCursor(
wallLocalToWorld(
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld })
// Ghost world yaw must equal the committed wall-CHILD's world yaw
// (-wallAngle + itemRotation); `cursorRotation` is π off here. See
// `MoveDoorTool.applyPreview`.
const wallAngle = Math.atan2(
target.wallNode.end[1] - target.wallNode.start[1],
target.wallNode.end[0] - target.wallNode.start[0],
)
setGhostPose({
position: wallLocalToWorld(
target.wallNode,
target.clampedX,
target.clampedY,
getLevelYOffset(),
getSlabElevation(target.event),
),
target.cursorRotation,
target.valid,
)
rotationY: target.itemRotation - wallAngle,
tint: placement.tint,
floorY: getLevelYOffset() + getSlabElevation(target.event),
side: target.side,
})
publishOpeningGuidesForWallEvent({
wall: target.wallNode,
movingId: movingWindowNode.id,
centerS: target.clampedX,
centerY: target.clampedY,
width: movingWindowNode.width,
height: movingWindowNode.height,
includeVertical: true,
levelYOffset: getLevelYOffset(),
slabElevation: getSlabElevation(target.event),
})
}
const onWallEnter = (event: WallEvent) => {
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
const target = resolveMoveTarget(event)
if (!target) {
onWallLeave()
return
}
freeFollowing = false
lastTarget = target
lastRoofEvent = null
applyPreview(target)
@@ -299,6 +438,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const onWallMove = (event: WallEvent) => {
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
if (!isValidWallSideFace(event.normal)) {
onWallLeave()
return
@@ -318,21 +458,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
onWallLeave()
return
}
freeFollowing = false
lastTarget = target
lastRoofEvent = null
applyPreview(target)
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
// Promote the moving window into its committed wall placement. Shared by
// the direct wall-mesh click and the floor proximity click.
const commitToWall = (target: NonNullable<typeof lastTarget>) => {
if (committed) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
if (!target?.valid) return
committed = true
let placedId: string
@@ -356,6 +492,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
// Hidden during free-follow; the committed window must be visible.
visible: true,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -371,6 +509,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -382,6 +521,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId,
roofSegmentId: undefined,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== target.wallId) {
@@ -398,31 +538,109 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
}
const onWallClick = (event: WallEvent) => {
if (committed) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
// Shift force-places: commit even when the window overlaps another opening.
// The preview keeps its red invalid tint as a warning; Shift just lifts the
// commit block. Read shift from THIS event so it's never stale at commit.
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
commitToWall(target)
event.stopPropagation()
}
const onWallLeave = () => {
// The cursor left the wall mesh. Don't snap back to the origin/original
// here — the floor proximity handler (onGridMove) takes over on the same
// pointermove: it snaps to a nearby wall or free-follows the cursor, so
// the window never blinks back to the building origin between a wall and
// open floor. Revert is left to free-follow / cancel / commit.
hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
}
// Reveal the real window node + drop the ghost. Used by the roof-face path,
// which previews with the real mesh (the ghost-tint flow is wall-specific).
const revealRealNode = () => {
setGhostPose(null)
const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
| WindowNode
| undefined
if (live && live.visible === false) {
useScene.getState().updateNode(movingWindowNode.id, { visible: true })
}
currentHostId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
}
// Free-follow: over open floor there's no wall to host the window, so hide
// the real (pale, near-invisible-on-grid) node and float a red translucent
// ghost at the cursor — same treatment the raw `WindowTool` build path uses.
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
freeFollowing = true
lastTarget = null
lastRoofEvent = null
// Click per grid cell as the ghost slides over open floor (X+Z) — the
// same `tickGridStep` the on-wall slide uses, so both feel identical.
tickGridStep(frame, localX, localZ)
hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id)
const levelId = getLevelId()
const sillCenterY = getSillCenterY()
// Keep the R-flip visible while free-following (back = rotated π).
const yaw = sideOverride === 'back' ? Math.PI : 0
if (currentHostId !== levelId) {
if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId)
useScene.getState().updateNode(movingWindowNode.id, {
position: [localX, sillCenterY, localZ],
rotation: [0, yaw, 0],
side: sideOverride,
parentId: levelId ?? undefined,
wallId: undefined,
roofSegmentId: undefined,
roofFace: undefined,
visible: false,
})
currentHostId = levelId
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: [localX, sillCenterY, localZ],
rotation: [0, yaw, 0],
side: sideOverride,
visible: false,
})
}
// Float the red (invalid — no wall) ghost at the cursor, level-Y lifted to
// the sill center (sideOverride carries the R-flip so the ghost matches).
setGhostPose({
position: [localX, getLevelYOffset() + sillCenterY, localZ],
rotationY: yaw,
tint: 'invalid',
floorY: getLevelYOffset(),
side: sideOverride,
})
if (original.parentId) markHostDirty(original.parentId)
}
const onGridMove = (event: GridEvent) => {
if (committed) return
if (useViewer.getState().cameraDragging) return
// A wall/roof mesh handler owns this exact pointermove (shared DOM
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
// the cursor is over open floor — free-follow it. No proximity magnet:
// snapping engages only when the cursor ray actually hovers a wall.
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
const [x, , z] = event.localPosition
lastFloorPoint = [x, z]
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -449,16 +667,22 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const onRoofHover = (event: RoofEvent) => {
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
const target = resolveRoofMoveTarget(event)
if (!target) {
onRoofLeave()
return
}
// Wall-frame drag anchor / live transform don't apply on a roof face.
freeFollowing = false
dragAnchor = null
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingWindowNode.id)
// Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D()
// On a roof face the real mesh is the preview — drop the ghost + reveal.
revealRealNode()
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
@@ -468,6 +692,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: undefined,
roofSegmentId: target.segment.id,
roofFace: target.face.id,
visible: true,
})
markHostDirty(currentHostId)
currentHostId = target.segment.id
@@ -485,7 +710,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onRoofClick = (event: RoofEvent) => {
if (committed) return
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target too (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
committed = true
const segmentId = target.segment.id
@@ -508,6 +735,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: segmentId,
roofFace: target.face.id,
parentId: segmentId,
visible: true,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id
@@ -521,6 +749,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
useScene.temporal.getState().resume()
@@ -533,6 +762,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: segmentId,
roofFace: target.face.id,
metadata: {},
visible: true,
})
if (original.parentId && original.parentId !== segmentId) {
@@ -553,26 +783,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const onRoofLeave = () => {
// Mirror onWallLeave: don't revert to origin here — onGridMove takes
// over on the same pointermove (snap to a nearby wall or free-follow).
hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
lastRoofEvent = null
if (isNew) return
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
}
currentHostId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markHostDirty(original.parentId)
}
const onCancel = () => {
@@ -590,6 +807,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
@@ -600,13 +818,68 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (lastTarget) {
onWallClick(lastTarget.event)
// Free-following over open floor can't commit (no wall). A wall hover
// target commits via commitToWall; a roof face via onRoofClick. Shift
// force-places over a colliding wall target (tint stays red as a warning);
// read shift from this pointerup so it's current at commit.
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
commitToWall(lastTarget)
return
}
if (lastRoofEvent) onRoofClick(lastRoofEvent)
}
// R flips the window's facing side mid-placement (front ↔ back), like the
// committed-selected R flip — usable before commit, whether snapped to a
// wall or free-following. No-op on a roof-segment face (front-only host).
const onKeyDown = (e: KeyboardEvent) => {
if (committed) return
if (e.key !== 'r' && e.key !== 'R') return
const target = e.target as HTMLElement | null
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return
}
// Ignore OS key-repeat so a held R doesn't flip many times per press.
if (e.repeat) return
e.preventDefault()
// ALWAYS toggle the persistent flip intent — never a no-op (the old gate
// dropped R before the first pointermove). Then re-render the current
// preview so the flip shows live and matches commit. See `MoveDoorTool`.
sideOverride = sideOverride === 'front' ? 'back' : 'front'
triggerSFX('sfx:item-rotate')
if (lastTarget) {
const next = resolveMoveTarget(lastTarget.event)
if (next) {
lastTarget = next
applyPreview(next)
}
} else if (lastFloorPoint) {
// Free-following: re-run at the same spot so the floating ghost rebuilds
// with the flipped side.
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
} else {
// No preview yet (R before the first pointermove): flip the hidden node
// so the first preview/commit already reflects the chosen side.
useScene.getState().updateNode(movingWindowNode.id, {
side: sideOverride,
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
})
}
}
// Shift toggles force-place — re-run the on-wall preview so the tint flips
// green↔red live (pointer stationary). Commit gates still read shift fresh.
const onShiftToggle = (e: KeyboardEvent) => {
if (e.key !== 'Shift') return
const held = e.type === 'keydown'
if (held === shiftHeld) return
shiftHeld = held
if (!committed && lastTarget) applyPreview(lastTarget)
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
@@ -615,8 +888,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', onGridMove)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keydown', onShiftToggle)
window.addEventListener('keyup', onShiftToggle)
return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
@@ -638,12 +915,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
visible: original.visible,
})
if (original.parentId) markHostDirty(original.parentId)
}
} else if (current && current.visible === false) {
// Safety net: a fresh (isNew) clone isn't marked `isTransient`; if we
// unmount mid-free-follow it would be left hidden. Reveal it so it never
// becomes an invisible orphan (place-preset deletes a true cancel).
useScene.getState().updateNode(movingWindowNode.id, { visible: true })
}
useLiveTransforms.getState().clear(movingWindowNode.id)
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
@@ -653,8 +937,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', onGridMove)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keydown', onShiftToggle)
window.removeEventListener('keyup', onShiftToggle)
}
}, [movingWindowNode, exitMoveMode])
@@ -668,11 +956,38 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
boxGeo.dispose()
return geo
}, [movingWindowNode])
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
<>
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
</group>
{/* Placement ghost shown for the whole move (the real pale node stays
hidden): red off-wall / colliding, green on a valid wall. */}
{ghostPose && (
<group position={ghostPose.position} rotation-y={ghostPose.rotationY}>
<WindowPreview
invalid={ghostPose.tint === 'invalid'}
node={ghostNode}
valid={ghostPose.tint === 'valid'}
/>
</group>
)}
{/* Floor "shadow" projection: footprint + dashed drop-line, so an elevated
window's plan position is legible while placing. World-space, so it's a
sibling of the ghost group, not a child. */}
{ghostPose && (
<WindowFloorProjection
centerX={ghostPose.position[0]}
centerY={ghostPose.position[1]}
centerZ={ghostPose.position[2]}
floorY={ghostPose.floorY}
rotationY={ghostPose.rotationY}
width={movingWindowNode.width}
/>
)}
</>
)
}
+1 -1
View File
@@ -383,7 +383,7 @@ export default function WindowPanel() {
return (
<PanelWrapper
icon="/icons/window.png"
icon="/icons/window.webp"
onClose={handleClose}
title={node.name || 'Window'}
width={320}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { EDITOR_LAYER } from '@pascal-app/editor'
import { buildWindowPreviewMesh } from '@pascal-app/viewer'
import { useEffect, useMemo } from 'react'
import { applyGhost } from '../shared/ghost-materials'
import type { WindowNode } from './schema'
/**
* Translucent preview of a window — used by the placement tool's floating ghost.
*
* Builds the window mesh via buildWindowPreviewMesh (so the preview shape stays in
* lockstep with committed windows), then applies ghost treatment (translucent,
* raycast-off, tinted red if invalid).
*
* The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox
* material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha).
*/
const WindowPreview = ({
node,
invalid,
valid,
}: {
node: WindowNode
invalid?: boolean
valid?: boolean
}) => {
const mesh = useMemo(() => {
const m = buildWindowPreviewMesh(node)
m.layers.set(EDITOR_LAYER)
return m
}, [
node.width,
node.height,
node.frameDepth,
node.openingShape,
node.windowType,
node.sill,
node.sillDepth,
node.sillThickness,
])
// Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
// its cleanup only disposes the clones it made.
useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
// Geometry is freshly built per `mesh` and owned here — dispose it only
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
useEffect(
() => () => {
mesh.traverse((obj) => {
const m = obj as { geometry?: { dispose: () => void } }
m.geometry?.dispose()
})
},
[mesh],
)
return <primitive object={mesh} />
}
export default WindowPreview
+424 -254
View File
@@ -10,6 +10,7 @@ import {
spatialGridManager,
useScene,
type WallEvent,
type WallNode,
WindowNode,
} from '@pascal-app/core'
import {
@@ -23,9 +24,14 @@ import {
useAlignmentGuides,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
clearOpeningGuides3D,
publishOpeningGuidesForWallEvent,
resolveSillSnap,
} from '../shared/opening-guides-runtime'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
@@ -33,7 +39,14 @@ import {
worldToSelectedBuildingLocal,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
import { WindowFloorProjection } from './floor-projection'
import WindowPreview from './preview'
import {
clampToWall,
DEFAULT_WINDOW_SILL_M,
hasWallChildOverlap,
wallLocalToWorld,
} from './window-math'
// Shared edge material — reuse across renders, just toggle color
const edgeMaterial = new LineBasicNodeMaterial({
@@ -45,34 +58,81 @@ const edgeMaterial = new LineBasicNodeMaterial({
const FALLBACK_WIDTH = 1.5
const FALLBACK_HEIGHT = 1.5
const FALLBACK_SILL_LIFT = 0.45
// Off-wall ghost lift = the default sill, so the floating preview matches the
// sill the floor-cursor placement commits at.
const FALLBACK_SILL_LIFT = DEFAULT_WINDOW_SILL_M
// Default sill centre for a window snapped from the floor (the floor cursor
// carries no wall-face height): the default sill + half the default height.
const DEFAULT_SILL_CENTER_Y = DEFAULT_WINDOW_SILL_M + FALLBACK_HEIGHT / 2
const roofFallbackPoint = new Vector3()
// What currently owns the cursor frame: a wall/roof mesh hover, or null when
// the cursor is over open floor (the grid handler then free-follows).
type HostKind = 'wall' | 'roof' | null
/**
* Window tool — places WindowNodes on walls and on roof-segment wall
* faces (the generated base walls under a roof, including coplanar gable
* ends — a window can sit in the gable pediment).
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
*
* The ghost follows the cursor everywhere (like moving an item): over open
* floor it floats as an invalid (unplaceable) ghost; the moment the cursor ray
* hovers a wall (or roof-segment face) the real draft snaps onto it. Snapping
* engages only on an actual mesh hover — no proximity magnet.
*/
const WindowTool: React.FC = () => {
const draftRef = useRef<WindowNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
// Off-host floating ghost: the real window geometry follows the cursor
// over the grid (tinted invalid). Mutually exclusive with the on-host draft.
// `floorY` feeds the floor "shadow" projection; `side` carries the R-flip so
// the floating ghost faces the side that will be committed.
const [fallbackPose, setFallbackPose] = useState<{
position: [number, number, number]
rotationY: number
floorY: number
side: WindowNode['side']
} | null>(null)
// Ghost preview node — zeroed transform + the live facing side (rebuilds on R).
const ghostStub = useMemo(
() =>
WindowNode.parse({
position: [0, 0, 0],
rotation: [0, 0, 0],
side: fallbackPose?.side ?? 'front',
}),
[fallbackPose?.side],
)
useEffect(() => {
useScene.temporal.getState().pause()
let hostKind: HostKind = null
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
// the grid raycast from the SAME pointermove share the source DOM event's
// timeStamp, so the grid handler can detect "a mesh handler already owns
// this frame" without depending on event order or on a leave firing (node
// events are suppressed during a camera drag, so a sticky boolean would
// strand the draft after an orbit; a per-frame timestamp self-heals).
let lastMeshEventTime = -1
// R flips the window's facing side mid-placement (front ↔ back); re-applied
// to the last wall hover so the flip shows live before commit.
let sideFlip = false
let lastWallEvent: WallEvent | null = null
// Last open-floor cursor point (level-local X/Z) + floor Y, so an R-flip
// while free-following can re-render the floating ghost with the new facing.
let lastFloorPoint: { pos: [number, number, number]; floorY: number } | null = null
const getLevelId = () => useViewer.getState().selection.levelId
const getLevelYOffset = () => {
const id = getLevelId()
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
}
const getSlabElevation = (wallEvent: WallEvent) =>
spatialGridManager.getSlabElevationForWall(
wallEvent.node.parentId ?? '',
wallEvent.node.start,
wallEvent.node.end,
)
const getSlabElevationForWall = (wall: WallNode) =>
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
@@ -90,6 +150,8 @@ const WindowTool: React.FC = () => {
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setFallbackPose(null)
}
// Alignment candidates — anchors of every alignable object; refreshed
@@ -97,11 +159,15 @@ const WindowTool: React.FC = () => {
// (along-wall only; the floor-plane guides don't cover sill height).
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// On-host cursor: the green/red wireframe outline tracks a live draft.
// Showing it always clears the off-host floating ghost (they never
// coexist — a draft means the cursor is on a valid host).
const updateCursor = (
worldPosition: [number, number, number],
cursorRotationY: number,
valid: boolean,
) => {
setFallbackPose(null)
const group = cursorGroupRef.current
if (!group) return
group.visible = true
@@ -110,276 +176,231 @@ const WindowTool: React.FC = () => {
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
}
const showFallbackCursor = (event: GridEvent) => {
if (draftRef.current) return
const [x, y, z] = event.localPosition
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
// Off-host fallback: hide the wireframe outline and float the real window
// geometry (tinted invalid) at the cursor so the armed tool is visible.
const showGhostAt = (position: [number, number, number], floorY: number) => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
lastFloorPoint = { pos: position, floorY }
// `sideFlip` (R) flips the facing — back is a π yaw on the floating ghost.
setFallbackPose({
position,
rotationY: sideFlip ? Math.PI : 0,
floorY,
side: sideFlip ? 'back' : 'front',
})
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
showGhostAt(
[x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z],
getLevelYOffset(),
)
}
const showWallFallbackCursor = (event: WallEvent) => {
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
useAlignmentGuides.getState().clear()
}
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
const levelId = getLevelId()
if (!levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
// Only interact with walls on the current level
if (event.node.parentId !== levelId) {
destroyDraft()
showWallFallbackCursor(event)
return
}
destroyDraft()
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const width = 1.5
const height = 1.5
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
})
const localY =
event.nativeEvent?.shiftKey === true
? event.localPosition[1]
: snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
updateCursor(
wallLocalToWorld(
event.node,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevation(event),
),
cursorRotation,
valid,
showGhostAt(
[x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z],
getLevelYOffset(),
)
event.stopPropagation()
}
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
if (isCurvedWall(event.node)) {
destroyDraft()
showWallFallbackCursor(event)
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) {
destroyDraft()
showWallFallbackCursor(event)
return
}
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
// 0.5m grid when within threshold; Shift bypasses both. `movingId` is the
// draft's id once it exists (so it's excluded from the sibling scan), or ''
// before the draft is created (nothing to exclude yet).
const resolvePlacementY = (args: {
wall: WallNode
movingId: string
localX: number
rawLocalY: number
width: number
height: number
bypassSnap: boolean
}): number => {
if (args.bypassSnap) return args.rawLocalY
const sillY = resolveSillSnap({
wall: args.wall,
movingId: args.movingId,
localX: args.localX,
localY: args.rawLocalY,
width: args.width,
height: args.height,
nodes: useScene.getState().nodes,
})
return sillY ?? snapToHalf(args.rawLocalY)
}
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
// Settle a wall target: alignment snap → sill clamp → overlap check.
const resolveWallPlacement = (
wall: WallNode,
rawLocalX: number,
rawLocalY: number,
width: number,
height: number,
bypass: boolean,
bypassSnap: boolean,
ignoreId?: string,
) => {
// bypassSnap is set by Shift (see callers). Shift = free-place: land at the
// raw cursor but keep the along-wall guides visible. bypass (Alt) still
// hard-disables alignment.
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
wallNode: wall,
rawLocalX,
width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
bypass: bypass && !bypassSnap,
freePlace: bypassSnap,
})
const localY =
event.nativeEvent?.shiftKey === true
? event.localPosition[1]
: snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
// Draft may be null after a successful placement (the click handler
// deletes it and relies on the wall rebuild → pointer-enter cascade to
// recreate it). Recreate it here on the first subsequent move so the
// preview is ready for the next click without requiring a leave/enter.
if (!draftRef.current) {
const levelId = getLevelId()
if (levelId && event.node.parentId === levelId) {
const node = WindowNode.parse({
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
draftRef.current = node
}
}
if (draftRef.current) {
// Update the scene store on every move so the 2D floor plan
// stays in sync (it re-renders from `node.position`). Only
// forward `parentId` / `wallId` when the wall actually changed
// — otherwise the reparent path churns the host wall's
// `children` array every tick, which re-renders the wall and
// briefly draws its 0-vertex placeholder geometry (WebGPU then
// flags "Vertex buffer slot 0 ... was not set").
const isSameWall = event.node.id === draftRef.current.parentId
if (isSameWall) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
})
markHostDirty(event.node.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: event.node.id,
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
const localY = resolvePlacementY({
wall,
movingId: ignoreId ?? '',
localX,
rawLocalY,
width,
height,
draftRef.current?.id,
bypassSnap,
})
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
return { clampedX, clampedY, valid }
}
// Shared create/update path for the wall draft — used by the direct
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
// (reparenting only on an actual wall change to avoid churning the host's
// children array, which flashes 0-vertex wall geometry in WebGPU).
const applyWallTarget = (args: {
wall: WallNode
rawLocalX: number
rawLocalY: number
side: 'front' | 'back'
itemRotation: number
cursorRotationY: number
bypass: boolean
bypassSnap: boolean
}) => {
const {
wall,
rawLocalX,
rawLocalY,
side,
itemRotation,
cursorRotationY,
bypass,
bypassSnap,
} = args
const width = draftRef.current?.width ?? 1.5
const height = draftRef.current?.height ?? 1.5
if (!draftRef.current) {
const node = WindowNode.parse({
position: [0, DEFAULT_SILL_CENTER_Y, 0],
rotation: [0, itemRotation, 0],
side,
wallId: wall.id,
parentId: wall.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, wall.id as AnyNodeId)
draftRef.current = node
}
const { clampedX, clampedY, valid } = resolveWallPlacement(
wall,
rawLocalX,
rawLocalY,
width,
height,
bypass,
bypassSnap,
draftRef.current.id,
)
if (wall.id === draftRef.current.parentId) {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
})
markHostDirty(wall.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
parentId: wall.id,
wallId: wall.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
updateCursor(
wallLocalToWorld(
event.node,
wall,
clampedX,
clampedY,
getLevelYOffset(),
getSlabElevation(event),
getSlabElevationForWall(wall),
),
cursorRotation,
cursorRotationY,
valid,
)
event.stopPropagation()
if (draftRef.current) {
publishOpeningGuidesForWallEvent({
wall,
movingId: draftRef.current.id,
centerS: clampedX,
centerY: clampedY,
width,
height,
includeVertical: true,
levelYOffset: getLevelYOffset(),
slabElevation: getSlabElevationForWall(wall),
})
}
return { clampedX, clampedY, valid }
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
const itemRotation = calculateItemRotation(event.normal)
const localX = resolveWallSlideAlignment({
wallNode: event.node,
rawLocalX: event.localPosition[0],
width: draftRef.current.width,
candidates: alignmentCandidates,
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
bypassSnap: event.nativeEvent?.shiftKey === true,
})
const localY =
event.nativeEvent?.shiftKey === true
? event.localPosition[1]
: snapToHalf(event.localPosition[1])
const { clampedX, clampedY } = clampToWall(
event.node,
localX,
localY,
draftRef.current.width,
draftRef.current.height,
)
const valid = !hasWallChildOverlap(
event.node.id,
clampedX,
clampedY,
draftRef.current.width,
draftRef.current.height,
draftRef.current.id,
)
if (!valid) return
// Promote the draft into a permanent window. Shared by the wall-mesh click
// and the floor proximity click.
const commitWindowAtWall = (
wall: WallNode,
clampedX: number,
clampedY: number,
side: 'front' | 'back',
itemRotation: number,
) => {
const draft = draftRef.current
if (!draft) return
draftRef.current = null
hostKind = null
// Delete transient draft (paused, invisible to undo)
useScene.getState().deleteNode(draft.id)
// Resume → create permanent node (single undoable action)
useScene.temporal.getState().resume()
const levelId = getLevelId()
const state = useScene.getState()
const windowCount = Object.values(state.nodes).filter((n) => {
if (n.type !== 'window') return false
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return wall?.parentId === levelId
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
return w?.parentId === levelId
}).length
const name = `Window ${windowCount + 1}`
const node = WindowNode.parse({
name,
name: `Window ${windowCount + 1}`,
position: [clampedX, clampedY, 0],
rotation: [0, itemRotation, 0],
side,
wallId: event.node.id,
parentId: event.node.id,
wallId: wall.id,
parentId: wall.id,
width: draft.width,
height: draft.height,
windowType: draft.windowType,
@@ -398,19 +419,111 @@ const WindowTool: React.FC = () => {
sillThickness: draft.sillThickness,
})
useScene.getState().createNode(node, event.node.id as AnyNodeId)
useScene.getState().createNode(node, wall.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
// ── Direct wall-mesh hover ──────────────────────────────────────
const onWallHover = (event: WallEvent) => {
hostKind = 'wall'
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
if (
!isValidWallSideFace(event.normal) ||
isCurvedWall(event.node) ||
event.node.parentId !== getLevelId()
) {
destroyDraft()
showWallFallbackCursor(event)
return
}
lastWallEvent = event
const faceSide = getSideFromNormal(event.normal)
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
const flipOffset = sideFlip ? Math.PI : 0
const itemRotation = calculateItemRotation(event.normal) + flipOffset
const cursorRotation =
calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset
const bypassSnap = event.nativeEvent?.shiftKey === true
const bypass = event.nativeEvent?.altKey === true || bypassSnap
applyWallTarget({
wall: event.node,
rawLocalX: event.localPosition[0],
rawLocalY: event.localPosition[1],
side,
itemRotation,
cursorRotationY: cursorRotation,
bypass,
bypassSnap,
})
event.stopPropagation()
}
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (
!isValidWallSideFace(event.normal) ||
isCurvedWall(event.node) ||
event.node.parentId !== getLevelId()
) {
return
}
const faceSide = getSideFromNormal(event.normal)
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0)
const bypassSnap = event.nativeEvent?.shiftKey === true
const bypass = event.nativeEvent?.altKey === true || bypassSnap
const { clampedX, clampedY, valid } = resolveWallPlacement(
event.node,
event.localPosition[0],
event.localPosition[1],
draftRef.current.width,
draftRef.current.height,
bypass,
bypassSnap,
draftRef.current.id,
)
// Shift force-places over a collision (the draft stays red as a warning).
if (!valid && !bypassSnap) return
commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation)
event.stopPropagation()
}
const onWallLeave = () => {
if (hostKind !== 'wall') return
lastWallEvent = null
destroyDraft()
hideCursor()
hostKind = null
}
// ── Floor free-follow ───────────────────────────────────────────
// Over open floor the ghost follows the cursor like a moving item. It does
// NOT snap from proximity — snapping engages only when the cursor ray
// actually hovers a wall (onWallHover) or roof face (onRoofHover).
const onGridFreeFollow = (event: GridEvent) => {
if (useViewer.getState().cameraDragging) return
// A wall/roof mesh handler processed this exact pointermove (R3F + the
// grid raycast share the source DOM event's timeStamp) — it owns the
// frame and has snapped the draft, so skip the floor follow this tick.
const ts = event.nativeEvent?.timeStamp ?? -1
if (ts === lastMeshEventTime) return
// Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft
// and free-follow the cursor with the invalid (unplaceable) ghost.
hostKind = null
lastWallEvent = null
const [x, y, z] = event.localPosition
destroyDraft()
showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y)
}
// ── Roof-segment wall faces ─────────────────────────────────────
@@ -437,6 +550,8 @@ const WindowTool: React.FC = () => {
}
const onRoofHover = (event: RoofEvent) => {
hostKind = 'roof'
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
const target = resolveRoofTarget(event)
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
@@ -467,6 +582,8 @@ const WindowTool: React.FC = () => {
useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node
}
// Opening guides are wall-specific; clear them while over a roof face.
clearOpeningGuides3D()
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
@@ -474,11 +591,14 @@ const WindowTool: React.FC = () => {
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
// Shift force-places over a colliding roof-face target (see onWallClick).
if (!target) return
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
const { segment, face, position } = target
const draft = draftRef.current
draftRef.current = null
hostKind = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
@@ -525,59 +645,109 @@ const WindowTool: React.FC = () => {
}
const onRoofLeave = () => {
if (!draftRef.current?.roofSegmentId) return
if (hostKind !== 'roof') return
destroyDraft()
hideCursor()
hostKind = null
}
const onCancel = () => {
destroyDraft()
hideCursor()
hostKind = null
}
emitter.on('wall:enter', onWallEnter)
emitter.on('wall:move', onWallMove)
// R flips the window's facing side mid-placement (front ↔ back). ALWAYS
// toggles the persistent flip intent — never a no-op (the old `!lastWallEvent`
// guard dropped R off-wall / before the first hover). Then re-renders the
// current preview so the flip shows live and matches commit.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'r' && e.key !== 'R') return
if (e.repeat) return
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
e.preventDefault()
sideFlip = !sideFlip
triggerSFX('sfx:item-rotate')
if (lastWallEvent) {
onWallHover(lastWallEvent)
} else if (lastFloorPoint) {
showGhostAt(lastFloorPoint.pos, lastFloorPoint.floorY)
}
// else: no preview yet — `sideFlip` is set, so the first hover/follow uses it.
}
emitter.on('wall:enter', onWallHover)
emitter.on('wall:move', onWallHover)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofHover)
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('grid:move', showFallbackCursor)
emitter.on('grid:move', onGridFreeFollow)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
destroyDraft()
hideCursor()
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
emitter.off('wall:enter', onWallHover)
emitter.off('wall:move', onWallHover)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofHover)
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('grid:move', showFallbackCursor)
emitter.off('grid:move', onGridFreeFollow)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [])
// Cursor geometry: window outline rectangle.
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
// Cursor geometry: window outline rectangle. Static dims, so build it once and
// dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry
// on every re-render during placement.
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [])
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<group ref={cursorGroupRef} visible={false}>
<lineSegments
geometry={edgesGeo}
layers={EDITOR_LAYER}
material={edgeMaterial}
ref={edgesRef}
/>
</group>
<>
<group ref={cursorGroupRef} visible={false}>
<lineSegments
geometry={edgesGeo}
layers={EDITOR_LAYER}
material={edgeMaterial}
ref={edgesRef}
/>
</group>
{fallbackPose && (
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
<WindowPreview invalid node={ghostStub} />
</group>
)}
{/* Floor "shadow" projection for the off-host ghost (drop-line + footprint)
so the elevated window's plan position is legible while placing. */}
{fallbackPose && (
<WindowFloorProjection
centerX={fallbackPose.position[0]}
centerY={fallbackPose.position[1]}
centerZ={fallbackPose.position[2]}
floorY={fallbackPose.floorY}
rotationY={fallbackPose.rotationY}
width={FALLBACK_WIDTH}
/>
)}
</>
)
}
+14 -69
View File
@@ -1,12 +1,13 @@
import {
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import type { WallNode } from '@pascal-app/core'
/**
* Default sill height (metres from the floor to the BOTTOM of a window) for a
* fresh window that has no wall-face height yet — the off-wall ghost and the
* floor-cursor placement use it so a new window floats slightly above the
* ground rather than sitting on it. The committed Y is the window's CENTRE, so
* callers add `height / 2`. An existing window keeps its own sill.
*/
export const DEFAULT_WINDOW_SILL_M = 0.5
/**
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
@@ -54,64 +55,8 @@ export function clampToWall(
}
/**
* Directly checks the wall's children for bounding-box overlap with a proposed window.
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
* The spatial grid only tracks `item` nodes, so windows must be checked this way.
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
* Wall-child overlap is shared by door + window placement (one source of
* truth in `shared/wall-attach-target.ts`). Re-exported here so existing
* `./window-math` importers don't change.
*/
export function hasWallChildOverlap(
wallId: string,
clampedX: number,
clampedY: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode) return true // Block if wall not found
const halfW = width / 2
const halfH = height / 2
const newBottom = clampedY - halfH
const newTop = clampedY + halfH
const newLeft = clampedX - halfW
const newRight = clampedX + halfW
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
let childLeft: number, childRight: number, childBottom: number, childTop: number
if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childLeft = item.position[0] - w / 2
childRight = item.position[0] + w / 2
childBottom = item.position[1] // items store bottom Y
childTop = item.position[1] + h
} else if (child.type === 'window') {
const win = child as WindowNode
childLeft = win.position[0] - win.width / 2
childRight = win.position[0] + win.width / 2
childBottom = win.position[1] - win.height / 2 // windows store center Y
childTop = win.position[1] + win.height / 2
} else if (child.type === 'door') {
const door = child as DoorNode
childLeft = door.position[0] - door.width / 2
childRight = door.position[0] + door.width / 2
childBottom = door.position[1] - door.height / 2 // doors store center Y
childTop = door.position[1] + door.height / 2
} else {
continue
}
const xOverlap = newLeft < childRight && newRight > childLeft
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
export { hasWallChildOverlap } from '../shared/wall-attach-target'