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
@@ -87,6 +87,14 @@ export {
|
|||||||
type Space,
|
type Space,
|
||||||
wallTouchesOthers,
|
wallTouchesOthers,
|
||||||
} from './lib/space-detection'
|
} from './lib/space-detection'
|
||||||
|
export {
|
||||||
|
closestOnSegment,
|
||||||
|
collectLevelWallSegments,
|
||||||
|
nearestWallSegment,
|
||||||
|
WALL_SNAP_DISTANCE_M,
|
||||||
|
type WallSegment,
|
||||||
|
type WallSegmentClosest,
|
||||||
|
} from './lib/wall-distance'
|
||||||
export {
|
export {
|
||||||
getCatalogMaterialById,
|
getCatalogMaterialById,
|
||||||
getLibraryMaterialIdFromRef,
|
getLibraryMaterialIdFromRef,
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { WallNode } from '../schema/nodes/wall'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
|
import { isCurvedWall } from '../systems/wall/wall-curve'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure plan-space wall-distance math shared by the 2D opening snap
|
||||||
|
* (`findClosestWallInPlan` in @pascal-app/nodes) and the editor's 2D
|
||||||
|
* Voronoi debug overlay. One source of truth means the overlay is a
|
||||||
|
* faithful picture of what the snap actually decides.
|
||||||
|
*
|
||||||
|
* A wall segment's Voronoi cell is exactly "the points whose nearest wall
|
||||||
|
* is this segment", so nearest-segment classification == the segment
|
||||||
|
* Voronoi diagram. Curved walls are excluded (the opening snap rejects
|
||||||
|
* them — mitering + arc + opening tears in 3D).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max cursor-to-wall plan distance (metres) for a 2D opening to snap onto a
|
||||||
|
* wall. Tight, because plan walls are thin and often close together — a large
|
||||||
|
* radius would let a far wall's region reach across a nearer one. Shared so the
|
||||||
|
* snap and the Voronoi debug overlay clip to the exact same range.
|
||||||
|
*/
|
||||||
|
export const WALL_SNAP_DISTANCE_M = 0.4
|
||||||
|
|
||||||
|
export type WallSegment = {
|
||||||
|
wall: WallNode
|
||||||
|
/** [x, z] plan start. */
|
||||||
|
start: readonly [number, number]
|
||||||
|
/** [x, z] plan end. */
|
||||||
|
end: readonly [number, number]
|
||||||
|
/** Unit direction (start → end) in plan. */
|
||||||
|
dirX: number
|
||||||
|
dirY: number
|
||||||
|
/** Segment length in metres. */
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WallSegmentClosest = {
|
||||||
|
segment: WallSegment
|
||||||
|
/** Distance from the query point to the closest point on the segment. */
|
||||||
|
distance: number
|
||||||
|
/** Distance along the wall from `start`, clamped to [0, length]. */
|
||||||
|
along: number
|
||||||
|
/** Signed perpendicular offset from the wall axis (+ on the front side). */
|
||||||
|
perp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the straight (non-curved) wall segments that are direct children
|
||||||
|
* of a level — the candidates an opening can snap onto.
|
||||||
|
*/
|
||||||
|
export function collectLevelWallSegments(
|
||||||
|
nodes: Record<AnyNodeId, AnyNode>,
|
||||||
|
levelId: AnyNodeId | null,
|
||||||
|
): WallSegment[] {
|
||||||
|
if (!levelId) return []
|
||||||
|
const level = nodes[levelId]
|
||||||
|
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
|
||||||
|
if (!Array.isArray(childIds)) return []
|
||||||
|
|
||||||
|
const segments: WallSegment[] = []
|
||||||
|
for (const childId of childIds) {
|
||||||
|
const node = nodes[childId]
|
||||||
|
if (node?.type !== 'wall') continue
|
||||||
|
const wall = node as WallNode
|
||||||
|
if (isCurvedWall(wall)) continue
|
||||||
|
const dx = wall.end[0] - wall.start[0]
|
||||||
|
const dy = wall.end[1] - wall.start[1]
|
||||||
|
const length = Math.hypot(dx, dy)
|
||||||
|
if (length < 1e-6) continue
|
||||||
|
segments.push({
|
||||||
|
wall,
|
||||||
|
start: wall.start,
|
||||||
|
end: wall.end,
|
||||||
|
dirX: dx / length,
|
||||||
|
dirY: dy / length,
|
||||||
|
length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closest point + signed offset of one query point against one segment. */
|
||||||
|
export function closestOnSegment(
|
||||||
|
segment: WallSegment,
|
||||||
|
pointX: number,
|
||||||
|
pointY: number,
|
||||||
|
): { distance: number; along: number; perp: number } {
|
||||||
|
const px = pointX - segment.start[0]
|
||||||
|
const py = pointY - segment.start[1]
|
||||||
|
const along = Math.max(0, Math.min(segment.length, px * segment.dirX + py * segment.dirY))
|
||||||
|
const perp = px * -segment.dirY + py * segment.dirX
|
||||||
|
const closestX = segment.start[0] + segment.dirX * along
|
||||||
|
const closestY = segment.start[1] + segment.dirY * along
|
||||||
|
const distance = Math.hypot(pointX - closestX, pointY - closestY)
|
||||||
|
return { distance, along, perp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single nearest wall segment to a plan point — its Voronoi cell. Returns
|
||||||
|
* null when `segments` is empty or (when `maxDistance` is given) nothing is
|
||||||
|
* within range. Ties resolve to the first segment scanned; callers pass an
|
||||||
|
* already-curved-filtered list from `collectLevelWallSegments`.
|
||||||
|
*/
|
||||||
|
export function nearestWallSegment(
|
||||||
|
segments: readonly WallSegment[],
|
||||||
|
pointX: number,
|
||||||
|
pointY: number,
|
||||||
|
maxDistance = Number.POSITIVE_INFINITY,
|
||||||
|
excludeWallId?: AnyNodeId,
|
||||||
|
): WallSegmentClosest | null {
|
||||||
|
let best: WallSegmentClosest | null = null
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (excludeWallId && segment.wall.id === excludeWallId) continue
|
||||||
|
const { distance, along, perp } = closestOnSegment(segment, pointX, pointY)
|
||||||
|
if (distance > maxDistance) continue
|
||||||
|
if (best && distance >= best.distance) continue
|
||||||
|
best = { segment, distance, along, perp }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
@@ -616,6 +616,14 @@ export type FloorplanMoveTargetSession = {
|
|||||||
* returns.
|
* returns.
|
||||||
*/
|
*/
|
||||||
commit?(): void
|
commit?(): void
|
||||||
|
/**
|
||||||
|
* Optional R-key flip toggle. Kinds with a directional facing
|
||||||
|
* (door / window: front ↔ back) implement this so the overlay can flip
|
||||||
|
* the orientation mid-placement before commit. Toggling just records the
|
||||||
|
* intent; the visible change lands when the overlay re-runs `apply()` with
|
||||||
|
* the last pointer position. Kinds with no facing leave it unset.
|
||||||
|
*/
|
||||||
|
flipSide?(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FloorplanMoveTarget<N> = (args: {
|
export type FloorplanMoveTarget<N> = (args: {
|
||||||
|
|||||||
@@ -283,6 +283,27 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
// R flips a directional kind's facing mid-placement (door / window:
|
||||||
|
// front ↔ back). The session records the flip and re-runs its last
|
||||||
|
// apply so the 2D symbol updates immediately; kinds without a facing
|
||||||
|
// leave `flipSide` unset and R falls through to the global handler.
|
||||||
|
//
|
||||||
|
// Only when THIS overlay is the active mover — i.e. the user has moved
|
||||||
|
// the pointer over the 2D pane (`hasMovedSinceStart`). The 3D move tool
|
||||||
|
// also listens for R while a door/window `movingNode` is placed (split
|
||||||
|
// / 3D-only view); gating on `hasMovedSinceStart` keeps the two from
|
||||||
|
// both flipping (or double-playing the cue) on a single R press.
|
||||||
|
if (event.key === 'r' || event.key === 'R') {
|
||||||
|
if (!(session.flipSide && hasMovedSinceStart)) return
|
||||||
|
const t = event.target as HTMLElement | null
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
session.flipSide()
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.key !== 'Escape') return
|
if (event.key !== 'Escape') return
|
||||||
// Claim teardown ownership so the 3D move tool's cleanup skips
|
// Claim teardown ownership so the 3D move tool's cleanup skips
|
||||||
// its own restore — without this, both sides would race to
|
// its own restore — without this, both sides would race to
|
||||||
|
|||||||
+4
-1
@@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
|||||||
*/
|
*/
|
||||||
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
|
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
|
||||||
const node = usePlacementPreview((s) => s.node)
|
const node = usePlacementPreview((s) => s.node)
|
||||||
|
const parentNode = usePlacementPreview((s) => s.parentNode)
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
|
|
||||||
const builder = nodeRegistry.get(node.type)?.floorplan
|
const builder = nodeRegistry.get(node.type)?.floorplan
|
||||||
@@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
|
|||||||
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
|
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
|
||||||
// and `parent: null` short-circuits the elevator's level walk) so the layer
|
// and `parent: null` short-circuits the elevator's level walk) so the layer
|
||||||
// never subscribes to / bulk-reads the nodes map during render.
|
// never subscribes to / bulk-reads the nodes map during render.
|
||||||
|
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
|
||||||
|
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
|
||||||
const ctx = {
|
const ctx = {
|
||||||
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
|
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
|
||||||
children: [],
|
children: [],
|
||||||
siblings: [],
|
siblings: [],
|
||||||
parent: null,
|
parent: parentNode ?? null,
|
||||||
viewState: undefined,
|
viewState: undefined,
|
||||||
} as unknown as GeometryContext
|
} as unknown as GeometryContext
|
||||||
|
|
||||||
|
|||||||
@@ -506,7 +506,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
if (live) {
|
if (live) {
|
||||||
const floorPlaced = def?.capabilities?.floorPlaced
|
const floorPlaced = def?.capabilities?.floorPlaced
|
||||||
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
||||||
if (floorPlaced && hasPosition) {
|
if (node.type === 'door' || node.type === 'window') {
|
||||||
|
// Door / window movers publish WALL-LOCAL live transforms
|
||||||
|
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
|
||||||
|
// wiki/architecture/tools.md. The mover only writes
|
||||||
|
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
|
||||||
|
// updates the 3D mesh imperatively but never the scene node —
|
||||||
|
// without applying the live transform here the 2D symbol stays
|
||||||
|
// frozen while the cursor slides. Merge the wall-local position +
|
||||||
|
// rotation onto the node but KEEP `parentId` (the wall) so
|
||||||
|
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
|
||||||
|
// real swing-arc / pane symbol at the live spot.
|
||||||
|
const r = (node as { rotation?: unknown }).rotation
|
||||||
|
effectiveNode = {
|
||||||
|
...node,
|
||||||
|
position: live.position,
|
||||||
|
rotation: Array.isArray(r)
|
||||||
|
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
||||||
|
: r,
|
||||||
|
} as AnyNode
|
||||||
|
} else if (floorPlaced && hasPosition) {
|
||||||
effectiveNode = applyPositionLiveTransform(node, live)
|
effectiveNode = applyPositionLiveTransform(node, live)
|
||||||
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
||||||
const dx = live.position[0]
|
const dx = live.position[0]
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { collectLevelWallSegments, useScene, WALL_SNAP_DISTANCE_M } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only 2D debug overlay for the opening (door / window) wall snap.
|
||||||
|
*
|
||||||
|
* The snap (`findClosestWallInPlan`) attaches to a wall when the cursor is
|
||||||
|
* within `WALL_SNAP_DISTANCE_M` of the wall's centerline, picking the
|
||||||
|
* nearest such wall. The set of points within that radius of a segment is a
|
||||||
|
* **capsule** (stadium): a band of half-width = the snap radius along the
|
||||||
|
* wall, with semicircular caps at each end. That capsule IS the wall's
|
||||||
|
* (normally invisible) hit target — so this layer draws it directly, one
|
||||||
|
* analytic `<path>` per wall, instead of sampling a grid (which produced the
|
||||||
|
* stair-stepped boundary the previous version showed). No per-point
|
||||||
|
* classification, so it's cheap regardless of plan size.
|
||||||
|
*
|
||||||
|
* Where two walls sit closer than 2× the radius their capsules overlap; the
|
||||||
|
* snap resolves the overlap to the nearer wall (the translucent fills just
|
||||||
|
* blend there — a darker patch reads as "either wall is in reach, nearest
|
||||||
|
* wins"). Drawing the true bisector-clipped cells would need the expensive
|
||||||
|
* per-point pass this rewrite removes, and the hit-area view is what the
|
||||||
|
* user asked for.
|
||||||
|
*
|
||||||
|
* Gated on `useEditor.show2dVoronoi` (developer menu). Renders inside the
|
||||||
|
* floor-plan scene `<g>`, so it shares the plan→SVG transform and the scene
|
||||||
|
* rotation with every other floor-plan layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Stable hue per wall id so a wall keeps its colour across re-renders. */
|
||||||
|
function wallHue(id: string): number {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < id.length; i++) {
|
||||||
|
hash = (hash * 31 + id.charCodeAt(i)) >>> 0
|
||||||
|
}
|
||||||
|
// Spread hues around the wheel with an offset that avoids a muddy
|
||||||
|
// red-orange clump for short, similar ids.
|
||||||
|
return (hash * 47) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloorplanVoronoiLayer = memo(function FloorplanVoronoiLayer() {
|
||||||
|
const show2dVoronoi = useEditor((s) => s.show2dVoronoi)
|
||||||
|
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
||||||
|
// Recompute only when the wall geometry on this level changes — not on
|
||||||
|
// every scene write. Dragging a door updates the door node every frame;
|
||||||
|
// keying the build on this string means the capsule paths don't rebuild
|
||||||
|
// for openings, only for wall edits.
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const wallsKey = useMemo(() => {
|
||||||
|
if (!show2dVoronoi || !selectedLevelId) return ''
|
||||||
|
const segments = collectLevelWallSegments(nodes, selectedLevelId)
|
||||||
|
return segments
|
||||||
|
.map((s) => `${s.wall.id}:${s.start[0]},${s.start[1]},${s.end[0]},${s.end[1]}`)
|
||||||
|
.join('|')
|
||||||
|
}, [show2dVoronoi, selectedLevelId, nodes])
|
||||||
|
|
||||||
|
const walls = useMemo(() => {
|
||||||
|
if (!show2dVoronoi || !selectedLevelId || !wallsKey) return null
|
||||||
|
// Read nodes imperatively: `wallsKey` already encodes every wall change,
|
||||||
|
// so this memo is keyed on it rather than on the per-frame `nodes` ref.
|
||||||
|
const segments = collectLevelWallSegments(useScene.getState().nodes, selectedLevelId)
|
||||||
|
if (segments.length === 0) return null
|
||||||
|
|
||||||
|
const R = WALL_SNAP_DISTANCE_M
|
||||||
|
const r = R.toFixed(3)
|
||||||
|
return segments.map((s) => {
|
||||||
|
// Capsule outline. Normal = dir rotated +90° = (-dirY, dirX). Offset the
|
||||||
|
// segment endpoints ±R along the normal for the long sides, then a
|
||||||
|
// semicircular cap (radius R, sweep-flag 0 bulges outward past each end)
|
||||||
|
// joins them. Verified winding holds for every orientation because the
|
||||||
|
// whole construction is a rigid transform of the axis-aligned case.
|
||||||
|
const nx = -s.dirY
|
||||||
|
const ny = s.dirX
|
||||||
|
const ax = (s.start[0] + nx * R).toFixed(3)
|
||||||
|
const ay = (s.start[1] + ny * R).toFixed(3)
|
||||||
|
const bx = (s.end[0] + nx * R).toFixed(3)
|
||||||
|
const by = (s.end[1] + ny * R).toFixed(3)
|
||||||
|
const cx = (s.end[0] - nx * R).toFixed(3)
|
||||||
|
const cy = (s.end[1] - ny * R).toFixed(3)
|
||||||
|
const dx = (s.start[0] - nx * R).toFixed(3)
|
||||||
|
const dy = (s.start[1] - ny * R).toFixed(3)
|
||||||
|
const d = `M${ax} ${ay}L${bx} ${by}A${r} ${r} 0 0 0 ${cx} ${cy}L${dx} ${dy}A${r} ${r} 0 0 0 ${ax} ${ay}Z`
|
||||||
|
return {
|
||||||
|
wallId: s.wall.id,
|
||||||
|
d,
|
||||||
|
hue: wallHue(s.wall.id),
|
||||||
|
x1: s.start[0],
|
||||||
|
y1: s.start[1],
|
||||||
|
x2: s.end[0],
|
||||||
|
y2: s.end[1],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [show2dVoronoi, selectedLevelId, wallsKey])
|
||||||
|
|
||||||
|
if (!walls) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g className="floorplan-voronoi-debug" pointerEvents="none">
|
||||||
|
{walls.map(({ wallId, d, hue }) => (
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
fill={`hsla(${hue}, 80%, 55%, 0.22)`}
|
||||||
|
key={`hit-${wallId}`}
|
||||||
|
stroke={`hsl(${hue}, 85%, 50%)`}
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
strokeWidth={1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{walls.map(({ wallId, hue, x1, y1, x2, y2 }) => (
|
||||||
|
<line
|
||||||
|
key={`line-${wallId}`}
|
||||||
|
stroke={`hsl(${hue}, 85%, 42%)`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
x1={x1}
|
||||||
|
x2={x2}
|
||||||
|
y1={y1}
|
||||||
|
y2={y2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
calculateLevelMiters,
|
calculateLevelMiters,
|
||||||
DEFAULT_ANGLE_STEP,
|
DEFAULT_ANGLE_STEP,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
|
DoorNode as DoorNodeSchema,
|
||||||
type ElevatorNode,
|
type ElevatorNode,
|
||||||
emitter,
|
emitter,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
@@ -45,7 +46,9 @@ import {
|
|||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
WallNode as WallNodeSchema,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
|
WindowNode as WindowNodeSchema,
|
||||||
ZoneNode as ZoneNodeSchema,
|
ZoneNode as ZoneNodeSchema,
|
||||||
type ZoneNode as ZoneNodeType,
|
type ZoneNode as ZoneNodeType,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -85,6 +88,7 @@ import { cn } from '../../lib/utils'
|
|||||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||||
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
|
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
|
||||||
|
import usePlacementPreview from '../../store/use-placement-preview'
|
||||||
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||||
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
||||||
@@ -102,6 +106,7 @@ import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-
|
|||||||
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
|
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
|
||||||
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
|
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
|
||||||
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
||||||
|
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
|
||||||
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
||||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
import { snapToHalf } from '../tools/item/placement-math'
|
import { snapToHalf } from '../tools/item/placement-math'
|
||||||
@@ -5501,6 +5506,70 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
return 0
|
return 0
|
||||||
}, [isWindowBuildActive, movingNode, shiftPressed])
|
}, [isWindowBuildActive, movingNode, shiftPressed])
|
||||||
|
// Float the faithful door/window symbol at the cursor while it isn't over a
|
||||||
|
// wall (the off-wall placement ghost), by publishing a transient opening on a
|
||||||
|
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
|
||||||
|
// renders it through the real `def.floorplan` builder (swing arc / panes), so
|
||||||
|
// it reads as a real door/window, not a bare rectangle. Off any wall there's
|
||||||
|
// no orientation to inherit, so the synthetic wall runs along plan-X.
|
||||||
|
const showOpeningGhost = useCallback(
|
||||||
|
(planPoint: WallPlanPoint) => {
|
||||||
|
const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType)
|
||||||
|
// Synthetic wall centred at the cursor; the opening sits at its midpoint.
|
||||||
|
const half =
|
||||||
|
(isDoor
|
||||||
|
? movingNode?.type === 'door'
|
||||||
|
? movingNode.width
|
||||||
|
: 0.9
|
||||||
|
: movingNode?.type === 'window'
|
||||||
|
? movingNode.width
|
||||||
|
: 1.5) /
|
||||||
|
2 +
|
||||||
|
0.5
|
||||||
|
const wall = WallNodeSchema.parse({
|
||||||
|
start: [planPoint[0] - half, planPoint[1]],
|
||||||
|
end: [planPoint[0] + half, planPoint[1]],
|
||||||
|
thickness: 0.1,
|
||||||
|
})
|
||||||
|
// Clone the moving opening (carries width / type / hinge / swing) onto the
|
||||||
|
// synthetic wall, or parse a default for build mode. position[0] = the
|
||||||
|
// along-wall midpoint so the symbol centres on the cursor.
|
||||||
|
const base =
|
||||||
|
movingNode?.type === 'door' || movingNode?.type === 'window'
|
||||||
|
? { ...movingNode }
|
||||||
|
: isDoor
|
||||||
|
? DoorNodeSchema.parse({})
|
||||||
|
: WindowNodeSchema.parse({})
|
||||||
|
const ghost = {
|
||||||
|
...base,
|
||||||
|
parentId: wall.id,
|
||||||
|
wallId: wall.id,
|
||||||
|
roofSegmentId: undefined,
|
||||||
|
roofFace: undefined,
|
||||||
|
position: [half, floorplanOpeningLocalY, 0] as [number, number, number],
|
||||||
|
rotation: [0, 0, 0] as [number, number, number],
|
||||||
|
} as AnyNode
|
||||||
|
usePlacementPreview.getState().set(ghost, wall)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
DoorNodeSchema,
|
||||||
|
WallNodeSchema,
|
||||||
|
WindowNodeSchema,
|
||||||
|
floorplanOpeningLocalY,
|
||||||
|
isDoorBuildActive,
|
||||||
|
movingNode,
|
||||||
|
movingOpeningType,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
// Drop the floating opening ghost whenever opening placement ends (commit,
|
||||||
|
// tool change, mode switch, cancel) or the active level changes, so a stale
|
||||||
|
// ghost never lingers on the wrong level.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
|
||||||
|
}, [isOpeningPlacementActive])
|
||||||
|
useEffect(() => {
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
|
}, [levelId])
|
||||||
const isMarqueeSelectionToolActive =
|
const isMarqueeSelectionToolActive =
|
||||||
mode === 'select' &&
|
mode === 'select' &&
|
||||||
floorplanSelectionTool === 'marquee' &&
|
floorplanSelectionTool === 'marquee' &&
|
||||||
@@ -8568,7 +8637,16 @@ export function FloorplanPanel({
|
|||||||
// `wall:move` events the door / window placement tools listen for.
|
// `wall:move` events the door / window placement tools listen for.
|
||||||
// Same reason `handleBackgroundPlacementClick` runs its opening
|
// Same reason `handleBackgroundPlacementClick` runs its opening
|
||||||
// branch before its grid catch-all.
|
// branch before its grid catch-all.
|
||||||
if (isOpeningPlacementActive) {
|
//
|
||||||
|
// Only the pure BUILD case (a door/window tool armed with no
|
||||||
|
// `movingNode`) drives placement through these synthesized `wall:*`
|
||||||
|
// events. When a door/window `movingNode` is set — the community
|
||||||
|
// preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D
|
||||||
|
// placement end-to-end via `def.floorplanMoveTarget` (faithful symbol,
|
||||||
|
// plan-space snap, single-undo commit, R-flip). Running both at once
|
||||||
|
// made them fight (R-flip overwritten on the next move, click-commit
|
||||||
|
// dropped), so the move case is excluded here.
|
||||||
|
if (isOpeningBuildActive && !isOpeningMoveActive) {
|
||||||
const closest = findClosestWallPoint(planPoint, walls, {
|
const closest = findClosestWallPoint(planPoint, walls, {
|
||||||
canUseWall: (wall) => !isCurvedWall(wall),
|
canUseWall: (wall) => !isCurvedWall(wall),
|
||||||
})
|
})
|
||||||
@@ -8595,9 +8673,23 @@ export function FloorplanPanel({
|
|||||||
} else {
|
} else {
|
||||||
emitter.emit('wall:move', wallEvent as any)
|
emitter.emit('wall:move', wallEvent as any)
|
||||||
}
|
}
|
||||||
} else if (hoveredWallIdRef.current) {
|
// Snapped to a wall — the real on-wall draft is the preview; drop
|
||||||
emitFloorplanWallLeave(hoveredWallIdRef.current)
|
// the loose free-follow ghost.
|
||||||
hoveredWallIdRef.current = null
|
usePlacementPreview.getState().clear()
|
||||||
|
} else {
|
||||||
|
if (hoveredWallIdRef.current) {
|
||||||
|
emitFloorplanWallLeave(hoveredWallIdRef.current)
|
||||||
|
hoveredWallIdRef.current = null
|
||||||
|
}
|
||||||
|
// Off any wall — float the FAITHFUL door/window symbol (swing arc /
|
||||||
|
// panes) following the cursor, not a bare rectangle. The glyph
|
||||||
|
// builder needs a wall for `ctx.parent`, so we publish the opening on
|
||||||
|
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
|
||||||
|
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
|
||||||
|
// through the real `def.floorplan` builder. Shift bypasses grid snap.
|
||||||
|
const snappedPoint =
|
||||||
|
shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||||
|
showOpeningGhost(snappedPoint)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -8622,7 +8714,13 @@ export function FloorplanPanel({
|
|||||||
// window are also registered kinds, but need wall events — see
|
// window are also registered kinds, but need wall events — see
|
||||||
// comment there). Wall build skips this so its own branch below
|
// comment there). Wall build skips this so its own branch below
|
||||||
// updates local `draftEnd` state alongside the registry tool.
|
// updates local `draftEnd` state alongside the registry tool.
|
||||||
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
|
//
|
||||||
|
// A door/window MOVE (community preset) is owned by
|
||||||
|
// `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true
|
||||||
|
// for it (build mode + a registered `door`/`window` tool), so without
|
||||||
|
// this exclusion the catch-all would emit `grid:move` and re-drive the
|
||||||
|
// 3D MoveDoorTool's free-follow, fighting the overlay again.
|
||||||
|
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
|
||||||
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||||
setCursorPoint((previousPoint) =>
|
setCursorPoint((previousPoint) =>
|
||||||
@@ -8969,8 +9067,15 @@ export function FloorplanPanel({
|
|||||||
isCeilingBuildActive,
|
isCeilingBuildActive,
|
||||||
isCeilingItemPlacementActive,
|
isCeilingItemPlacementActive,
|
||||||
isFenceBuildActive,
|
isFenceBuildActive,
|
||||||
isFloorplanGridInteractionActive,
|
// Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the
|
||||||
isOpeningPlacementActive,
|
// grid catch-all true for it, but the overlay owns its commit (its own
|
||||||
|
// pointerup). Letting the catch-all emit `grid:click` here would consume
|
||||||
|
// the commit click and fight the overlay.
|
||||||
|
isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive,
|
||||||
|
// Only the pure-build opening case (tool armed, no movingNode) commits via
|
||||||
|
// the synthesized `wall:click`; the move case (community preset) is owned
|
||||||
|
// by FloorplanRegistryMoveOverlay, which commits on its own pointerup.
|
||||||
|
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
|
||||||
isPolygonBuildActive,
|
isPolygonBuildActive,
|
||||||
isRoofBuildActive,
|
isRoofBuildActive,
|
||||||
isSlabBuildActive,
|
isSlabBuildActive,
|
||||||
@@ -10412,6 +10517,13 @@ export function FloorplanPanel({
|
|||||||
showGrid={showGrid}
|
showGrid={showGrid}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Dev-only: draw each wall's opening-snap hit area (the
|
||||||
|
capsule of points within the snap radius of its centerline).
|
||||||
|
Gated on the developer-menu toggle. Painted right after the
|
||||||
|
grid so the translucent capsules sit under the wall / opening
|
||||||
|
glyphs. */}
|
||||||
|
<FloorplanVoronoiLayer />
|
||||||
|
|
||||||
<FloorplanReferenceFloorLayer
|
<FloorplanReferenceFloorLayer
|
||||||
data={referenceFloorData}
|
data={referenceFloorData}
|
||||||
opacity={referenceFloorOpacity}
|
opacity={referenceFloorOpacity}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
|||||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
|
import usePlacementPreview from '../../store/use-placement-preview'
|
||||||
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
||||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||||
@@ -152,6 +153,9 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
stopPropagation: () => {},
|
stopPropagation: () => {},
|
||||||
} as any)
|
} as any)
|
||||||
}
|
}
|
||||||
|
// Drop the off-wall ghost on commit so it doesn't linger at the
|
||||||
|
// just-placed spot before the next pointer move re-evaluates.
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export const useKeyboard = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True while a door/window is being placed: either a fresh clone is moving
|
||||||
|
// (preset / duplicate path) or a door/window build tool is armed. The
|
||||||
|
// placement tool owns R/T then (flip the draft before commit), so the
|
||||||
|
// global selection-based R/T handler must stand down to avoid double-firing.
|
||||||
|
const isPlacingOpening = () => {
|
||||||
|
const ed = useEditor.getState()
|
||||||
|
if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true
|
||||||
|
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
|
||||||
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Don't handle shortcuts if user is typing in an input
|
// Don't handle shortcuts if user is typing in an input
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
@@ -171,11 +181,16 @@ export const useKeyboard = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) {
|
||||||
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
||||||
// Doors use R to flip side (front ↔ back, rotation += π); their
|
// Doors use R to flip side (front ↔ back, rotation += π); their
|
||||||
// open/close toggle lives on E. Windows still use R to toggle
|
// open/close toggle lives on E. Windows still use R to toggle
|
||||||
// their open/closed state.
|
// their open/closed state.
|
||||||
|
//
|
||||||
|
// Skipped entirely while a door/window placement is active
|
||||||
|
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
|
||||||
|
// before commit), and the user can have a node selected at the same
|
||||||
|
// time — without this guard both would fire (double flip + sfx).
|
||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedNodeIds.length === 1) {
|
if (selectedNodeIds.length === 1) {
|
||||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||||
@@ -225,7 +240,7 @@ export const useKeyboard = ({
|
|||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
|
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
|
||||||
// Rotate selected node counter-clockwise
|
// Rotate selected node counter-clockwise
|
||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedNodeIds.length === 1) {
|
if (selectedNodeIds.length === 1) {
|
||||||
|
|||||||
@@ -369,6 +369,11 @@ type EditorState = {
|
|||||||
// Development-only camera debug flag for inspecting underside geometry
|
// Development-only camera debug flag for inspecting underside geometry
|
||||||
allowUndergroundCamera: boolean
|
allowUndergroundCamera: boolean
|
||||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||||
|
// Development-only debug overlay: draw each wall's opening-snap hit area
|
||||||
|
// (the capsule of points within the snap radius of its centerline). Lets us
|
||||||
|
// see why a door/window snaps where it does.
|
||||||
|
show2dVoronoi: boolean
|
||||||
|
setShow2dVoronoi: (enabled: boolean) => void
|
||||||
// First-person walkthrough mode (street view)
|
// First-person walkthrough mode (street view)
|
||||||
isFirstPersonMode: boolean
|
isFirstPersonMode: boolean
|
||||||
_viewModeBeforeFirstPerson: ViewMode | null
|
_viewModeBeforeFirstPerson: ViewMode | null
|
||||||
@@ -955,6 +960,8 @@ const useEditor = create<EditorState>()(
|
|||||||
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
|
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
|
||||||
allowUndergroundCamera: false,
|
allowUndergroundCamera: false,
|
||||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||||
|
show2dVoronoi: false,
|
||||||
|
setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
|
||||||
isFirstPersonMode: false,
|
isFirstPersonMode: false,
|
||||||
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
||||||
setFirstPersonMode: (enabled) => {
|
setFirstPersonMode: (enabled) => {
|
||||||
|
|||||||
@@ -18,14 +18,22 @@ type PlacementPreviewState = {
|
|||||||
/** Transient preview node, already positioned + rotated at the (snapped,
|
/** Transient preview node, already positioned + rotated at the (snapped,
|
||||||
* aligned) cursor. `null` when no placement is active. */
|
* aligned) cursor. `null` when no placement is active. */
|
||||||
node: AnyNode | null
|
node: AnyNode | null
|
||||||
set(node: AnyNode | null): void
|
/** Optional synthetic parent for the preview's `def.floorplan` context.
|
||||||
|
* Door / window glyph builders need `ctx.parent` to be a wall to draw their
|
||||||
|
* real symbol (swing arc / panes); off any real wall we hand them a
|
||||||
|
* synthetic wall segment centred at the cursor so the floating ghost shows
|
||||||
|
* the faithful blueprint symbol instead of a bare rectangle. `null` for
|
||||||
|
* self-contained kinds (column / elevator). */
|
||||||
|
parentNode: AnyNode | null
|
||||||
|
set(node: AnyNode | null, parentNode?: AnyNode | null): void
|
||||||
clear(): void
|
clear(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const usePlacementPreview = create<PlacementPreviewState>((set) => ({
|
const usePlacementPreview = create<PlacementPreviewState>((set) => ({
|
||||||
node: null,
|
node: null,
|
||||||
set: (node) => set({ node }),
|
parentNode: null,
|
||||||
clear: () => set({ node: null }),
|
set: (node, parentNode = null) => set({ node, parentNode }),
|
||||||
|
clear: () => set({ node: null, parentNode: null }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export default usePlacementPreview
|
export default usePlacementPreview
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildBoxVentGeometry } from './geometry'
|
import { buildBoxVentGeometry } from './geometry'
|
||||||
import type { BoxVentNode } from './schema'
|
import type { BoxVentNode } from './schema'
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ import type { BoxVentNode } from './schema'
|
|||||||
* leaving raycast active would cause the preview itself to intercept
|
* leaving raycast active would cause the preview itself to intercept
|
||||||
* the cursor ray and starve the placement tool of `roof:move` events.
|
* the cursor ray and starve the placement tool of `roof:move` events.
|
||||||
*/
|
*/
|
||||||
const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolean }) => {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildBoxVentGeometry(node),
|
() => buildBoxVentGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
|
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
|
||||||
@@ -24,17 +25,17 @@ const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
|||||||
const material = useMemo(
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: 0xff_ff_ff,
|
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissive: 0x6c_a3_ff,
|
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||||
emissiveIntensity: 0.18,
|
emissiveIntensity: 0.18,
|
||||||
roughness: 0.85,
|
roughness: 0.85,
|
||||||
metalness: 0.05,
|
metalness: 0.05,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.35,
|
opacity: invalid ? 0.4 : 0.35,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
}),
|
}),
|
||||||
[],
|
[invalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|||||||
@@ -127,11 +127,11 @@ const BoxVentTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<BoxVentPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
}}
|
}}
|
||||||
size={[0.6, 0.4, 0.6]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
<group position={previewPos}>
|
<group position={previewPos}>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { ChimneyNode, RoofSegmentNode } from '@pascal-app/core'
|
import {
|
||||||
|
type ChimneyNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
RoofSegmentNode as RoofSegmentSchema,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildChimneyGeometry } from './geometry'
|
import { buildChimneyGeometry } from './geometry'
|
||||||
|
|
||||||
const ghostMaterial = new THREE.MeshStandardMaterial({
|
const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||||
@@ -15,21 +20,42 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: INVALID_GHOST_COLOR,
|
||||||
|
emissive: INVALID_GHOST_COLOR,
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
roughness: 0.85,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preview needs a segment fixture to build the body height. The
|
* The preview needs a segment fixture to build the body height. The
|
||||||
* placement tool passes the segment under the cursor; before any
|
* placement tool passes the segment under the cursor; when floating
|
||||||
* segment is hit, the preview isn't shown at all (the tool guards on
|
* (off-roof fallback), segment is absent — build against RoofSegmentNode
|
||||||
* `previewPos`).
|
* defaults so the ghost renders flat at the grid position with yaw 0.
|
||||||
*/
|
*/
|
||||||
const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSegmentNode }) => {
|
const ChimneyPreview = ({
|
||||||
|
node,
|
||||||
|
segment,
|
||||||
|
invalid,
|
||||||
|
}: {
|
||||||
|
node: ChimneyNode
|
||||||
|
segment?: RoofSegmentNode
|
||||||
|
invalid?: boolean
|
||||||
|
}) => {
|
||||||
|
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||||
|
const effectiveSegment = segment ?? RoofSegmentSchema.parse({})
|
||||||
|
|
||||||
const geo = useMemo(
|
const geo = useMemo(
|
||||||
() => buildChimneyGeometry(node, segment),
|
() => buildChimneyGeometry(node, effectiveSegment),
|
||||||
[
|
[
|
||||||
segment.wallHeight,
|
effectiveSegment.wallHeight,
|
||||||
segment.pitch,
|
effectiveSegment.pitch,
|
||||||
segment.roofType,
|
effectiveSegment.roofType,
|
||||||
segment.width,
|
effectiveSegment.width,
|
||||||
segment.depth,
|
effectiveSegment.depth,
|
||||||
node.width,
|
node.width,
|
||||||
node.depth,
|
node.depth,
|
||||||
node.heightAboveRidge,
|
node.heightAboveRidge,
|
||||||
@@ -68,16 +94,10 @@ const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSeg
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh
|
<mesh geometry={geo.body} material={material} raycast={() => {}} />
|
||||||
geometry={geo.body}
|
{geo.cap && <mesh geometry={geo.cap} material={material} raycast={() => {}} />}
|
||||||
material={ghostMaterial}
|
{geo.flues && <mesh geometry={geo.flues} material={material} raycast={() => {}} />}
|
||||||
raycast={() => {
|
{geo.cricket && <mesh geometry={geo.cricket} material={material} raycast={() => {}} />}
|
||||||
/* preview should not intercept the cursor */
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{geo.cap && <mesh geometry={geo.cap} material={ghostMaterial} raycast={() => {}} />}
|
|
||||||
{geo.flues && <mesh geometry={geo.flues} material={ghostMaterial} raycast={() => {}} />}
|
|
||||||
{geo.cricket && <mesh geometry={geo.cricket} material={ghostMaterial} raycast={() => {}} />}
|
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,12 +144,12 @@ const ChimneyTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<ChimneyPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setSegmentXform(null)
|
setSegmentXform(null)
|
||||||
setHitLocal(null)
|
setHitLocal(null)
|
||||||
setPreviewSegment(null)
|
setPreviewSegment(null)
|
||||||
}}
|
}}
|
||||||
size={[1, 2.5, 1]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && segmentXform && hitLocal && previewSegment && (
|
{activeBuildingId && segmentXform && hitLocal && previewSegment && (
|
||||||
// Outer group mirrors the real renderer's `position={segment.position}
|
// Outer group mirrors the real renderer's `position={segment.position}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildCupolaGeometry } from './geometry'
|
import { buildCupolaGeometry } from './geometry'
|
||||||
import type { CupolaNode } from './schema'
|
import type { CupolaNode } from './schema'
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ import type { CupolaNode } from './schema'
|
|||||||
* the ghost stays in lockstep with the committed cupola. Raycast disabled
|
* the ghost stays in lockstep with the committed cupola. Raycast disabled
|
||||||
* so the preview doesn't intercept the cursor ray feeding the tool.
|
* so the preview doesn't intercept the cursor ray feeding the tool.
|
||||||
*/
|
*/
|
||||||
const CupolaPreview = ({ node }: { node: CupolaNode }) => {
|
const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildCupolaGeometry(node),
|
() => buildCupolaGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
||||||
@@ -20,17 +21,17 @@ const CupolaPreview = ({ node }: { node: CupolaNode }) => {
|
|||||||
const material = useMemo(
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: 0xff_ff_ff,
|
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissive: 0x6c_a3_ff,
|
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||||
emissiveIntensity: 0.18,
|
emissiveIntensity: 0.18,
|
||||||
roughness: 0.7,
|
roughness: 0.7,
|
||||||
metalness: 0.1,
|
metalness: 0.1,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.35,
|
opacity: invalid ? 0.4 : 0.35,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
}),
|
}),
|
||||||
[],
|
[invalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|||||||
@@ -119,11 +119,11 @@ const CupolaTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<CupolaPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
}}
|
}}
|
||||||
size={[0.8, 1.2, 0.8]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
<group position={previewPos}>
|
<group position={previewPos}>
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ import {
|
|||||||
type DoorNode,
|
type DoorNode,
|
||||||
type FloorplanMoveTarget,
|
type FloorplanMoveTarget,
|
||||||
type FloorplanMoveTargetSession,
|
type FloorplanMoveTargetSession,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
WallNode as WallNodeSchema,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { snapToHalf } from '@pascal-app/editor'
|
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
|
||||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||||
import {
|
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
||||||
getRoofHostedOpeningLevelId,
|
|
||||||
getRoofHostedOpeningPlanPoint,
|
|
||||||
} from '../shared/roof-opening-host'
|
|
||||||
import {
|
import {
|
||||||
findClosestWallInPlan,
|
findClosestWallInPlan,
|
||||||
projectWallLocalPointToPlan,
|
projectWallLocalPointToPlan,
|
||||||
@@ -38,16 +37,10 @@ import { clampToWall, hasWallChildOverlap } from './door-math'
|
|||||||
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
|
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
|
||||||
// Snapshot of the door's "valid" state at move-start — used by
|
// Snapshot of the door's "valid" state at move-start — used by
|
||||||
// canCommit to decide whether the current snapped position is OK.
|
// canCommit to decide whether the current snapped position is OK.
|
||||||
const startLevelId = (() => {
|
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
||||||
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
|
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
||||||
// segment → roof → level. Cached at start because the parent chain
|
// Cached at start because the parent chain doesn't change during a move.
|
||||||
// doesn't change during a move.
|
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
||||||
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
|
|
||||||
})()
|
|
||||||
const originalWall = node.parentId
|
const originalWall = node.parentId
|
||||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -57,6 +50,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
||||||
metadata: node.metadata,
|
metadata: node.metadata,
|
||||||
|
// Absolute: query the wall snap with the TRUE cursor, not the door's
|
||||||
|
// original wall position plus a grab delta. A wall-hosted opening always
|
||||||
|
// belongs to the wall nearest the cursor (the user's rule), and the 3D
|
||||||
|
// move snaps on the wall literally under the ray — relative mode would
|
||||||
|
// anchor the search to the old wall and resist hopping to a closer one
|
||||||
|
// across a thin gap, picking the "far" wall the user reported. It also
|
||||||
|
// makes the 2D Voronoi overlay (classified by cursor) predict the snap.
|
||||||
|
mode: 'absolute',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Track the last successful placement so `commit()` can write it
|
// Track the last successful placement so `commit()` can write it
|
||||||
@@ -72,13 +73,83 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
roofFace: undefined
|
roofFace: undefined
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
|
// R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives
|
||||||
|
// the wall-facing side every move, so the flip is a persistent XOR applied on
|
||||||
|
// top of the wall hit, plus a π rotation offset (matching the committed R).
|
||||||
|
let flipped = false
|
||||||
|
// Remember the last apply args so the overlay's R keydown can re-run `apply`
|
||||||
|
// (which has no event of its own) to show the flip immediately.
|
||||||
|
let lastApply: {
|
||||||
|
planPoint: readonly [number, number]
|
||||||
|
modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean }
|
||||||
|
} | null = null
|
||||||
|
// Whether the cursor is currently over a wall. Off-wall the door free-follows
|
||||||
|
// the cursor as a ghost (like the 3D move) and is NOT committable — a door
|
||||||
|
// needs a wall. Starts true so a click before any move keeps the door put.
|
||||||
|
let onWall = true
|
||||||
|
|
||||||
|
// Off-wall: float the faithful door symbol at the cursor (via a synthetic
|
||||||
|
// wall fed to the placement-preview layer) and hide the real node, so the
|
||||||
|
// ghost follows the cursor in 2D instead of the door staying frozen on its
|
||||||
|
// old wall. Mirrors the fresh-placement free-follow.
|
||||||
|
const freeFollow = (planPoint: readonly [number, number]) => {
|
||||||
|
onWall = false
|
||||||
|
lastValid = null
|
||||||
|
if ((useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | 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, node.position[1], 0] as [number, number, number],
|
||||||
|
rotation: [0, 0, 0] as [number, number, number],
|
||||||
|
visible: true,
|
||||||
|
} as DoorNode
|
||||||
|
usePlacementPreview.getState().set(ghost, wall)
|
||||||
|
}
|
||||||
|
|
||||||
const session: FloorplanMoveTargetSession = {
|
const session: FloorplanMoveTargetSession = {
|
||||||
affectedIds: [node.id as AnyNodeId],
|
affectedIds: [node.id as AnyNodeId],
|
||||||
|
flipSide() {
|
||||||
|
flipped = !flipped
|
||||||
|
if (lastApply) this.apply(lastApply)
|
||||||
|
},
|
||||||
apply({ planPoint, modifiers }) {
|
apply({ planPoint, modifiers }) {
|
||||||
|
lastApply = { planPoint, modifiers }
|
||||||
|
// Drop any stale live transform left by the 3D `MoveDoorTool` (it
|
||||||
|
// publishes one on every wall hover). The 2D registry layer renders
|
||||||
|
// door/window from `useLiveTransforms` IN PREFERENCE to the scene node,
|
||||||
|
// but this 2D move writes the scene node — so a leftover 3D entry would
|
||||||
|
// freeze the symbol on its wall and the slide wouldn't show. Only the
|
||||||
|
// 2D path runs during an opening move (the panel gates the 3D tool's
|
||||||
|
// events off via `!isOpeningMoveActive`), so nothing re-adds it. Guarded
|
||||||
|
// on existence: `clear` always 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 nodes = useScene.getState().nodes
|
||||||
const resolvedPlanPoint = resolveCursor(planPoint)
|
const resolvedPlanPoint = resolveCursor(planPoint)
|
||||||
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
||||||
if (!hit) return // pointer off any wall — keep door at last valid position
|
if (!hit) {
|
||||||
|
// Off any wall — free-follow the cursor (not committable).
|
||||||
|
freeFollow(resolvedPlanPoint)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Back on a wall — drop the free-follow ghost + reveal the real node.
|
||||||
|
onWall = true
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
|
if ((nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible === false) {
|
||||||
|
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
|
||||||
|
}
|
||||||
|
|
||||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||||
// openings / wall ends); it competes with — and wins over — the 0.5m
|
// openings / wall ends); it competes with — and wins over — the 0.5m
|
||||||
@@ -97,10 +168,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||||
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
||||||
|
|
||||||
|
// Apply the R-flip on top of the wall-derived side.
|
||||||
|
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
|
||||||
|
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
|
||||||
|
|
||||||
lastValid = {
|
lastValid = {
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
rotation: [0, hit.itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side: hit.side,
|
side,
|
||||||
parentId: hit.wall.id,
|
parentId: hit.wall.id,
|
||||||
wallId: hit.wall.id,
|
wallId: hit.wall.id,
|
||||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||||
@@ -122,6 +197,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
|||||||
])
|
])
|
||||||
},
|
},
|
||||||
canCommit() {
|
canCommit() {
|
||||||
|
// Off-wall the door is free-following in mid-air — not placeable. The
|
||||||
|
// overlay then reverts to the pre-move snapshot (door returns to its
|
||||||
|
// original wall), matching the 3D move where an open-floor click commits
|
||||||
|
// nothing.
|
||||||
|
if (!onWall) return false
|
||||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
|
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
|
||||||
if (!live || live.type !== 'door') return false
|
if (!live || live.type !== 'door') return false
|
||||||
// Block commit if the door overlaps any other wall child at its
|
// Block commit if the door overlaps any other wall child at its
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
@@ -82,6 +83,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
let currentHostId: string | null = movingDoorNode.parentId
|
let currentHostId: string | null = movingDoorNode.parentId
|
||||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||||
let committed = false
|
let committed = false
|
||||||
|
// Off-wall free-follow: when the cursor is over empty floor (no wall under
|
||||||
|
// the ray) the door is parented to the level and tracks the cursor like an
|
||||||
|
// item node. `freeFollowing` distinguishes that state so the placement
|
||||||
|
// commit no-ops in open space (a door needs a wall). `lastMeshEventTime`
|
||||||
|
// defers the floor handler whenever a wall/roof mesh event owns the same
|
||||||
|
// pointermove (shared DOM timeStamp) — that's the only thing that snaps.
|
||||||
|
let freeFollowing = false
|
||||||
|
let lastMeshEventTime = -1
|
||||||
|
// The door's chosen facing side. R flips it mid-placement (front ↔ back,
|
||||||
|
// same as the committed-selected R flip) so the user can reorient before
|
||||||
|
// committing. Initialised from the moving node's side.
|
||||||
|
let sideOverride: DoorNode['side'] = movingDoorNode.side
|
||||||
let lastTarget: {
|
let lastTarget: {
|
||||||
wallNode: WallEvent['node']
|
wallNode: WallEvent['node']
|
||||||
wallId: string
|
wallId: string
|
||||||
@@ -149,7 +162,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
const getPlacementOrientation = (event: WallEvent) => {
|
const getPlacementOrientation = (event: WallEvent) => {
|
||||||
const faceSide = getSideFromNormal(event.normal)
|
const faceSide = getSideFromNormal(event.normal)
|
||||||
const side = movingDoorNode.side ?? faceSide
|
const side = sideOverride ?? faceSide
|
||||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||||
return {
|
return {
|
||||||
side,
|
side,
|
||||||
@@ -256,11 +269,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
const target = resolveMoveTarget(event)
|
const target = resolveMoveTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
onWallLeave()
|
onWallLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
freeFollowing = false
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
lastRoofEvent = null
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
@@ -268,6 +283,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
if (!isValidWallSideFace(event.normal)) {
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
onWallLeave()
|
onWallLeave()
|
||||||
return
|
return
|
||||||
@@ -286,20 +302,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
onWallLeave()
|
onWallLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
freeFollowing = false
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
lastRoofEvent = null
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
// Promote the moving door 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 (committed) return
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
|
||||||
if (isCurvedWall(event.node)) return
|
|
||||||
if (event.node.parentId !== getLevelId()) return
|
|
||||||
|
|
||||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
|
||||||
if (!target?.valid) return
|
|
||||||
committed = true
|
committed = true
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
@@ -360,30 +373,84 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
hideCursor()
|
hideCursor()
|
||||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallClick = (event: WallEvent) => {
|
||||||
|
if (committed) return
|
||||||
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
if (isCurvedWall(event.node)) return
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
|
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||||
|
if (!target?.valid) return
|
||||||
|
commitToWall(target)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallLeave = () => {
|
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 either snaps to a nearby wall or free-follows the
|
||||||
|
// cursor. The wireframe outline + live transform are cleared so the
|
||||||
|
// free-follow path can re-establish them. Reverting the node is left to
|
||||||
|
// onGridMove's free-follow / cancel / commit, so the door never blinks
|
||||||
|
// back to the building origin between a wall and open floor.
|
||||||
hideCursor()
|
hideCursor()
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = null
|
lastRoofEvent = null
|
||||||
if (isNew) return
|
}
|
||||||
if (currentHostId && currentHostId !== original.parentId) {
|
|
||||||
markHostDirty(currentHostId)
|
// Free-follow: the door rides the cursor over empty floor, parented to the
|
||||||
|
// level like an item node (lifted so it stands on the floor). 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(movingDoorNode.id)
|
||||||
|
const levelId = getLevelId()
|
||||||
|
const y = movingDoorNode.height / 2
|
||||||
|
// Keep the R-flip visible while free-following: face the chosen side
|
||||||
|
// (back = rotated π) instead of forcing 0, so an R press isn't undone on
|
||||||
|
// the next mousemove.
|
||||||
|
const yaw = sideOverride === 'back' ? Math.PI : 0
|
||||||
|
if (currentHostId !== levelId) {
|
||||||
|
if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId)
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: [localX, y, localZ],
|
||||||
|
rotation: [0, yaw, 0],
|
||||||
|
side: sideOverride,
|
||||||
|
parentId: levelId ?? undefined,
|
||||||
|
wallId: undefined,
|
||||||
|
roofSegmentId: undefined,
|
||||||
|
roofFace: undefined,
|
||||||
|
})
|
||||||
|
currentHostId = levelId
|
||||||
|
} else {
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: [localX, y, localZ],
|
||||||
|
rotation: [0, yaw, 0],
|
||||||
|
side: sideOverride,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
currentHostId = original.parentId
|
}
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
|
||||||
position: original.position,
|
const onGridMove = (event: GridEvent) => {
|
||||||
rotation: original.rotation,
|
if (committed) return
|
||||||
side: original.side,
|
if (useViewer.getState().cameraDragging) return
|
||||||
parentId: original.parentId,
|
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
||||||
wallId: original.wallId,
|
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
||||||
roofSegmentId: original.roofSegmentId,
|
// the cursor is over open floor — free-follow it.
|
||||||
roofFace: original.roofFace,
|
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||||
})
|
|
||||||
if (original.parentId) markHostDirty(original.parentId)
|
// No proximity magnet: in 3D the wall side faces are big raycast targets,
|
||||||
|
// so snapping engages only when the cursor ray actually hovers a wall
|
||||||
|
// (`onWallMove`). Over open floor the door just follows the cursor.
|
||||||
|
const [x, , z] = event.localPosition
|
||||||
|
freeFollowAt(x, z)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||||
@@ -406,12 +473,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
onRoofLeave()
|
onRoofLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||||
|
freeFollowing = false
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = event
|
lastRoofEvent = event
|
||||||
@@ -509,26 +578,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofLeave = () => {
|
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()
|
hideCursor()
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = null
|
lastRoofEvent = null
|
||||||
if (isNew) return
|
|
||||||
if (currentHostId && currentHostId !== original.parentId) {
|
|
||||||
markHostDirty(currentHostId)
|
|
||||||
}
|
|
||||||
currentHostId = original.parentId
|
|
||||||
useScene.getState().updateNode(movingDoorNode.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 = () => {
|
const onCancel = () => {
|
||||||
@@ -556,13 +612,53 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
|
|
||||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
if (!consumePlacementDragRelease(event)) return
|
if (!consumePlacementDragRelease(event)) return
|
||||||
if (lastTarget) {
|
// Free-following over open floor can't commit (no wall). A wall hover
|
||||||
onWallClick(lastTarget.event)
|
// target commits via commitToWall; a roof face via onRoofClick.
|
||||||
|
if (lastTarget?.valid && !freeFollowing) {
|
||||||
|
commitToWall(lastTarget)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R flips the door's facing side mid-placement (front ↔ back), like the
|
||||||
|
// committed-selected R flip — usable before commit, whether snapped to a
|
||||||
|
// wall or free-following. Re-applies the preview so the flip shows live.
|
||||||
|
// No-op on a roof-segment face (those host front-only; nothing to flip).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Only act where a flip is meaningful — on a wall hover or while
|
||||||
|
// free-following. On a roof face leave it to the default R (no flip, no
|
||||||
|
// sfx) so the cue never fires without a visible effect.
|
||||||
|
const onWall = lastTarget !== null
|
||||||
|
if (!(onWall || freeFollowing)) return
|
||||||
|
e.preventDefault()
|
||||||
|
sideOverride = sideOverride === 'front' ? 'back' : 'front'
|
||||||
|
triggerSFX('sfx:item-rotate')
|
||||||
|
if (onWall) {
|
||||||
|
// On a wall: re-resolve with the flipped side and re-preview.
|
||||||
|
const next = resolveMoveTarget(lastTarget!.event)
|
||||||
|
if (next) {
|
||||||
|
lastTarget = next
|
||||||
|
applyPreview(next)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Free-following on the level: flip the draft's facing in place.
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
side: sideOverride,
|
||||||
|
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('wall:enter', onWallEnter)
|
emitter.on('wall:enter', onWallEnter)
|
||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
emitter.on('wall:click', onWallClick)
|
||||||
@@ -571,8 +667,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
emitter.on('roof:move', onRoofHover)
|
emitter.on('roof:move', onRoofHover)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||||
@@ -608,8 +706,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
emitter.off('roof:move', onRoofHover)
|
emitter.off('roof:move', onRoofHover)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
}
|
}
|
||||||
}, [movingDoorNode, exitMoveMode])
|
}, [movingDoorNode, exitMoveMode])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { EDITOR_LAYER } from '@pascal-app/editor'
|
||||||
|
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { applyGhost } from '../shared/ghost-materials'
|
||||||
|
import type { DoorNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translucent preview of a door — used by the placement tool's floating ghost.
|
||||||
|
*
|
||||||
|
* Builds the door mesh via buildDoorPreviewMesh (so the preview shape stays in
|
||||||
|
* lockstep with committed doors), 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 DoorPreview = ({ node, invalid }: { node: DoorNode; invalid?: boolean }) => {
|
||||||
|
const mesh = useMemo(() => {
|
||||||
|
const m = buildDoorPreviewMesh(node)
|
||||||
|
m.layers.set(EDITOR_LAYER)
|
||||||
|
return m
|
||||||
|
}, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount])
|
||||||
|
|
||||||
|
// 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 DoorPreview
|
||||||
+271
-221
@@ -11,6 +11,7 @@ import {
|
|||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
calculateCursorRotation,
|
calculateCursorRotation,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
} from '../shared/roof-wall-opening-placement'
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
|
import DoorPreview from './preview'
|
||||||
|
|
||||||
const edgeMaterial = new LineBasicNodeMaterial({
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
color: 0xef_44_44,
|
color: 0xef_44_44,
|
||||||
@@ -45,31 +47,61 @@ const FALLBACK_WIDTH = 0.9
|
|||||||
const FALLBACK_HEIGHT = 2.1
|
const FALLBACK_HEIGHT = 2.1
|
||||||
const roofFallbackPoint = new Vector3()
|
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
||||||
* (the generated base walls under a roof, including coplanar gable ends).
|
* (the generated base walls under a roof, including coplanar gable ends).
|
||||||
* Doors always sit at floor level (clampedY = height/2 — segment base for
|
* Doors always sit at floor level (clampedY = height/2 — segment base for
|
||||||
* roof-hosted doors).
|
* roof-hosted doors).
|
||||||
|
*
|
||||||
|
* 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 — since
|
||||||
|
* the wall side faces are big raycast targets.
|
||||||
*/
|
*/
|
||||||
const DoorTool: React.FC = () => {
|
const DoorTool: React.FC = () => {
|
||||||
const draftRef = useRef<DoorNode | null>(null)
|
const draftRef = useRef<DoorNode | null>(null)
|
||||||
const cursorGroupRef = useRef<Group>(null!)
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
const edgesRef = useRef<LineSegments>(null!)
|
const edgesRef = useRef<LineSegments>(null!)
|
||||||
|
|
||||||
|
// Off-host floating ghost: the real door geometry follows the cursor over
|
||||||
|
// the grid (tinted invalid). Mutually exclusive with the on-host draft —
|
||||||
|
// when a draft + wireframe is shown this is null and vice-versa.
|
||||||
|
const [fallbackPose, setFallbackPose] = useState<{
|
||||||
|
position: [number, number, number]
|
||||||
|
rotationY: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const ghostStub = useMemo(() => DoorNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
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 door's facing side mid-placement (front ↔ back). On a wall
|
||||||
|
// the chosen side is `getSideFromNormal(normal)` flipped by `sideFlip`;
|
||||||
|
// 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 getLevelId = () => useViewer.getState().selection.levelId
|
||||||
const getLevelYOffset = () => {
|
const getLevelYOffset = () => {
|
||||||
const id = getLevelId()
|
const id = getLevelId()
|
||||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||||
}
|
}
|
||||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
const getSlabElevationForWall = (wall: WallNode) =>
|
||||||
spatialGridManager.getSlabElevationForWall(
|
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
|
||||||
wallEvent.node.parentId ?? '',
|
|
||||||
wallEvent.node.start,
|
|
||||||
wallEvent.node.end,
|
|
||||||
)
|
|
||||||
|
|
||||||
const markHostDirty = (hostId: string) => {
|
const markHostDirty = (hostId: string) => {
|
||||||
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
@@ -86,17 +118,22 @@ const DoorTool: React.FC = () => {
|
|||||||
const hideCursor = () => {
|
const hideCursor = () => {
|
||||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
setFallbackPose(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — anchors of every alignable object; refreshed
|
// Alignment candidates — anchors of every alignable object; refreshed
|
||||||
// after each placement. A door aligns by the plan position of its centre.
|
// after each placement. A door aligns by the plan position of its centre.
|
||||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
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 = (
|
const updateCursor = (
|
||||||
worldPosition: [number, number, number],
|
worldPosition: [number, number, number],
|
||||||
cursorRotationY: number,
|
cursorRotationY: number,
|
||||||
valid: boolean,
|
valid: boolean,
|
||||||
) => {
|
) => {
|
||||||
|
setFallbackPose(null)
|
||||||
const group = cursorGroupRef.current
|
const group = cursorGroupRef.current
|
||||||
if (!group) return
|
if (!group) return
|
||||||
group.visible = true
|
group.visible = true
|
||||||
@@ -105,237 +142,134 @@ const DoorTool: React.FC = () => {
|
|||||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showFallbackCursor = (event: GridEvent) => {
|
// Off-host fallback: hide the wireframe outline and float the real door
|
||||||
if (draftRef.current) return
|
// geometry (tinted invalid) at the cursor so the armed tool is visible.
|
||||||
const [x, y, z] = event.localPosition
|
const showGhostAt = (position: [number, number, number]) => {
|
||||||
updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false)
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
|
setFallbackPose({ position, rotationY: 0 })
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
|
||||||
useAlignmentGuides.getState().clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showWallFallbackCursor = (event: WallEvent) => {
|
const showWallFallbackCursor = (event: WallEvent) => {
|
||||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
|
||||||
useAlignmentGuides.getState().clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
// Settle a wall target: alignment snap → clamp → overlap check. Pure read.
|
||||||
if (!isValidWallSideFace(event.normal)) {
|
const resolveWallPlacement = (
|
||||||
destroyDraft()
|
wall: WallNode,
|
||||||
showWallFallbackCursor(event)
|
rawLocalX: number,
|
||||||
return
|
width: number,
|
||||||
}
|
height: number,
|
||||||
if (isCurvedWall(event.node)) {
|
bypass: boolean,
|
||||||
destroyDraft()
|
bypassSnap: boolean,
|
||||||
showWallFallbackCursor(event)
|
ignoreId?: string,
|
||||||
return
|
) => {
|
||||||
}
|
|
||||||
const levelId = getLevelId()
|
|
||||||
if (!levelId) {
|
|
||||||
destroyDraft()
|
|
||||||
showWallFallbackCursor(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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 = 0.9
|
|
||||||
const height = 2.1
|
|
||||||
const localX = resolveWallSlideAlignment({
|
const localX = resolveWallSlideAlignment({
|
||||||
wallNode: event.node,
|
wallNode: wall,
|
||||||
rawLocalX: event.localPosition[0],
|
rawLocalX,
|
||||||
width,
|
width,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
bypass,
|
||||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
bypassSnap,
|
||||||
})
|
})
|
||||||
|
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
|
||||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||||
|
return { clampedX, clampedY, valid }
|
||||||
const node = DoorNode.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 onWallMove = (event: WallEvent) => {
|
// Shared create/update path for the wall draft — used by the direct
|
||||||
if (!isValidWallSideFace(event.normal)) {
|
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
|
||||||
destroyDraft()
|
// (reparenting only on an actual wall change to avoid churning the host's
|
||||||
showWallFallbackCursor(event)
|
// children array, which flashes 0-vertex wall geometry in WebGPU).
|
||||||
return
|
const applyWallTarget = (args: {
|
||||||
}
|
wall: WallNode
|
||||||
if (isCurvedWall(event.node)) {
|
rawLocalX: number
|
||||||
destroyDraft()
|
side: 'front' | 'back'
|
||||||
showWallFallbackCursor(event)
|
itemRotation: number
|
||||||
return
|
cursorRotationY: number
|
||||||
}
|
bypass: boolean
|
||||||
if (event.node.parentId !== getLevelId()) {
|
bypassSnap: boolean
|
||||||
destroyDraft()
|
}) => {
|
||||||
showWallFallbackCursor(event)
|
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ?? 0.9
|
const width = draftRef.current?.width ?? 0.9
|
||||||
const height = draftRef.current?.height ?? 2.1
|
const height = draftRef.current?.height ?? 2.1
|
||||||
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 { clampedX, clampedY } = clampToWall(event.node, localX, 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) {
|
if (!draftRef.current) {
|
||||||
const levelId = getLevelId()
|
const node = DoorNode.parse({
|
||||||
if (levelId && event.node.parentId === levelId) {
|
position: [0, height / 2, 0],
|
||||||
const node = DoorNode.parse({
|
rotation: [0, itemRotation, 0],
|
||||||
position: [clampedX, clampedY, 0],
|
side,
|
||||||
rotation: [0, itemRotation, 0],
|
wallId: wall.id,
|
||||||
side,
|
parentId: wall.id,
|
||||||
wallId: event.node.id,
|
metadata: { isTransient: true },
|
||||||
parentId: event.node.id,
|
})
|
||||||
metadata: { isTransient: true },
|
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||||
})
|
draftRef.current = node
|
||||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
||||||
draftRef.current = node
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftRef.current) {
|
const { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||||
// Update the scene store on every move so the 2D floor plan
|
wall,
|
||||||
// stays in sync (it re-renders from `node.position`). Only
|
rawLocalX,
|
||||||
// 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,
|
|
||||||
width,
|
width,
|
||||||
height,
|
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(
|
updateCursor(
|
||||||
wallLocalToWorld(
|
wallLocalToWorld(
|
||||||
event.node,
|
wall,
|
||||||
clampedX,
|
clampedX,
|
||||||
clampedY,
|
clampedY,
|
||||||
getLevelYOffset(),
|
getLevelYOffset(),
|
||||||
getSlabElevation(event),
|
getSlabElevationForWall(wall),
|
||||||
),
|
),
|
||||||
cursorRotation,
|
cursorRotationY,
|
||||||
valid,
|
valid,
|
||||||
)
|
)
|
||||||
event.stopPropagation()
|
return { clampedX, clampedY, valid }
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
// Promote the draft into a permanent door. Shared by the wall-mesh click
|
||||||
if (!draftRef.current) return
|
// and the floor proximity click.
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
const commitDoorAtWall = (
|
||||||
if (isCurvedWall(event.node)) return
|
wall: WallNode,
|
||||||
if (event.node.parentId !== getLevelId()) return
|
clampedX: number,
|
||||||
|
clampedY: number,
|
||||||
const side = getSideFromNormal(event.normal)
|
side: 'front' | 'back',
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
itemRotation: number,
|
||||||
|
) => {
|
||||||
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 { clampedX, clampedY } = clampToWall(
|
|
||||||
event.node,
|
|
||||||
localX,
|
|
||||||
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
|
|
||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
|
if (!draft) return
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
|
hostKind = null
|
||||||
|
|
||||||
useScene.getState().deleteNode(draft.id)
|
useScene.getState().deleteNode(draft.id)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -344,18 +278,17 @@ const DoorTool: React.FC = () => {
|
|||||||
const state = useScene.getState()
|
const state = useScene.getState()
|
||||||
const doorCount = Object.values(state.nodes).filter((n) => {
|
const doorCount = Object.values(state.nodes).filter((n) => {
|
||||||
if (n.type !== 'door') return false
|
if (n.type !== 'door') return false
|
||||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||||
return wall?.parentId === levelId
|
return w?.parentId === levelId
|
||||||
}).length
|
}).length
|
||||||
const name = `Door ${doorCount + 1}`
|
|
||||||
|
|
||||||
const node = DoorNode.parse({
|
const node = DoorNode.parse({
|
||||||
name,
|
name: `Door ${doorCount + 1}`,
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side,
|
side,
|
||||||
wallId: event.node.id,
|
wallId: wall.id,
|
||||||
parentId: event.node.id,
|
parentId: wall.id,
|
||||||
width: draft.width,
|
width: draft.width,
|
||||||
height: draft.height,
|
height: draft.height,
|
||||||
doorCategory: draft.doorCategory,
|
doorCategory: draft.doorCategory,
|
||||||
@@ -380,19 +313,107 @@ const DoorTool: React.FC = () => {
|
|||||||
panicBarHeight: draft.panicBarHeight,
|
panicBarHeight: draft.panicBarHeight,
|
||||||
})
|
})
|
||||||
|
|
||||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||||
useAlignmentGuides.getState().clear()
|
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],
|
||||||
|
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],
|
||||||
|
draftRef.current.width,
|
||||||
|
draftRef.current.height,
|
||||||
|
bypass,
|
||||||
|
bypassSnap,
|
||||||
|
draftRef.current.id,
|
||||||
|
)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallLeave = () => {
|
const onWallLeave = () => {
|
||||||
|
if (hostKind !== 'wall') return
|
||||||
|
lastWallEvent = null
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
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, z])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||||
@@ -414,6 +435,8 @@ const DoorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
|
hostKind = 'roof'
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
const target = resolveRoofTarget(event)
|
const target = resolveRoofTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// On the roof but not over a placeable wall face (slope, soffit,
|
// On the roof but not over a placeable wall face (slope, soffit,
|
||||||
@@ -456,6 +479,7 @@ const DoorTool: React.FC = () => {
|
|||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
|
hostKind = null
|
||||||
|
|
||||||
useScene.getState().deleteNode(draft.id)
|
useScene.getState().deleteNode(draft.id)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -508,42 +532,61 @@ const DoorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofLeave = () => {
|
const onRoofLeave = () => {
|
||||||
if (!draftRef.current?.roofSegmentId) return
|
if (hostKind !== 'roof') return
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
hostKind = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
hostKind = null
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.on('wall:enter', onWallEnter)
|
// R flips the door's facing side mid-placement (front ↔ back), like the
|
||||||
emitter.on('wall:move', onWallMove)
|
// 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:click', onWallClick)
|
||||||
emitter.on('wall:leave', onWallLeave)
|
emitter.on('wall:leave', onWallLeave)
|
||||||
emitter.on('roof:enter', onRoofHover)
|
emitter.on('roof:enter', onRoofHover)
|
||||||
emitter.on('roof:move', onRoofHover)
|
emitter.on('roof:move', onRoofHover)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('grid:move', showFallbackCursor)
|
emitter.on('grid:move', onGridFreeFollow)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallHover)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallHover)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
emitter.off('wall:leave', onWallLeave)
|
emitter.off('wall:leave', onWallLeave)
|
||||||
emitter.off('roof:enter', onRoofHover)
|
emitter.off('roof:enter', onRoofHover)
|
||||||
emitter.off('roof:move', onRoofHover)
|
emitter.off('roof:move', onRoofHover)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('grid:move', showFallbackCursor)
|
emitter.off('grid:move', onGridFreeFollow)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -553,14 +596,21 @@ const DoorTool: React.FC = () => {
|
|||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={cursorGroupRef} visible={false}>
|
<>
|
||||||
<lineSegments
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
geometry={edgesGeo}
|
<lineSegments
|
||||||
layers={EDITOR_LAYER}
|
geometry={edgesGeo}
|
||||||
material={edgeMaterial}
|
layers={EDITOR_LAYER}
|
||||||
ref={edgesRef}
|
material={edgeMaterial}
|
||||||
/>
|
ref={edgesRef}
|
||||||
</group>
|
/>
|
||||||
|
</group>
|
||||||
|
{fallbackPose && (
|
||||||
|
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
|
||||||
|
<DoorPreview invalid node={ghostStub} />
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildDormerGhostGeometry } from './geometry'
|
import { buildDormerGhostGeometry } from './geometry'
|
||||||
import type { DormerNode } from './schema'
|
import type { DormerNode } from './schema'
|
||||||
|
|
||||||
@@ -12,7 +13,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const DormerPreview = ({ node }: { node: DormerNode }) => {
|
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: INVALID_GHOST_COLOR,
|
||||||
|
emissive: INVALID_GHOST_COLOR,
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
roughness: 0.5,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean }) => {
|
||||||
|
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||||
|
|
||||||
const geo = useMemo(
|
const geo = useMemo(
|
||||||
() => buildDormerGhostGeometry(node),
|
() => buildDormerGhostGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight],
|
[node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight],
|
||||||
@@ -20,7 +33,7 @@ const DormerPreview = ({ node }: { node: DormerNode }) => {
|
|||||||
|
|
||||||
useEffect(() => () => geo.dispose(), [geo])
|
useEffect(() => () => geo.dispose(), [geo])
|
||||||
|
|
||||||
return <mesh geometry={geo} material={ghostMaterial} raycast={() => {}} />
|
return <mesh geometry={geo} material={material} raycast={() => {}} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DormerPreview
|
export default DormerPreview
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ const DormerTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<DormerPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={clearPreview}
|
onInvalidTarget={clearPreview}
|
||||||
size={[1.8, 1.8, 1.4]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && segmentXform && hitLocal && (
|
{activeBuildingId && segmentXform && hitLocal && (
|
||||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildDownspoutGeometry } from './geometry'
|
import { buildDownspoutGeometry } from './geometry'
|
||||||
import type { DownspoutRouting } from './routing'
|
import type { DownspoutRouting } from './routing'
|
||||||
import type { DownspoutNode } from './schema'
|
import type { DownspoutNode } from './schema'
|
||||||
@@ -19,9 +20,11 @@ import type { DownspoutNode } from './schema'
|
|||||||
const DownspoutPreview = ({
|
const DownspoutPreview = ({
|
||||||
node,
|
node,
|
||||||
routing,
|
routing,
|
||||||
|
invalid,
|
||||||
}: {
|
}: {
|
||||||
node: DownspoutNode
|
node: DownspoutNode
|
||||||
routing?: DownspoutRouting | null
|
routing?: DownspoutRouting | null
|
||||||
|
invalid?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildDownspoutGeometry(node, routing),
|
() => buildDownspoutGeometry(node, routing),
|
||||||
@@ -40,17 +43,17 @@ const DownspoutPreview = ({
|
|||||||
const material = useMemo(
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: 0xff_ff_ff,
|
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissive: 0xff_ff_ff,
|
emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissiveIntensity: 0.12,
|
emissiveIntensity: 0.12,
|
||||||
roughness: 0.7,
|
roughness: 0.7,
|
||||||
metalness: 0.2,
|
metalness: 0.2,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.55,
|
opacity: invalid ? 0.4 : 0.55,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: THREE.FrontSide,
|
side: THREE.FrontSide,
|
||||||
}),
|
}),
|
||||||
[],
|
[invalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|||||||
@@ -167,8 +167,8 @@ const DownspoutTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<DownspoutPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => setTarget(null)}
|
onInvalidTarget={() => setTarget(null)}
|
||||||
size={[0.2, 2.5, 0.2]}
|
|
||||||
validTarget="gutter"
|
validTarget="gutter"
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && target && (
|
{activeBuildingId && target && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildEyebrowVentGeometry } from './geometry'
|
import { buildEyebrowVentGeometry } from './geometry'
|
||||||
import type { EyebrowVentNode } from './schema'
|
import type { EyebrowVentNode } from './schema'
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ import type { EyebrowVentNode } from './schema'
|
|||||||
* so the ghost stays in lockstep with the committed vent. Raycast disabled so
|
* so the ghost stays in lockstep with the committed vent. Raycast disabled so
|
||||||
* the preview doesn't intercept the cursor ray feeding the tool.
|
* the preview doesn't intercept the cursor ray feeding the tool.
|
||||||
*/
|
*/
|
||||||
const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => {
|
const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid?: boolean }) => {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildEyebrowVentGeometry(node),
|
() => buildEyebrowVentGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
|
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
|
||||||
@@ -20,17 +21,17 @@ const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => {
|
|||||||
const material = useMemo(
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: 0xff_ff_ff,
|
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissive: 0x6c_a3_ff,
|
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||||
emissiveIntensity: 0.18,
|
emissiveIntensity: 0.18,
|
||||||
roughness: 0.7,
|
roughness: 0.7,
|
||||||
metalness: 0.1,
|
metalness: 0.1,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.35,
|
opacity: invalid ? 0.4 : 0.35,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
}),
|
}),
|
||||||
[],
|
[invalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ const EyebrowVentTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<EyebrowVentPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
}}
|
}}
|
||||||
size={[1.2, 0.4, 0.5]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
<group position={previewPos}>
|
<group position={previewPos}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildGutterGeometry } from './geometry'
|
import { buildGutterGeometry } from './geometry'
|
||||||
import type { GutterNode } from './schema'
|
import type { GutterNode } from './schema'
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ import type { GutterNode } from './schema'
|
|||||||
* of the trough walls and visually thicken the ghost relative to the
|
* of the trough walls and visually thicken the ghost relative to the
|
||||||
* placed gutter.
|
* placed gutter.
|
||||||
*/
|
*/
|
||||||
const GutterPreview = ({ node }: { node: GutterNode }) => {
|
const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean }) => {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildGutterGeometry(node),
|
() => buildGutterGeometry(node),
|
||||||
[
|
[
|
||||||
@@ -39,17 +40,17 @@ const GutterPreview = ({ node }: { node: GutterNode }) => {
|
|||||||
const material = useMemo(
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: 0xff_ff_ff,
|
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissive: 0xff_ff_ff,
|
emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissiveIntensity: 0.12,
|
emissiveIntensity: 0.12,
|
||||||
roughness: 0.7,
|
roughness: 0.7,
|
||||||
metalness: 0.2,
|
metalness: 0.2,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.55,
|
opacity: invalid ? 0.4 : 0.55,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: THREE.FrontSide,
|
side: THREE.FrontSide,
|
||||||
}),
|
}),
|
||||||
[],
|
[invalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ const GutterTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<GutterPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => setTarget(null)}
|
onInvalidTarget={() => setTarget(null)}
|
||||||
size={[2, 0.2, 0.25]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && target && (
|
{activeBuildingId && target && (
|
||||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildRidgeVentGeometry } from './geometry'
|
import { buildRidgeVentGeometry } from './geometry'
|
||||||
import type { RidgeVentNode } from './schema'
|
import type { RidgeVentNode } from './schema'
|
||||||
|
|
||||||
const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
|
const RidgeVentPreview = ({ node, invalid }: { node: RidgeVentNode; invalid?: boolean }) => {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildRidgeVentGeometry(node),
|
() => buildRidgeVentGeometry(node),
|
||||||
[node.length, node.width, node.height, node.style, node.endCaps],
|
[node.length, node.width, node.height, node.style, node.endCaps],
|
||||||
@@ -14,17 +15,17 @@ const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
|
|||||||
const material = useMemo(
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: 0xff_ff_ff,
|
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissive: 0xff_ff_ff,
|
emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissiveIntensity: 0.12,
|
emissiveIntensity: 0.12,
|
||||||
roughness: 0.85,
|
roughness: 0.85,
|
||||||
metalness: 0.05,
|
metalness: 0.05,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.55,
|
opacity: invalid ? 0.4 : 0.55,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
}),
|
}),
|
||||||
[],
|
[invalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ const RidgeVentTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<RidgeVentPreview node={previewNode} invalid />}
|
||||||
isValidRoofTarget={(event) => {
|
isValidRoofTarget={(event) => {
|
||||||
const hit = resolveRoofSegmentHit(
|
const hit = resolveRoofSegmentHit(
|
||||||
event.node as RoofNode,
|
event.node as RoofNode,
|
||||||
@@ -150,7 +151,6 @@ const RidgeVentTool = () => {
|
|||||||
return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
|
return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
|
||||||
}}
|
}}
|
||||||
onInvalidTarget={() => setPreviewPos(null)}
|
onInvalidTarget={() => setPreviewPos(null)}
|
||||||
size={[2, 0.15, 0.35]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && (
|
{activeBuildingId && previewPos && (
|
||||||
<group position={previewPos}>
|
<group position={previewPos}>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Material, Mesh, Object3D, Raycaster } from 'three'
|
||||||
|
|
||||||
|
export const INVALID_GHOST_COLOR = 0xef_44_44
|
||||||
|
|
||||||
|
const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply ghost material treatment to a preview mesh tree.
|
||||||
|
*
|
||||||
|
* Traverses the object tree, disables raycasting on all descendants (prevents
|
||||||
|
* cursor-ray starvation), and clones visible mesh materials to set translucency.
|
||||||
|
*
|
||||||
|
* When `invalid` is true, sets color/emissive to INVALID_GHOST_COLOR and opacity ~0.4.
|
||||||
|
* Otherwise sets opacity ~0.5 while preserving the original color.
|
||||||
|
*
|
||||||
|
* Skips: meshes whose material.visible === false (door/window root hitbox) and
|
||||||
|
* children named 'cutout'.
|
||||||
|
*
|
||||||
|
* Returns cleanup that disposes only the cloned materials (never originals or geometry).
|
||||||
|
*
|
||||||
|
* @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh)
|
||||||
|
* @param opts - { invalid?: boolean } whether to tint red for invalid placement
|
||||||
|
* @returns Cleanup function that disposes the cloned materials
|
||||||
|
*/
|
||||||
|
export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () => void {
|
||||||
|
const invalid = opts?.invalid ?? false
|
||||||
|
const cloned: Material[] = []
|
||||||
|
|
||||||
|
root.traverse((obj) => {
|
||||||
|
// Disable raycast on every descendant to prevent cursor-ray starvation.
|
||||||
|
obj.raycast = NO_RAYCAST
|
||||||
|
|
||||||
|
const mesh = obj as Mesh
|
||||||
|
if (!mesh.isMesh) return
|
||||||
|
if (mesh.name === 'cutout') return
|
||||||
|
|
||||||
|
const original = mesh.material
|
||||||
|
const wasArray = Array.isArray(original)
|
||||||
|
|
||||||
|
const cloneOne = (mat: Material): Material | null => {
|
||||||
|
// Skip invisible materials (door/window root hitbox).
|
||||||
|
if ((mat as { visible?: boolean }).visible === false) return null
|
||||||
|
const clone = mat.clone()
|
||||||
|
clone.transparent = true
|
||||||
|
clone.depthWrite = false
|
||||||
|
if (invalid) {
|
||||||
|
;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(INVALID_GHOST_COLOR)
|
||||||
|
;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex(
|
||||||
|
INVALID_GHOST_COLOR,
|
||||||
|
)
|
||||||
|
clone.opacity = 0.4
|
||||||
|
} else {
|
||||||
|
clone.opacity = 0.5
|
||||||
|
}
|
||||||
|
cloned.push(clone)
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasArray) {
|
||||||
|
const clonedMats = original.map(cloneOne).filter((m): m is Material => m !== null)
|
||||||
|
if (clonedMats.length > 0) mesh.material = clonedMats
|
||||||
|
} else {
|
||||||
|
const clone = cloneOne(original)
|
||||||
|
if (clone) mesh.material = clone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const mat of cloned) {
|
||||||
|
mat.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { DragBoundingBox } from '@pascal-app/editor'
|
import { DragBoundingBox } from '@pascal-app/editor'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
|
|
||||||
const INVALID_PREVIEW_COLOR = 0xef_44_44
|
const INVALID_PREVIEW_COLOR = 0xef_44_44
|
||||||
@@ -17,6 +17,7 @@ type ValidTarget = 'roof' | 'gutter'
|
|||||||
|
|
||||||
export function RoofAttachmentFallbackPreview({
|
export function RoofAttachmentFallbackPreview({
|
||||||
activeBuildingId,
|
activeBuildingId,
|
||||||
|
ghost,
|
||||||
isValidRoofTarget,
|
isValidRoofTarget,
|
||||||
lift = 0,
|
lift = 0,
|
||||||
onInvalidTarget,
|
onInvalidTarget,
|
||||||
@@ -24,10 +25,11 @@ export function RoofAttachmentFallbackPreview({
|
|||||||
validTarget = 'roof',
|
validTarget = 'roof',
|
||||||
}: {
|
}: {
|
||||||
activeBuildingId: string | null | undefined
|
activeBuildingId: string | null | undefined
|
||||||
|
ghost?: ReactNode
|
||||||
isValidRoofTarget?: (event: RoofEvent) => boolean
|
isValidRoofTarget?: (event: RoofEvent) => boolean
|
||||||
lift?: number
|
lift?: number
|
||||||
onInvalidTarget?: () => void
|
onInvalidTarget?: () => void
|
||||||
size: [number, number, number]
|
size?: [number, number, number]
|
||||||
validTarget?: ValidTarget
|
validTarget?: ValidTarget
|
||||||
}) {
|
}) {
|
||||||
const [position, setPosition] = useState<[number, number, number] | null>(null)
|
const [position, setPosition] = useState<[number, number, number] | null>(null)
|
||||||
@@ -100,6 +102,13 @@ export function RoofAttachmentFallbackPreview({
|
|||||||
|
|
||||||
if (!(activeBuildingId && position)) return null
|
if (!(activeBuildingId && position)) return null
|
||||||
|
|
||||||
|
// When ghost is provided, render the ghost instead of DragBoundingBox
|
||||||
|
if (ghost) {
|
||||||
|
return <group position={position}>{ghost}</group>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to DragBoundingBox for callers not yet migrated
|
||||||
|
if (!size) return null
|
||||||
return (
|
return (
|
||||||
<DragBoundingBox
|
<DragBoundingBox
|
||||||
color={INVALID_PREVIEW_COLOR}
|
color={INVALID_PREVIEW_COLOR}
|
||||||
|
|||||||
@@ -84,6 +84,32 @@ export function getRoofHostedOpeningLevelId(
|
|||||||
return (roof.parentId as AnyNodeId | null) ?? null
|
return (roof.parentId as AnyNodeId | null) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The level that owns the wall-snap candidates for an opening (door /
|
||||||
|
* window), across all three parentings the 2D move can start from:
|
||||||
|
* - roof-hosted: opening → segment → roof → level (`getRoofHostedOpeningLevelId`).
|
||||||
|
* - wall-hosted (existing opening): parent is a wall → its parent is the level.
|
||||||
|
* - fresh placement (preset/catalog): the clone is parented straight to the
|
||||||
|
* LEVEL (`place-preset` sets `parentId: levelId`), so the parent IS the level.
|
||||||
|
*
|
||||||
|
* The fresh-placement case is the subtle one: treating the parent as always a
|
||||||
|
* wall (`parent.parentId`) resolves a fresh opening's level to the BUILDING,
|
||||||
|
* and `collectLevelWallSegments(building)` finds no walls — so a new door /
|
||||||
|
* window never snapped in 2D. Returns null when the parent chain is none of
|
||||||
|
* the above.
|
||||||
|
*/
|
||||||
|
export function getOpeningHostLevelId(
|
||||||
|
node: { parentId: string | null },
|
||||||
|
nodes: Record<string, AnyNode | undefined>,
|
||||||
|
): AnyNodeId | null {
|
||||||
|
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
|
||||||
|
if (roofLevelId) return roofLevelId
|
||||||
|
const parent = node.parentId ? nodes[node.parentId] : undefined
|
||||||
|
if (!parent) return null
|
||||||
|
if (parent.type === 'level') return parent.id as AnyNodeId
|
||||||
|
return (parent.parentId as AnyNodeId | null) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
|
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
|
||||||
* through the face frame, then composed through the segment's and roof's
|
* through the face frame, then composed through the segment's and roof's
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
collectLevelWallSegments,
|
||||||
getScaledDimensions,
|
getScaledDimensions,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
isCurvedWall,
|
nearestWallSegment,
|
||||||
|
WALL_SNAP_DISTANCE_M,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
@@ -22,8 +24,6 @@ import {
|
|||||||
* rejects curved walls (mitering + arc + opening would tear in 3D).
|
* rejects curved walls (mitering + arc + opening would tear in 3D).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const WALL_SNAP_DISTANCE_M = 1.5
|
|
||||||
|
|
||||||
export type WallHit = {
|
export type WallHit = {
|
||||||
wall: WallNode
|
wall: WallNode
|
||||||
/** Distance along the wall from `start` (clamped to [0, length]). */
|
/** Distance along the wall from `start` (clamped to [0, length]). */
|
||||||
@@ -61,10 +61,14 @@ export function projectWallLocalPointToPlan(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walk every wall under `parentLevelId` and return the closest one to
|
* Return the single closest wall under `parentLevelId` to `planPoint` — the
|
||||||
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
|
* wall whose segment-Voronoi cell the point lies in — or `null` if nothing is
|
||||||
* `excludeWallId` skips a specific wall (e.g. the current parent during
|
* within `WALL_SNAP_DISTANCE_M`. `excludeWallId` skips a specific wall.
|
||||||
* a re-parent flow if you want a "must change" guard).
|
*
|
||||||
|
* The nearest-segment scan + curved-wall filter live in core
|
||||||
|
* (`collectLevelWallSegments` / `nearestWallSegment`) so the editor's 2D
|
||||||
|
* Voronoi debug overlay classifies points with the exact same math — the
|
||||||
|
* overlay is then a faithful picture of where this snaps.
|
||||||
*/
|
*/
|
||||||
export function findClosestWallInPlan(
|
export function findClosestWallInPlan(
|
||||||
planPoint: readonly [number, number],
|
planPoint: readonly [number, number],
|
||||||
@@ -72,78 +76,36 @@ export function findClosestWallInPlan(
|
|||||||
parentLevelId: AnyNodeId | null,
|
parentLevelId: AnyNodeId | null,
|
||||||
excludeWallId?: AnyNodeId,
|
excludeWallId?: AnyNodeId,
|
||||||
): WallHit | null {
|
): WallHit | null {
|
||||||
if (!parentLevelId) return null
|
const segments = collectLevelWallSegments(nodes, parentLevelId)
|
||||||
const level = nodes[parentLevelId]
|
const closest = nearestWallSegment(
|
||||||
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
|
segments,
|
||||||
if (!Array.isArray(childIds)) return null
|
planPoint[0],
|
||||||
|
planPoint[1],
|
||||||
|
WALL_SNAP_DISTANCE_M,
|
||||||
|
excludeWallId,
|
||||||
|
)
|
||||||
|
if (!closest) return null
|
||||||
|
|
||||||
let best: WallHit | null = null
|
const { segment, along, perp } = closest
|
||||||
|
// Side determination, calibrated to the 3D wall convention. In wall-local
|
||||||
|
// space the wall extends along +X and its +Z axis is the front-face normal;
|
||||||
|
// `perp >= 0` is consistently the front side (see `closestOnSegment`).
|
||||||
|
const side: 'front' | 'back' = perp >= 0 ? 'front' : 'back'
|
||||||
|
// Wall-local rotation matching 3D `calculateItemRotation`: 0 front, π back.
|
||||||
|
// The node is parented to the wall, so this composes with the wall's own
|
||||||
|
// rotation at render — never a world-space rotation here.
|
||||||
|
const itemRotation = side === 'front' ? 0 : Math.PI
|
||||||
|
|
||||||
for (const childId of childIds) {
|
return {
|
||||||
const node = nodes[childId]
|
wall: segment.wall,
|
||||||
if (!node || node.type !== 'wall') continue
|
localX: along,
|
||||||
if (childId === excludeWallId) continue
|
perpDistance: perp,
|
||||||
const wall = node as WallNode
|
side,
|
||||||
if (isCurvedWall(wall)) continue
|
dirX: segment.dirX,
|
||||||
|
dirY: segment.dirY,
|
||||||
const sx = wall.start[0]
|
wallLength: segment.length,
|
||||||
const sy = wall.start[1]
|
itemRotation,
|
||||||
const dx = wall.end[0] - sx
|
|
||||||
const dy = wall.end[1] - sy
|
|
||||||
const wallLength = Math.hypot(dx, dy)
|
|
||||||
if (wallLength < 1e-6) continue
|
|
||||||
|
|
||||||
const dirX = dx / wallLength
|
|
||||||
const dirY = dy / wallLength
|
|
||||||
|
|
||||||
// Project pointer onto wall axis.
|
|
||||||
const px = planPoint[0] - sx
|
|
||||||
const py = planPoint[1] - sy
|
|
||||||
const along = px * dirX + py * dirY
|
|
||||||
const perpRaw = px * -dirY + py * dirX // signed perpendicular distance
|
|
||||||
const clampedAlong = Math.max(0, Math.min(wallLength, along))
|
|
||||||
|
|
||||||
// Distance from the pointer to the wall segment (not just the line).
|
|
||||||
const closestPointX = sx + dirX * clampedAlong
|
|
||||||
const closestPointY = sy + dirY * clampedAlong
|
|
||||||
const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY)
|
|
||||||
if (distance > WALL_SNAP_DISTANCE_M) continue
|
|
||||||
if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue
|
|
||||||
|
|
||||||
// Side determination, calibrated to the 3D wall convention. In
|
|
||||||
// wall-local space the wall extends along +X and its +Z axis is the
|
|
||||||
// front-face normal. After `mesh.rotation.y = -wallAngle`:
|
|
||||||
// - For a wall going `+X` in plan (wallAngle=0): wall-local +Z
|
|
||||||
// maps to world +Z = plan +Y, so the front face is on plan +Y.
|
|
||||||
// `perpRaw = py` is positive → front.
|
|
||||||
// - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z
|
|
||||||
// maps to world -X = plan -X, so the front face is on plan -X.
|
|
||||||
// `perpRaw = -px` is positive there → front.
|
|
||||||
// So `perpRaw >= 0` is consistently the front side. The earlier
|
|
||||||
// labelling had this flipped, which produced rotations that were
|
|
||||||
// off by 90° on non-horizontal walls.
|
|
||||||
const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back'
|
|
||||||
|
|
||||||
// Rotation in wall-local space — matches 3D `calculateItemRotation`:
|
|
||||||
// 0 when the item faces the front normal (+Z), π for the back. The
|
|
||||||
// node is parented to the wall, so this composes with the wall's
|
|
||||||
// own rotation when rendered. Don't return a world-space rotation
|
|
||||||
// here — the consumer writes this straight into `node.rotation[1]`.
|
|
||||||
const itemRotation = side === 'front' ? 0 : Math.PI
|
|
||||||
|
|
||||||
best = {
|
|
||||||
wall,
|
|
||||||
localX: clampedAlong,
|
|
||||||
perpDistance: perpRaw,
|
|
||||||
side,
|
|
||||||
dirX,
|
|
||||||
dirY,
|
|
||||||
wallLength,
|
|
||||||
itemRotation,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return best
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Figma-style along-wall alignment threshold (meters) — parity with the
|
/** Figma-style along-wall alignment threshold (meters) — parity with the
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildFrameGeometry } from './frame-csg'
|
import { buildFrameGeometry } from './frame-csg'
|
||||||
import type { SkylightNode } from './schema'
|
import type { SkylightNode } from './schema'
|
||||||
|
|
||||||
@@ -15,7 +16,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const SkylightPreview = ({ node }: { node: SkylightNode }) => {
|
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: INVALID_GHOST_COLOR,
|
||||||
|
emissive: INVALID_GHOST_COLOR,
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
roughness: 0.5,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const SkylightPreview = ({ node, invalid }: { node: SkylightNode; invalid?: boolean }) => {
|
||||||
|
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||||
|
|
||||||
const frame = useMemo(
|
const frame = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildFrameGeometry({
|
buildFrameGeometry({
|
||||||
@@ -48,8 +61,8 @@ const SkylightPreview = ({ node }: { node: SkylightNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh geometry={frame} material={ghostMaterial} raycast={() => {}} />
|
<mesh geometry={frame} material={material} raycast={() => {}} />
|
||||||
<mesh geometry={glass} material={ghostMaterial} raycast={() => {}} />
|
<mesh geometry={glass} material={material} raycast={() => {}} />
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,11 +114,11 @@ const SkylightTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<SkylightPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
}}
|
}}
|
||||||
size={[1.2, 0.2, 1]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
<group position={previewPos}>
|
<group position={previewPos}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildSolarPanelGeometry } from './geometry'
|
import { buildSolarPanelGeometry } from './geometry'
|
||||||
import type { SolarPanelNode } from './schema'
|
import type { SolarPanelNode } from './schema'
|
||||||
|
|
||||||
@@ -15,7 +16,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => {
|
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: INVALID_GHOST_COLOR,
|
||||||
|
emissive: INVALID_GHOST_COLOR,
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
roughness: 0.5,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.4,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: boolean }) => {
|
||||||
|
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||||
|
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildSolarPanelGeometry(node),
|
() => buildSolarPanelGeometry(node),
|
||||||
[
|
[
|
||||||
@@ -38,7 +51,7 @@ const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => {
|
|||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
geometry={geometry}
|
geometry={geometry}
|
||||||
material={ghostMaterial}
|
material={material}
|
||||||
raycast={() => {
|
raycast={() => {
|
||||||
/* preview should not intercept the cursor */
|
/* preview should not intercept the cursor */
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -135,11 +135,11 @@ const SolarPanelTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<SolarPanelPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
}}
|
}}
|
||||||
size={[1.8, 0.2, 1.2]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
<group position={previewPos}>
|
<group position={previewPos}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||||
import { buildTurbineVentGeometry } from './geometry'
|
import { buildTurbineVentGeometry } from './geometry'
|
||||||
import type { TurbineVentNode } from './schema'
|
import type { TurbineVentNode } from './schema'
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ import type { TurbineVentNode } from './schema'
|
|||||||
* lockstep with the committed vent. Raycast is disabled so the preview
|
* lockstep with the committed vent. Raycast is disabled so the preview
|
||||||
* doesn't intercept the cursor ray feeding the placement tool.
|
* doesn't intercept the cursor ray feeding the placement tool.
|
||||||
*/
|
*/
|
||||||
const TurbineVentPreview = ({ node }: { node: TurbineVentNode }) => {
|
const TurbineVentPreview = ({ node, invalid }: { node: TurbineVentNode; invalid?: boolean }) => {
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildTurbineVentGeometry(node),
|
() => buildTurbineVentGeometry(node),
|
||||||
[node.style, node.diameter, node.height, node.neckHeight, node.vaneCount, node.baseOverhang],
|
[node.style, node.diameter, node.height, node.neckHeight, node.vaneCount, node.baseOverhang],
|
||||||
@@ -21,17 +22,17 @@ const TurbineVentPreview = ({ node }: { node: TurbineVentNode }) => {
|
|||||||
const material = useMemo(
|
const material = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: 0xff_ff_ff,
|
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||||
emissive: 0x6c_a3_ff,
|
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||||
emissiveIntensity: 0.18,
|
emissiveIntensity: 0.18,
|
||||||
roughness: 0.6,
|
roughness: 0.6,
|
||||||
metalness: 0.2,
|
metalness: 0.2,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.35,
|
opacity: invalid ? 0.4 : 0.35,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
}),
|
}),
|
||||||
[],
|
[invalid],
|
||||||
)
|
)
|
||||||
|
|
||||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ const TurbineVentTool = () => {
|
|||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
|
ghost={<TurbineVentPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
}}
|
}}
|
||||||
size={[0.5, 0.8, 0.5]}
|
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
<group position={previewPos}>
|
<group position={previewPos}>
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type FloorplanMoveTarget,
|
type FloorplanMoveTarget,
|
||||||
type FloorplanMoveTargetSession,
|
type FloorplanMoveTargetSession,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
WallNode as WallNodeSchema,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { snapToHalf } from '@pascal-app/editor'
|
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
|
||||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||||
import {
|
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
||||||
getRoofHostedOpeningLevelId,
|
|
||||||
getRoofHostedOpeningPlanPoint,
|
|
||||||
} from '../shared/roof-opening-host'
|
|
||||||
import {
|
import {
|
||||||
findClosestWallInPlan,
|
findClosestWallInPlan,
|
||||||
projectWallLocalPointToPlan,
|
projectWallLocalPointToPlan,
|
||||||
@@ -32,15 +31,9 @@ import { clampToWall, hasWallChildOverlap } from './window-math'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
||||||
const startLevelId = (() => {
|
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
||||||
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
|
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
||||||
// segment → roof → level.
|
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
||||||
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
|
|
||||||
})()
|
|
||||||
const originalWall = node.parentId
|
const originalWall = node.parentId
|
||||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -50,12 +43,21 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
|||||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
||||||
metadata: node.metadata,
|
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
|
// Preserve the source window's local Y — 2D move doesn't have a way
|
||||||
// to express vertical motion, so we keep whatever vertical position
|
// to express vertical motion, so we keep whatever vertical position
|
||||||
// the window had when the move started.
|
// the window had when the move started. A fresh preset/catalog clone is
|
||||||
const startLocalY = node.position[1]
|
// 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
|
// Track the last successful placement so `commit()` can write it
|
||||||
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
|
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
|
||||||
@@ -69,13 +71,72 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
|||||||
roofFace: undefined
|
roofFace: undefined
|
||||||
} | null = null
|
} | 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 = {
|
const session: FloorplanMoveTargetSession = {
|
||||||
affectedIds: [node.id as AnyNodeId],
|
affectedIds: [node.id as AnyNodeId],
|
||||||
|
flipSide() {
|
||||||
|
flipped = !flipped
|
||||||
|
if (lastApply) this.apply(lastApply)
|
||||||
|
},
|
||||||
apply({ planPoint, modifiers }) {
|
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 nodes = useScene.getState().nodes
|
||||||
const resolvedPlanPoint = resolveCursor(planPoint)
|
const resolvedPlanPoint = resolveCursor(planPoint)
|
||||||
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
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
|
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||||
// openings / wall ends), winning over the 0.5m grid snap; falls back
|
// openings / wall ends), winning over the 0.5m grid snap; falls back
|
||||||
@@ -99,10 +160,17 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
|||||||
node.height,
|
node.height,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const side: WindowNode['side'] = flipped
|
||||||
|
? hit.side === 'front'
|
||||||
|
? 'back'
|
||||||
|
: 'front'
|
||||||
|
: hit.side
|
||||||
|
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
|
||||||
|
|
||||||
lastValid = {
|
lastValid = {
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
rotation: [0, hit.itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side: hit.side,
|
side,
|
||||||
parentId: hit.wall.id,
|
parentId: hit.wall.id,
|
||||||
wallId: hit.wall.id,
|
wallId: hit.wall.id,
|
||||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||||
@@ -119,6 +187,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
|||||||
])
|
])
|
||||||
},
|
},
|
||||||
canCommit() {
|
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
|
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
|
||||||
if (!live || live.type !== 'window') return false
|
if (!live || live.type !== 'window') return false
|
||||||
const overlapping = hasWallChildOverlap(
|
const overlapping = hasWallChildOverlap(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
emitter,
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
@@ -101,6 +102,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
let currentHostId: string | null = movingWindowNode.parentId
|
let currentHostId: string | null = movingWindowNode.parentId
|
||||||
let committed = false
|
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: {
|
let dragAnchor: {
|
||||||
wallId: string
|
wallId: string
|
||||||
rawX: number
|
rawX: number
|
||||||
@@ -141,6 +151,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
const id = getLevelId()
|
const id = getLevelId()
|
||||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
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) =>
|
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||||
spatialGridManager.getSlabElevationForWall(
|
spatialGridManager.getSlabElevationForWall(
|
||||||
wallEvent.node.parentId ?? '',
|
wallEvent.node.parentId ?? '',
|
||||||
@@ -183,9 +204,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
// Only interact with walls on the current level
|
// Only interact with walls on the current level
|
||||||
if (event.node.parentId !== getLevelId()) return
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
const side = getSideFromNormal(event.normal)
|
const faceSide = getSideFromNormal(event.normal)
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
const side = sideOverride ?? faceSide
|
||||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
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 rawLocalX = event.localPosition[0]
|
||||||
const rawLocalY = event.localPosition[1]
|
const rawLocalY = event.localPosition[1]
|
||||||
@@ -287,11 +311,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
const target = resolveMoveTarget(event)
|
const target = resolveMoveTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
onWallLeave()
|
onWallLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
freeFollowing = false
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
lastRoofEvent = null
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
@@ -299,6 +325,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onWallMove = (event: WallEvent) => {
|
const onWallMove = (event: WallEvent) => {
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
if (!isValidWallSideFace(event.normal)) {
|
if (!isValidWallSideFace(event.normal)) {
|
||||||
onWallLeave()
|
onWallLeave()
|
||||||
return
|
return
|
||||||
@@ -318,21 +345,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
onWallLeave()
|
onWallLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
freeFollowing = false
|
||||||
lastTarget = target
|
lastTarget = target
|
||||||
lastRoofEvent = null
|
lastRoofEvent = null
|
||||||
applyPreview(target)
|
applyPreview(target)
|
||||||
event.stopPropagation()
|
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 (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
|
committed = true
|
||||||
|
|
||||||
let placedId: string
|
let placedId: string
|
||||||
@@ -398,31 +421,78 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
hideCursor()
|
hideCursor()
|
||||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||||
exitMoveMode()
|
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()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallLeave = () => {
|
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()
|
hideCursor()
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = 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) {
|
// Free-follow: the window rides the cursor over empty floor, parented to
|
||||||
markHostDirty(currentHostId)
|
// 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,
|
const onGridMove = (event: GridEvent) => {
|
||||||
rotation: original.rotation,
|
if (committed) return
|
||||||
side: original.side,
|
if (useViewer.getState().cameraDragging) return
|
||||||
parentId: original.parentId,
|
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
||||||
wallId: original.wallId,
|
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
||||||
roofSegmentId: original.roofSegmentId,
|
// the cursor is over open floor — free-follow it. No proximity magnet:
|
||||||
roofFace: original.roofFace,
|
// snapping engages only when the cursor ray actually hovers a wall.
|
||||||
})
|
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||||
if (original.parentId) markHostDirty(original.parentId)
|
const [x, , z] = event.localPosition
|
||||||
|
freeFollowAt(x, z)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||||
@@ -449,12 +519,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
const target = resolveRoofMoveTarget(event)
|
const target = resolveRoofMoveTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
onRoofLeave()
|
onRoofLeave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||||
|
freeFollowing = false
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = event
|
lastRoofEvent = event
|
||||||
@@ -553,26 +625,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofLeave = () => {
|
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()
|
hideCursor()
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
dragAnchor = null
|
dragAnchor = null
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = 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 = () => {
|
const onCancel = () => {
|
||||||
@@ -600,13 +659,47 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
|
|
||||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
if (!consumePlacementDragRelease(event)) return
|
if (!consumePlacementDragRelease(event)) return
|
||||||
if (lastTarget) {
|
// Free-following over open floor can't commit (no wall). A wall hover
|
||||||
onWallClick(lastTarget.event)
|
// target commits via commitToWall; a roof face via onRoofClick.
|
||||||
|
if (lastTarget?.valid && !freeFollowing) {
|
||||||
|
commitToWall(lastTarget)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
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:enter', onWallEnter)
|
||||||
emitter.on('wall:move', onWallMove)
|
emitter.on('wall:move', onWallMove)
|
||||||
emitter.on('wall:click', onWallClick)
|
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:move', onRoofHover)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
// 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:move', onRoofHover)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
}
|
}
|
||||||
}, [movingWindowNode, exitMoveMode])
|
}, [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,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
|
type WallNode,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +24,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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 { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
worldToSelectedBuildingLocal,
|
worldToSelectedBuildingLocal,
|
||||||
} from '../shared/roof-wall-opening-placement'
|
} from '../shared/roof-wall-opening-placement'
|
||||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||||
|
import WindowPreview from './preview'
|
||||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||||
|
|
||||||
// Shared edge material — reuse across renders, just toggle color
|
// Shared edge material — reuse across renders, just toggle color
|
||||||
@@ -46,33 +48,65 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
const FALLBACK_WIDTH = 1.5
|
const FALLBACK_WIDTH = 1.5
|
||||||
const FALLBACK_HEIGHT = 1.5
|
const FALLBACK_HEIGHT = 1.5
|
||||||
const FALLBACK_SILL_LIFT = 0.45
|
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()
|
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
|
* Window tool — places WindowNodes on walls and on roof-segment wall
|
||||||
* faces (the generated base walls under a roof, including coplanar gable
|
* faces (the generated base walls under a roof, including coplanar gable
|
||||||
* ends — a window can sit in the gable pediment).
|
* 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 WindowTool: React.FC = () => {
|
||||||
const draftRef = useRef<WindowNode | null>(null)
|
const draftRef = useRef<WindowNode | null>(null)
|
||||||
const cursorGroupRef = useRef<Group>(null!)
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
const edgesRef = useRef<LineSegments>(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(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
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 getLevelId = () => useViewer.getState().selection.levelId
|
||||||
const getLevelYOffset = () => {
|
const getLevelYOffset = () => {
|
||||||
const id = getLevelId()
|
const id = getLevelId()
|
||||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||||
}
|
}
|
||||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
const getSlabElevationForWall = (wall: WallNode) =>
|
||||||
spatialGridManager.getSlabElevationForWall(
|
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
|
||||||
wallEvent.node.parentId ?? '',
|
|
||||||
wallEvent.node.start,
|
|
||||||
wallEvent.node.end,
|
|
||||||
)
|
|
||||||
|
|
||||||
const markHostDirty = (hostId: string) => {
|
const markHostDirty = (hostId: string) => {
|
||||||
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||||
@@ -90,6 +124,7 @@ const WindowTool: React.FC = () => {
|
|||||||
const hideCursor = () => {
|
const hideCursor = () => {
|
||||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
setFallbackPose(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — anchors of every alignable object; refreshed
|
// 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).
|
// (along-wall only; the floor-plane guides don't cover sill height).
|
||||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
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 = (
|
const updateCursor = (
|
||||||
worldPosition: [number, number, number],
|
worldPosition: [number, number, number],
|
||||||
cursorRotationY: number,
|
cursorRotationY: number,
|
||||||
valid: boolean,
|
valid: boolean,
|
||||||
) => {
|
) => {
|
||||||
|
setFallbackPose(null)
|
||||||
const group = cursorGroupRef.current
|
const group = cursorGroupRef.current
|
||||||
if (!group) return
|
if (!group) return
|
||||||
group.visible = true
|
group.visible = true
|
||||||
@@ -110,276 +149,166 @@ const WindowTool: React.FC = () => {
|
|||||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showFallbackCursor = (event: GridEvent) => {
|
// Off-host fallback: hide the wireframe outline and float the real window
|
||||||
if (draftRef.current) return
|
// geometry (tinted invalid) at the cursor so the armed tool is visible.
|
||||||
const [x, y, z] = event.localPosition
|
const showGhostAt = (position: [number, number, number]) => {
|
||||||
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
|
setFallbackPose({ position, rotationY: 0 })
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
||||||
useAlignmentGuides.getState().clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showWallFallbackCursor = (event: WallEvent) => {
|
const showWallFallbackCursor = (event: WallEvent) => {
|
||||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
||||||
useAlignmentGuides.getState().clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
// Settle a wall target: alignment snap → sill clamp → overlap check.
|
||||||
if (!isValidWallSideFace(event.normal)) {
|
const resolveWallPlacement = (
|
||||||
destroyDraft()
|
wall: WallNode,
|
||||||
showWallFallbackCursor(event)
|
rawLocalX: number,
|
||||||
return
|
rawLocalY: number,
|
||||||
}
|
width: number,
|
||||||
if (isCurvedWall(event.node)) {
|
height: number,
|
||||||
destroyDraft()
|
bypass: boolean,
|
||||||
showWallFallbackCursor(event)
|
bypassSnap: boolean,
|
||||||
return
|
ignoreId?: string,
|
||||||
}
|
) => {
|
||||||
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({
|
const localX = resolveWallSlideAlignment({
|
||||||
wallNode: event.node,
|
wallNode: wall,
|
||||||
rawLocalX: event.localPosition[0],
|
rawLocalX,
|
||||||
width,
|
width,
|
||||||
candidates: alignmentCandidates,
|
candidates: alignmentCandidates,
|
||||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
bypass,
|
||||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
bypassSnap,
|
||||||
})
|
})
|
||||||
const localY =
|
const localY = bypassSnap ? rawLocalY : snapToHalf(rawLocalY)
|
||||||
event.nativeEvent?.shiftKey === true
|
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
|
||||||
? event.localPosition[1]
|
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||||
: snapToHalf(event.localPosition[1])
|
return { clampedX, clampedY, valid }
|
||||||
|
|
||||||
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 onWallMove = (event: WallEvent) => {
|
// Shared create/update path for the wall draft — used by the direct
|
||||||
if (!isValidWallSideFace(event.normal)) {
|
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
|
||||||
destroyDraft()
|
// (reparenting only on an actual wall change to avoid churning the host's
|
||||||
showWallFallbackCursor(event)
|
// children array, which flashes 0-vertex wall geometry in WebGPU).
|
||||||
return
|
const applyWallTarget = (args: {
|
||||||
}
|
wall: WallNode
|
||||||
if (isCurvedWall(event.node)) {
|
rawLocalX: number
|
||||||
destroyDraft()
|
rawLocalY: number
|
||||||
showWallFallbackCursor(event)
|
side: 'front' | 'back'
|
||||||
return
|
itemRotation: number
|
||||||
}
|
cursorRotationY: number
|
||||||
// Only interact with walls on the current level
|
bypass: boolean
|
||||||
if (event.node.parentId !== getLevelId()) {
|
bypassSnap: boolean
|
||||||
destroyDraft()
|
}) => {
|
||||||
showWallFallbackCursor(event)
|
const {
|
||||||
return
|
wall,
|
||||||
}
|
rawLocalX,
|
||||||
|
rawLocalY,
|
||||||
const side = getSideFromNormal(event.normal)
|
side,
|
||||||
const itemRotation = calculateItemRotation(event.normal)
|
itemRotation,
|
||||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
cursorRotationY,
|
||||||
|
bypass,
|
||||||
|
bypassSnap,
|
||||||
|
} = args
|
||||||
const width = draftRef.current?.width ?? 1.5
|
const width = draftRef.current?.width ?? 1.5
|
||||||
const height = draftRef.current?.height ?? 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) {
|
if (!draftRef.current) {
|
||||||
const levelId = getLevelId()
|
const node = WindowNode.parse({
|
||||||
if (levelId && event.node.parentId === levelId) {
|
position: [0, DEFAULT_SILL_CENTER_Y, 0],
|
||||||
const node = WindowNode.parse({
|
rotation: [0, itemRotation, 0],
|
||||||
position: [clampedX, clampedY, 0],
|
side,
|
||||||
rotation: [0, itemRotation, 0],
|
wallId: wall.id,
|
||||||
side,
|
parentId: wall.id,
|
||||||
wallId: event.node.id,
|
metadata: { isTransient: true },
|
||||||
parentId: event.node.id,
|
})
|
||||||
metadata: { isTransient: true },
|
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||||
})
|
draftRef.current = node
|
||||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
|
||||||
draftRef.current = node
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draftRef.current) {
|
const { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||||
// Update the scene store on every move so the 2D floor plan
|
wall,
|
||||||
// stays in sync (it re-renders from `node.position`). Only
|
rawLocalX,
|
||||||
// forward `parentId` / `wallId` when the wall actually changed
|
rawLocalY,
|
||||||
// — 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,
|
|
||||||
width,
|
width,
|
||||||
height,
|
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(
|
updateCursor(
|
||||||
wallLocalToWorld(
|
wallLocalToWorld(
|
||||||
event.node,
|
wall,
|
||||||
clampedX,
|
clampedX,
|
||||||
clampedY,
|
clampedY,
|
||||||
getLevelYOffset(),
|
getLevelYOffset(),
|
||||||
getSlabElevation(event),
|
getSlabElevationForWall(wall),
|
||||||
),
|
),
|
||||||
cursorRotation,
|
cursorRotationY,
|
||||||
valid,
|
valid,
|
||||||
)
|
)
|
||||||
event.stopPropagation()
|
return { clampedX, clampedY, valid }
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallClick = (event: WallEvent) => {
|
// Promote the draft into a permanent window. Shared by the wall-mesh click
|
||||||
if (!draftRef.current) return
|
// and the floor proximity click.
|
||||||
if (!isValidWallSideFace(event.normal)) return
|
const commitWindowAtWall = (
|
||||||
if (isCurvedWall(event.node)) return
|
wall: WallNode,
|
||||||
// Only interact with walls on the current level
|
clampedX: number,
|
||||||
if (event.node.parentId !== getLevelId()) return
|
clampedY: number,
|
||||||
|
side: 'front' | 'back',
|
||||||
const side = getSideFromNormal(event.normal)
|
itemRotation: number,
|
||||||
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
|
|
||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
|
if (!draft) return
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
|
hostKind = null
|
||||||
|
|
||||||
// Delete transient draft (paused, invisible to undo)
|
|
||||||
useScene.getState().deleteNode(draft.id)
|
useScene.getState().deleteNode(draft.id)
|
||||||
|
|
||||||
// Resume → create permanent node (single undoable action)
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
const levelId = getLevelId()
|
const levelId = getLevelId()
|
||||||
const state = useScene.getState()
|
const state = useScene.getState()
|
||||||
const windowCount = Object.values(state.nodes).filter((n) => {
|
const windowCount = Object.values(state.nodes).filter((n) => {
|
||||||
if (n.type !== 'window') return false
|
if (n.type !== 'window') return false
|
||||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||||
return wall?.parentId === levelId
|
return w?.parentId === levelId
|
||||||
}).length
|
}).length
|
||||||
const name = `Window ${windowCount + 1}`
|
|
||||||
|
|
||||||
const node = WindowNode.parse({
|
const node = WindowNode.parse({
|
||||||
name,
|
name: `Window ${windowCount + 1}`,
|
||||||
position: [clampedX, clampedY, 0],
|
position: [clampedX, clampedY, 0],
|
||||||
rotation: [0, itemRotation, 0],
|
rotation: [0, itemRotation, 0],
|
||||||
side,
|
side,
|
||||||
wallId: event.node.id,
|
wallId: wall.id,
|
||||||
parentId: event.node.id,
|
parentId: wall.id,
|
||||||
width: draft.width,
|
width: draft.width,
|
||||||
height: draft.height,
|
height: draft.height,
|
||||||
windowType: draft.windowType,
|
windowType: draft.windowType,
|
||||||
@@ -398,19 +327,109 @@ const WindowTool: React.FC = () => {
|
|||||||
sillThickness: draft.sillThickness,
|
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] })
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||||
useAlignmentGuides.getState().clear()
|
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()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallLeave = () => {
|
const onWallLeave = () => {
|
||||||
|
if (hostKind !== 'wall') return
|
||||||
|
lastWallEvent = null
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
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 ─────────────────────────────────────
|
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||||
@@ -437,6 +456,8 @@ const WindowTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofHover = (event: RoofEvent) => {
|
const onRoofHover = (event: RoofEvent) => {
|
||||||
|
hostKind = 'roof'
|
||||||
|
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||||
const target = resolveRoofTarget(event)
|
const target = resolveRoofTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// On the roof but not over a placeable wall face (slope, soffit,
|
// On the roof but not over a placeable wall face (slope, soffit,
|
||||||
@@ -479,6 +500,7 @@ const WindowTool: React.FC = () => {
|
|||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
|
hostKind = null
|
||||||
|
|
||||||
useScene.getState().deleteNode(draft.id)
|
useScene.getState().deleteNode(draft.id)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -525,42 +547,61 @@ const WindowTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onRoofLeave = () => {
|
const onRoofLeave = () => {
|
||||||
if (!draftRef.current?.roofSegmentId) return
|
if (hostKind !== 'roof') return
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
hostKind = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
hostKind = null
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.on('wall:enter', onWallEnter)
|
// R flips the window's facing side mid-placement (front ↔ back), like the
|
||||||
emitter.on('wall:move', onWallMove)
|
// 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:click', onWallClick)
|
||||||
emitter.on('wall:leave', onWallLeave)
|
emitter.on('wall:leave', onWallLeave)
|
||||||
emitter.on('roof:enter', onRoofHover)
|
emitter.on('roof:enter', onRoofHover)
|
||||||
emitter.on('roof:move', onRoofHover)
|
emitter.on('roof:move', onRoofHover)
|
||||||
emitter.on('roof:click', onRoofClick)
|
emitter.on('roof:click', onRoofClick)
|
||||||
emitter.on('roof:leave', onRoofLeave)
|
emitter.on('roof:leave', onRoofLeave)
|
||||||
emitter.on('grid:move', showFallbackCursor)
|
emitter.on('grid:move', onGridFreeFollow)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallHover)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallHover)
|
||||||
emitter.off('wall:click', onWallClick)
|
emitter.off('wall:click', onWallClick)
|
||||||
emitter.off('wall:leave', onWallLeave)
|
emitter.off('wall:leave', onWallLeave)
|
||||||
emitter.off('roof:enter', onRoofHover)
|
emitter.off('roof:enter', onRoofHover)
|
||||||
emitter.off('roof:move', onRoofHover)
|
emitter.off('roof:move', onRoofHover)
|
||||||
emitter.off('roof:click', onRoofClick)
|
emitter.off('roof:click', onRoofClick)
|
||||||
emitter.off('roof:leave', onRoofLeave)
|
emitter.off('roof:leave', onRoofLeave)
|
||||||
emitter.off('grid:move', showFallbackCursor)
|
emitter.off('grid:move', onGridFreeFollow)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -570,14 +611,21 @@ const WindowTool: React.FC = () => {
|
|||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={cursorGroupRef} visible={false}>
|
<>
|
||||||
<lineSegments
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
geometry={edgesGeo}
|
<lineSegments
|
||||||
layers={EDITOR_LAYER}
|
geometry={edgesGeo}
|
||||||
material={edgeMaterial}
|
layers={EDITOR_LAYER}
|
||||||
ref={edgesRef}
|
material={edgeMaterial}
|
||||||
/>
|
ref={edgesRef}
|
||||||
</group>
|
/>
|
||||||
|
</group>
|
||||||
|
{fallbackPose && (
|
||||||
|
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
|
||||||
|
<WindowPreview invalid node={ghostStub} />
|
||||||
|
</group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export {
|
|||||||
createColumnTorusGeometry,
|
createColumnTorusGeometry,
|
||||||
} from './systems/column/column-geometry'
|
} from './systems/column/column-geometry'
|
||||||
export { DoorAnimationSystem } from './systems/door/door-animation-system'
|
export { DoorAnimationSystem } from './systems/door/door-animation-system'
|
||||||
export { DoorSystem } from './systems/door/door-system'
|
export { buildDoorPreviewMesh, DoorSystem } from './systems/door/door-system'
|
||||||
export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system'
|
export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system'
|
||||||
// Fence system follows the wall re-export pattern — composed into the
|
// Fence system follows the wall re-export pattern — composed into the
|
||||||
// registry-driven fence definition's `def.system`. Removed in Phase 6
|
// registry-driven fence definition's `def.system`. Removed in Phase 6
|
||||||
@@ -151,5 +151,5 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
|||||||
// removed in Phase 6 when the legacy mount points are deleted.
|
// removed in Phase 6 when the legacy mount points are deleted.
|
||||||
export { WallSystem } from './systems/wall/wall-system'
|
export { WallSystem } from './systems/wall/wall-system'
|
||||||
export { WindowAnimationSystem } from './systems/window/window-animation-system'
|
export { WindowAnimationSystem } from './systems/window/window-animation-system'
|
||||||
export { WindowSystem } from './systems/window/window-system'
|
export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system'
|
||||||
export { ZoneSystem } from './systems/zone/zone-system'
|
export { ZoneSystem } from './systems/zone/zone-system'
|
||||||
|
|||||||
@@ -2372,3 +2372,13 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
cutout.visible = false
|
cutout.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh door mesh for preview/ghost rendering.
|
||||||
|
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
|
||||||
|
*/
|
||||||
|
export function buildDoorPreviewMesh(node: DoorNode): THREE.Mesh {
|
||||||
|
const mesh = new THREE.Mesh()
|
||||||
|
updateDoorMesh(node, mesh)
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|||||||
@@ -3457,3 +3457,13 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
cutout.visible = false
|
cutout.visible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh window mesh for preview/ghost rendering.
|
||||||
|
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).
|
||||||
|
*/
|
||||||
|
export function buildWindowPreviewMesh(node: WindowNode): THREE.Mesh {
|
||||||
|
const mesh = new THREE.Mesh()
|
||||||
|
updateWindowMesh(node, mesh)
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user