Always-visible placement ghosts + true-nearest 2D opening snap (#407)
* feat(editor): always-visible translucent placement ghost for openings + roof accessories When a host-surface placement tool is armed, the node's real geometry now follows the cursor everywhere as a translucent ghost: tinted invalid (red) and unconfirmable off-host, snapping onto its host surface (wall/roof) with the existing valid/invalid affordances when near. This replaces the old red wireframe box (door/window) and red DragBoundingBox (roof accessories), so the armed tool is visible before the cursor reaches a placeable surface. - New shared `applyGhost` helper (nodes/src/shared/ghost-materials.ts): clones materials, disables raycast (avoids cursor-ray starvation), tints invalid; cleanup disposes only the clones. - New door/window preview components built from the real geometry via new `buildDoorPreviewMesh`/`buildWindowPreviewMesh` viewer exports; tools float the ghost via a `fallbackPose` that is mutually exclusive with the on-host draft + wireframe outline. - `RoofAttachmentFallbackPreview` gains a `ghost` prop; all 11 roof-accessory tools pass their real preview (invalid-tinted) instead of a box `size`. Snapping behavior is unchanged (no proximity snap yet). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): magnetic proximity wall-snap for door/window placement The door/window ghost now follows the cursor over the floor like a moving item and magnetically snaps onto the nearest wall within range (1.5 m), then releases back to free-follow when the cursor moves away — instead of only attaching on a direct wall-mesh ray hit. A grid-snap sound plays each time it snaps onto a new spot, so it reads as moving a physical object that can only land on walls. - Plan-space proximity via the existing `findClosestWallInPlan` (the same helper the 2D floor-plan move uses): level-scoped, skips curved walls, returns wall + along-wall localX + side + wall-local rotation. - `grid:move` drives the snap and `grid:click` commits when proximity-snapped; a direct wall-mesh hover (wall:enter/move) still owns the precise face side. Both paths share `applyWallTarget` (create the draft once, reparent only on an actual wall change) and a shared commit that refreshes alignment anchors. - Disambiguation without a stuck flag: a per-pointermove `timeStamp` gate (R3F + the grid raycast share the source DOM event) plus a `cameraDragging` guard and stale-`hostKind` reset, so a missed wall:leave during a camera orbit can't strand the draft. - Window keeps its sill height on the floor path (the floor cursor carries no wall-face Y) — defaults to a ~0.9 m sill, mirroring the 2D move. - Shift bypasses the along-wall grid/alignment snap but still attaches to the nearest wall, matching the 3D-hover and 2D-move conventions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): door/window preset placement follows the cursor over open floor The community preset/catalog flow places doors and windows through the isNew move path (MoveDoorTool / MoveWindowTool), which had no free-follow: the fresh clone was parented to the level at the origin and only became visible once the cursor reached a wall, so over empty floor nothing tracked the cursor. Now the move tools mirror the def.tool placement behaviour: - Off-wall, the real node rides the cursor like an item (reparented to the level, positioned at the building-local cursor) so it's obvious what's being placed before it attaches. - Within range of a wall it magnetically snaps on via findClosestWallInPlan (the same plan-space helper the 2D move uses), releasing back to free-follow when the cursor moves away, and plays the grid-snap sound on each new snap. - grid:click commits only when snapped (open floor is a no-op — a door/window needs a wall); the wall/roof mesh-hover paths are unchanged and still own their own click. A per-pointermove timeStamp gate + cameraDragging guard keep the floor handler from fighting a wall/roof hover. - Windows default to a ~0.9m sill while off-wall (fresh preset clones carry position [0,0,0], which buried half the window below the floor). The wall/roof commit body is extracted into a shared commitToWall so the mesh-click and proximity-click paths stay identical. Existing-node moves are fully restored on cancel/unmount (the node stays isTransient through free-follow). Standalone-editor def.tool placement already had this in a prior commit; this brings the community move path to parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): 2D floorplan ghost follows the cursor for door/window placement Mirrors the 3D free-follow in the top-down floor plan: while placing a door or window, a loose footprint rectangle now follows the cursor over open floor so it's obvious what's being placed before it snaps to a wall. The instant the cursor nears a wall, the existing synthesized wall:enter/move path takes over and the real on-wall door/window symbol (swing arc, etc.) replaces the ghost. - The opening-placement pointer-move handler in floorplan-panel sets a new `openingGhostPoint` on the off-wall (findClosestWallPoint miss) branch and clears it on a wall hit; a loose width × 0.1m rectangle renders at that point inside the floor-plan scene group (same world→SVG transform as every glyph). - Width comes from the moving node or the kind default (door 0.9 / window 1.5). - The ghost clears when opening placement ends (tool/mode change, cancel, commit) and on level change, so no stale rectangle lingers. Deliberately a plain rectangle, not the full swing-arc symbol: off-wall there's no host to orient the swing to. The shared door/window def.floorplan builders are untouched — overloading them with a wall-less fallback would make roof-hosted doors (parent is a roof segment, builder returns null today) draw stray rectangles in plan. Keeping the preview in the editor layer avoids that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): faithful 2D door/window ghost, 2D wall-slide, R-flip during placement; 3D snap only on hover Three placement fixes plus a snapping revision, for both the standalone def.tool path and the community isNew move path (door + window): - 2D faithful ghost: the off-wall placement ghost now renders the real blueprint symbol (door swing arc / window panes) following the cursor, not a bare rectangle. Done by publishing a transient opening on a synthetic wall to usePlacementPreview (extended with a `parentNode` fed as the builder's ctx.parent) so the real def.floorplan builder draws it. Cleared on wall-hit, on commit, on placement-inactive, and on level change. - 2D slide-along-wall: the floor-plan registry layer ignored useLiveTransforms for door/window (only floor-placed + slab/ceiling/zone), so a same-wall slide updated the 3D mesh but left the 2D symbol frozen. It now merges the wall-local live position/rotation onto the node (keeping parentId) so the 2D symbol slides with the cursor. - R-flip during placement: pressing R now flips a door/window's facing (front ↔ back, rotation += π) before commit — the placement tools own R while placing (the global selection-based R/T handler stands down via isPlacingOpening so it can't double-fire). No-op on roof faces (front-only). - 3D snapping zero-padding: removed the 1.5 m proximity magnet; in 3D the opening free-follows the cursor over open floor and snaps only when the cursor ray actually hovers a wall/roof mesh (big raycast targets). 2D keeps its 0.5 m findClosestWallPoint padding since plan walls are thin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): make FloorplanRegistryMoveOverlay the sole 2D owner of door/window placement Community door/window placement (movingNode + metadata.isNew) had TWO 2D paths running at once: the floorplan-panel synthesized wall:*/grid:* events (driving the 3D MoveDoorTool) AND FloorplanRegistryMoveOverlay via def.floorplanMoveTarget. They fought — R didn't flip the 2D symbol and clicks didn't commit in 2D, while 3D worked. The overlay + floorplanMoveTarget is the purpose-built 2D owner (faithful def.floorplan symbol, plan-space CTM coords, Figma snap, single-undo commit), so it now owns 2D opening placement when movingNode is set: - floorplan-panel: the opening pointer-move branch + the registry grid catch-all + the background-click catch-all all now exclude the door/window MOVE case (`!isOpeningMoveActive`), so the synthesized events no longer fire for it (they still drive pure raw-build placement, which has no movingNode). Without the catch-all exclusions the move case fell through to grid:move/grid:click, which re-drove the 3D tool's free-follow and consumed the commit click. - R-flip in 2D: `FloorplanMoveTargetSession` gains optional `flipSide()`; door/window floorplan-move implement it (XOR the wall-derived side + π rotation, re-running the last apply). The overlay's keydown calls `session.flipSide()` on R — gated on `hasMovedSinceStart` so it only fires when the 2D pane is the active mover (the 3D MoveDoorTool owns R in 3D/split; this prevents a double flip / double cue on one R press). - Commit in 2D now flows solely through the overlay's pointerup (no competing synthesized wall:click), so click-to-place commits. The global use-keyboard R/T already stands down during opening placement (isPlacingOpening), so a selected node can't also flip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): 2D door/window move free-follows the cursor off-wall and commits only on a wall Moving an existing door/window in the 2D floor plan: the move target's `apply` early-returned off-wall (`if (!hit) return`), so the opening stayed frozen on its old wall instead of following the cursor between walls (3D free-follows), and an off-wall confirm click committed the stale last-wall position — looking like the placement failed. Now `doorFloorplanMoveTarget`/`windowFloorplanMoveTarget` mirror the 3D move: - Off-wall, `apply` free-follows the cursor — hides the real node and floats the faithful door/window symbol at the cursor via a synthetic wall published to `usePlacementPreview` (the same preview layer fresh placement uses). The real node is `visible:false` so the registry layer skips it (no double symbol). - Back on a wall, it clears the ghost, reveals the real node, and snaps as before. - `canCommit` returns false while off-wall, so an open-floor click reverts to the pre-move snapshot (door returns to its wall) instead of committing in mid-air — matching the 3D move, where clicking open floor commits nothing. On a wall the commit lands normally. The overlay's snapshot revert (cancel / invalid commit) and the on-wall `apply`'s `visible:true` restore both guarantee the node is never left hidden after a move. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): 2D opening snap picks the true nearest wall, with a tighter radius The 2D door/window snap felt too aggressive and could grab a wall further away than the one the cursor was actually nearest. Root cause in `findClosestWallInPlan`: it compared a candidate's true segment distance against the previous best's `perpDistance` (signed offset to the wall's infinite line, not the clamped segment distance). Near a wall end those diverge, so a closer wall could be rejected / a farther one kept. - Track the best segment distance and keep the strict minimum — the wall chosen is now always the single closest segment to the cursor (true nearest), which resolves correctly when many walls sit close together. - Tighten the snap radius from 1.5 m to 0.4 m: plan walls are thin, so the old radius snapped from far away. The opening now free-follows the cursor until it's genuinely near a wall. Only the 2D move/placement targets use this helper (3D snaps on raycast hover); wall-attached items share the same improved nearest-wall behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): true-nearest 2D opening snap with dev hit-area overlay Round 7 of the placement-ghosts work. Make the 2D door/window wall snap always pick the wall nearest the cursor, fix fresh-placement snapping/slide in 2D, and add a dev-only overlay that visualises each wall's snap region. - Extract the plan-space nearest-wall-segment math to core (`lib/wall-distance.ts`: collectLevelWallSegments / closestOnSegment / nearestWallSegment / WALL_SNAP_DISTANCE_M). `findClosestWallInPlan` delegates to it, so the snap and the debug overlay share one source of truth. WallHit contract unchanged. - door/window 2D move now resolves the host level via the shared `getOpeningHostLevelId` (wall-hosted, roof-hosted, AND fresh-placement parented straight to the level — the last case previously resolved to the building, so a new opening never snapped in 2D). - Cursor resolver switched to absolute mode: query the snap with the true cursor, not the original-wall position + grab delta, so it picks the cursor-nearest wall (matching the 3D move) instead of a far wall across a thin gap. - 2D move clears any stale `useLiveTransforms` entry for the node each apply: the registry layer renders door/window from the live transform in preference to the scene node, so a leftover entry from the 3D tool froze the 2D slide for fresh / re-armed openings. - Fresh window defaults to a 0.9 m sill in 2D (was sitting half-below floor at y=0), matching the 3D MoveWindowTool. - New dev-only FloorplanVoronoiLayer + `show2dVoronoi` editor flag: draws each wall's snap hit area as an analytic capsule (no grid sampling), gated on a developer-menu toggle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
aafb238545
commit
1628b728fa
@@ -2,16 +2,15 @@ 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, 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,
|
||||
@@ -32,15 +31,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 +43,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 DEFAULT_SILL = 0.9
|
||||
const startLocalY = node.position[1] > 0.1 ? node.position[1] : DEFAULT_SILL + node.height / 2
|
||||
|
||||
// Track the last successful placement so `commit()` can write it
|
||||
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
|
||||
@@ -69,13 +71,72 @@ 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
|
||||
|
||||
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,
|
||||
})
|
||||
const ghost = {
|
||||
...node,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
position: [half, startLocalY, 0] as [number, number, number],
|
||||
rotation: [0, 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 }
|
||||
// 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) {
|
||||
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 +160,17 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
node.height,
|
||||
)
|
||||
|
||||
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,6 +187,9 @@ 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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
@@ -101,6 +102,15 @@ 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
|
||||
// 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
|
||||
@@ -141,6 +151,17 @@ 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
|
||||
// ~0.9m sill so the ghost floats at a realistic height. An existing window
|
||||
// keeps its own sill.
|
||||
const DEFAULT_SILL = 0.9
|
||||
const getSillCenterY = () => {
|
||||
const y = movingWindowNode.position[1]
|
||||
return y > 0.1 ? y : DEFAULT_SILL + movingWindowNode.height / 2
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
@@ -183,9 +204,12 @@ 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 cursorRotation =
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset
|
||||
|
||||
const rawLocalX = event.localPosition[0]
|
||||
const rawLocalY = event.localPosition[1]
|
||||
@@ -287,11 +311,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
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 +325,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 +345,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
|
||||
@@ -398,31 +421,78 @@ 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)
|
||||
if (!target?.valid) 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)
|
||||
}
|
||||
|
||||
// Free-follow: the window rides the cursor over empty floor, parented to
|
||||
// the level like an item node, kept at a sensible sill height. No wall to
|
||||
// attach to, so it is not committable here.
|
||||
const freeFollowAt = (localX: number, localZ: number) => {
|
||||
freeFollowing = true
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
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,
|
||||
})
|
||||
currentHostId = levelId
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [localX, sillCenterY, localZ],
|
||||
rotation: [0, yaw, 0],
|
||||
side: sideOverride,
|
||||
})
|
||||
}
|
||||
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 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
|
||||
freeFollowAt(x, z)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -449,12 +519,14 @@ 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
|
||||
@@ -553,26 +625,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 = () => {
|
||||
@@ -600,13 +659,47 @@ 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.
|
||||
if (lastTarget?.valid && !freeFollowing) {
|
||||
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
|
||||
}
|
||||
const onWall = lastTarget !== null
|
||||
if (!(onWall || freeFollowing)) return
|
||||
e.preventDefault()
|
||||
sideOverride = sideOverride === 'front' ? 'back' : 'front'
|
||||
triggerSFX('sfx:item-rotate')
|
||||
if (onWall) {
|
||||
const next = resolveMoveTarget(lastTarget!.event)
|
||||
if (next) {
|
||||
lastTarget = next
|
||||
applyPreview(next)
|
||||
}
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
side: sideOverride,
|
||||
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
@@ -615,8 +708,10 @@ 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)
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
@@ -653,8 +748,10 @@ 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)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'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 }: { node: WindowNode; invalid?: 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 `invalid`
|
||||
// flips; its cleanup only disposes the clones it made.
|
||||
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid])
|
||||
|
||||
// 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
|
||||
+289
-241
@@ -10,6 +10,7 @@ import {
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ 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 {
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
worldToSelectedBuildingLocal,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import WindowPreview from './preview'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
@@ -46,33 +48,65 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
const FALLBACK_WIDTH = 1.5
|
||||
const FALLBACK_HEIGHT = 1.5
|
||||
const FALLBACK_SILL_LIFT = 0.45
|
||||
// Default sill centre for a window snapped from the floor (the floor cursor
|
||||
// carries no wall-face height). 0.9 m sill + half the 1.5 m default height.
|
||||
const DEFAULT_SILL_CENTER_Y = 0.9 + 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.
|
||||
const [fallbackPose, setFallbackPose] = useState<{
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
} | null>(null)
|
||||
|
||||
const ghostStub = useMemo(
|
||||
() => WindowNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }),
|
||||
[],
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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 +124,7 @@ const WindowTool: React.FC = () => {
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
setFallbackPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
@@ -97,11 +132,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 +149,166 @@ 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]) => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
setFallbackPose({ position, rotationY: 0 })
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
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()
|
||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
||||
}
|
||||
|
||||
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
|
||||
// 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,
|
||||
) => {
|
||||
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,
|
||||
bypassSnap,
|
||||
})
|
||||
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,
|
||||
)
|
||||
event.stopPropagation()
|
||||
const localY = bypassSnap ? rawLocalY : snapToHalf(rawLocalY)
|
||||
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
|
||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
// 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
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||
wall,
|
||||
rawLocalX,
|
||||
rawLocalY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
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()
|
||||
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 +327,109 @@ 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()
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
)
|
||||
if (!valid) 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])
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -437,6 +456,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,
|
||||
@@ -479,6 +500,7 @@ const WindowTool: React.FC = () => {
|
||||
|
||||
const draft = draftRef.current
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -525,42 +547,61 @@ 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), like the
|
||||
// committed-selected R flip. Only meaningful while snapped to a wall (the
|
||||
// off-wall ghost has no orientation), so it acts only then — re-applying
|
||||
// the last wall hover so the snapped preview flips live.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
if (!lastWallEvent) 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')
|
||||
onWallHover(lastWallEvent)
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -570,14 +611,21 @@ const WindowTool: React.FC = () => {
|
||||
boxGeo.dispose()
|
||||
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user