Merge remote-tracking branch 'origin/main' into feat/paint-slots
# Conflicts: # packages/core/src/store/use-scene.ts # packages/editor/src/components/editor/index.tsx
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
RoofSegmentNode,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
|
||||
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||
import { scaleHandleHeight } from './door-math'
|
||||
@@ -21,6 +22,9 @@ const SIDE_HANDLE_OFFSET = 0.24
|
||||
const HEIGHT_HANDLE_OFFSET = 0.24
|
||||
const MIN_DOOR_HEIGHT = 0.5
|
||||
const MIN_DOOR_WIDTH = 0.3
|
||||
// How far the move cross floats off the wall face (+Z, the door's facing
|
||||
// normal) so it's grabbable instead of buried in the leaf/frame.
|
||||
const MOVE_HANDLE_LIFT = 0.12
|
||||
|
||||
function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||
if (!door.wallId) return Number.POSITIVE_INFINITY
|
||||
@@ -55,6 +59,7 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType>
|
||||
return readWallLength(n, scene)
|
||||
},
|
||||
currentValue: (n) => n.width,
|
||||
onDrag: (node) => publishOpeningResizeGuides(node, false),
|
||||
apply: (initial, newWidth) => {
|
||||
// Anchored edge stays fixed in wall-local coords. Door rotation is
|
||||
// applied by the inner ride group (the renderer mounts a nested
|
||||
@@ -97,6 +102,7 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
||||
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
||||
},
|
||||
currentValue: (n) => n.height,
|
||||
onDrag: (node) => publishOpeningResizeGuides(node, false),
|
||||
apply: (initial, newHeight) => {
|
||||
const bottom = initial.position[1] - initial.height / 2
|
||||
// Scale the handle so it tracks the door instead of staying glued to a
|
||||
@@ -114,7 +120,26 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
||||
}
|
||||
}
|
||||
|
||||
// Press-drag move grip at the door centre, standing in the wall face. Routes
|
||||
// through the same move tool as the floating Move button (3D
|
||||
// `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall slide + re-host onto
|
||||
// another wall — but `engageMoveDrag` commits on release, with no second click.
|
||||
function doorMoveHandle(): HandleDescriptor<DoorNodeType> {
|
||||
return {
|
||||
kind: 'tap-action',
|
||||
shape: 'move-cross',
|
||||
plane: 'node-normal',
|
||||
portal: 'grandparent',
|
||||
cursor: 'move',
|
||||
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
|
||||
placement: {
|
||||
position: () => [0, 0, MOVE_HANDLE_LIFT],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const doorHandles: HandleDescriptor<DoorNodeType>[] = [
|
||||
doorMoveHandle(),
|
||||
doorWidthHandle('left'),
|
||||
doorWidthHandle('right'),
|
||||
doorHeightHandle(),
|
||||
@@ -232,7 +257,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
presentation: {
|
||||
label: 'Door',
|
||||
description: 'A door cut into a wall. Animated open/close state.',
|
||||
icon: { kind: 'url', src: '/icons/door.png' },
|
||||
icon: { kind: 'url', src: '/icons/door.webp' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 50,
|
||||
},
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { WallNode } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Keep the door handle at the same relative height when the door is resized:
|
||||
@@ -64,63 +56,7 @@ export function clampToWall(
|
||||
return { clampedX, clampedY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a proposed door position overlaps any existing wall children.
|
||||
* Handles item, window, and door types.
|
||||
*/
|
||||
export function hasWallChildOverlap(
|
||||
wallId: string,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ignoreId?: string,
|
||||
): boolean {
|
||||
const nodes = useScene.getState().nodes
|
||||
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||
if (!wallNode) return true
|
||||
const halfW = width / 2
|
||||
const halfH = height / 2
|
||||
const newBottom = clampedY - halfH
|
||||
const newTop = clampedY + halfH
|
||||
const newLeft = clampedX - halfW
|
||||
const newRight = clampedX + halfW
|
||||
|
||||
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
||||
if (childId === ignoreId) continue
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
let childLeft: number, childRight: number, childBottom: number, childTop: number
|
||||
|
||||
if (child.type === 'item') {
|
||||
const item = child as ItemNode
|
||||
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
||||
const [w, h] = getScaledDimensions(item)
|
||||
childLeft = item.position[0] - w / 2
|
||||
childRight = item.position[0] + w / 2
|
||||
childBottom = item.position[1]
|
||||
childTop = item.position[1] + h
|
||||
} else if (child.type === 'window') {
|
||||
const win = child as WindowNode
|
||||
childLeft = win.position[0] - win.width / 2
|
||||
childRight = win.position[0] + win.width / 2
|
||||
childBottom = win.position[1] - win.height / 2
|
||||
childTop = win.position[1] + win.height / 2
|
||||
} else if (child.type === 'door') {
|
||||
const door = child as DoorNode
|
||||
childLeft = door.position[0] - door.width / 2
|
||||
childRight = door.position[0] + door.width / 2
|
||||
childBottom = door.position[1] - door.height / 2
|
||||
childTop = door.position[1] + door.height / 2
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
const xOverlap = newLeft < childRight && newRight > childLeft
|
||||
const yOverlap = newBottom < childTop && newTop > childBottom
|
||||
if (xOverlap && yOverlap) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
// Wall-child overlap is shared by door + window placement (one source of
|
||||
// truth in `shared/wall-attach-target.ts`). Re-exported here so existing
|
||||
// `./door-math` importers don't change.
|
||||
export { hasWallChildOverlap } from '../shared/wall-attach-target'
|
||||
|
||||
@@ -3,18 +3,18 @@ import {
|
||||
type DoorNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import {
|
||||
getRoofHostedOpeningLevelId,
|
||||
getRoofHostedOpeningPlanPoint,
|
||||
} from '../shared/roof-opening-host'
|
||||
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
||||
import {
|
||||
findClosestWallInPlan,
|
||||
projectWallLocalPointToPlan,
|
||||
resolveOpeningPlacement,
|
||||
snapLocalXToNeighbors,
|
||||
} from '../shared/wall-attach-target'
|
||||
import { clampToWall, hasWallChildOverlap } from './door-math'
|
||||
@@ -38,16 +38,10 @@ import { clampToWall, hasWallChildOverlap } from './door-math'
|
||||
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
|
||||
// Snapshot of the door's "valid" state at move-start — used by
|
||||
// canCommit to decide whether the current snapped position is OK.
|
||||
const startLevelId = (() => {
|
||||
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
|
||||
// segment → roof → level. Cached at start because the parent chain
|
||||
// doesn't change during a move.
|
||||
const nodes = useScene.getState().nodes
|
||||
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
|
||||
if (roofLevelId) return roofLevelId
|
||||
const wall = nodes[node.parentId as AnyNodeId]
|
||||
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||
})()
|
||||
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
||||
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
||||
// Cached at start because the parent chain doesn't change during a move.
|
||||
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
||||
const originalWall = node.parentId
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
||||
: undefined
|
||||
@@ -57,6 +51,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
||||
metadata: node.metadata,
|
||||
// Absolute: query the wall snap with the TRUE cursor, 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
|
||||
@@ -72,13 +74,114 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
roofFace: undefined
|
||||
} | 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
|
||||
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
|
||||
// overlapping placement, matching the 3D move. Read in `canCommit` so a Shift-
|
||||
// held commit over a collision lands instead of reverting.
|
||||
let forcePlace = false
|
||||
|
||||
// Move SFX — parity with the 3D `MoveDoorTool`: ONE soft `sfx:grid-snap` click
|
||||
// per grid step, identical free-following or sliding on a wall, keyed on the
|
||||
// RAW cursor (not the snapped along-wall value). No separate floor→wall cue —
|
||||
// that distinct sound was the "double" the user heard. 2D `apply` runs once per
|
||||
// pointermove, so the step-key dedup is sufficient (no per-frame guard needed).
|
||||
const STEP_M = 0.1
|
||||
let lastStepKey: string | null = null
|
||||
const tickGridStep = (...coords: number[]) => {
|
||||
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
|
||||
if (key !== lastStepKey) {
|
||||
lastStepKey = key
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
// Reflect the R-flip on the floating ghost so its swing-arc faces the side
|
||||
// that will be committed (the synthetic wall is plan-X aligned, so a back
|
||||
// facing is a π yaw; the symbol builder also reads `side`).
|
||||
const ghostSide: DoorNode['side'] = flipped
|
||||
? node.side === 'front'
|
||||
? 'back'
|
||||
: 'front'
|
||||
: node.side
|
||||
const ghost = {
|
||||
...node,
|
||||
side: ghostSide,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
position: [half, node.position[1], 0] as [number, number, number],
|
||||
rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number],
|
||||
visible: true,
|
||||
} as DoorNode
|
||||
usePlacementPreview.getState().set(ghost, wall)
|
||||
}
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
flipSide() {
|
||||
flipped = !flipped
|
||||
if (lastApply) this.apply(lastApply)
|
||||
},
|
||||
apply({ planPoint, modifiers }) {
|
||||
lastApply = { planPoint, modifiers }
|
||||
forcePlace = modifiers.shiftKey === true
|
||||
// Drop any stale live transform left by the 3D `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 resolvedPlanPoint = resolveCursor(planPoint)
|
||||
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). Click per grid
|
||||
// cell as the ghost slides over open floor.
|
||||
tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1])
|
||||
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
|
||||
// openings / wall ends); it competes with — and wins over — the 0.5m
|
||||
@@ -97,10 +200,19 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
||||
|
||||
// One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`,
|
||||
// not the snapped value) so the wall slide ticks at the same cadence as the
|
||||
// off-wall ghost — the same SFX, no separate snap cue.
|
||||
tickGridStep(hit.localX)
|
||||
|
||||
// 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 = {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
@@ -122,11 +234,17 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
])
|
||||
},
|
||||
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
|
||||
if (!live || live.type !== 'door') return false
|
||||
// Block commit if the door overlaps any other wall child at its
|
||||
// current position. The 3D port has the same guard.
|
||||
const overlapping = hasWallChildOverlap(
|
||||
// Block commit if the door overlaps another wall child — UNLESS Shift
|
||||
// force-places (same `placeable` rule as the 3D move + the shared
|
||||
// `resolveOpeningPlacement`).
|
||||
const collides = hasWallChildOverlap(
|
||||
live.parentId as string,
|
||||
live.position[0],
|
||||
live.position[1],
|
||||
@@ -134,7 +252,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
live.height,
|
||||
live.id,
|
||||
)
|
||||
return !overlapping
|
||||
return resolveOpeningPlacement({ collides, forcePlace }).placeable
|
||||
},
|
||||
commit() {
|
||||
// Own the atomic write so the overlay takes the deterministic
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
DoorNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
@@ -25,16 +25,22 @@ import {
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
clearOpeningGuides3D,
|
||||
publishOpeningGuidesForWallEvent,
|
||||
} from '../shared/opening-guides-runtime'
|
||||
import {
|
||||
getRoofWallOpeningCursorPose,
|
||||
type RoofWallOpeningTarget,
|
||||
resolveRoofWallOpeningTarget,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import DoorPreview from './preview'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
@@ -46,6 +52,40 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
// The door preview ghost. Shown for the WHOLE move so the user always sees a
|
||||
// translucent door tinted by placement state — red off-wall or colliding,
|
||||
// green on a valid wall — exactly like the free-follow ghost. The real node
|
||||
// stays hidden until commit (the wall still cuts its hole from the node data,
|
||||
// so the opening reads correctly behind the ghost). `null` = not previewing
|
||||
// (committed / torn down). See the matching `DoorPreview` tint.
|
||||
const [ghostPose, setGhostPose] = useState<{
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
tint: 'valid' | 'invalid'
|
||||
// The door's facing side at the cursor. R-flip changes it mid-placement and
|
||||
// the door geometry's swing/hinge depends on it, so the ghost must rebuild
|
||||
// with the LIVE side — otherwise the preview shows the pre-flip orientation
|
||||
// while commit places the flipped one.
|
||||
side: DoorNode['side']
|
||||
} | null>(null)
|
||||
|
||||
// Ghost preview node: the moving door with a zeroed transform + the live
|
||||
// facing side. `updateDoorMesh` bakes `position`/`rotation` into the mesh (the
|
||||
// `<group>` wrapper already places it, so we zero those to avoid a double
|
||||
// offset) and reads `side` for the swing/hinge direction — so the ghost
|
||||
// matches exactly what commit will place, including an R-flip. Falls back to
|
||||
// the moving node's own side when no pose is active.
|
||||
const ghostSide = ghostPose?.side ?? movingDoorNode.side
|
||||
const ghostNode = useMemo(
|
||||
() => ({
|
||||
...movingDoorNode,
|
||||
side: ghostSide,
|
||||
position: [0, 0, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
}),
|
||||
[movingDoorNode, ghostSide],
|
||||
)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
@@ -71,6 +111,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
roofSegmentId: movingDoorNode.roofSegmentId,
|
||||
roofFace: movingDoorNode.roofFace,
|
||||
metadata: movingDoorNode.metadata,
|
||||
// Free-follow hides the node (visible:false); every revert path must
|
||||
// restore the original visibility or an existing door cancelled over open
|
||||
// floor would stay invisible.
|
||||
visible: movingDoorNode.visible,
|
||||
}
|
||||
|
||||
if (!isNew) {
|
||||
@@ -82,12 +126,52 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
let currentHostId: string | null = movingDoorNode.parentId
|
||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||
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
|
||||
// Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change
|
||||
// while free-following can re-run the ghost at the same spot with the new
|
||||
// facing/tint — no pointer move required.
|
||||
let lastFloorPoint: [number, number] | null = null
|
||||
// Live Shift state (force-place). Tracked here so the preview tint can be
|
||||
// re-evaluated when Shift is pressed/released with the pointer stationary —
|
||||
// the stored WallEvent carries a STALE shiftKey from the last move.
|
||||
let shiftHeld = false
|
||||
// Movement SFX: ONE soft `sfx:grid-snap` click each time the door crosses a
|
||||
// grid step — identical whether free-following over open floor or sliding
|
||||
// along a wall, so the two feel the same (the user's ask). Always keyed on
|
||||
// the RAW cursor position (continuous ~0.1m cadence), never the snapped
|
||||
// along-wall value, so the wall slide ticks at the same rate as the ghost.
|
||||
// Two guards prevent a doubled/flammed cue: `lastStepKey` (emit only when
|
||||
// the quantized cell changes) AND `lastTickFrame` (at most one tick per DOM
|
||||
// pointermove — a wall mesh can emit `wall:move` more than once per move, and
|
||||
// the grid + wall paths can both run). No separate snap cue: a distinct
|
||||
// floor→wall sound was the "double" the user heard.
|
||||
const STEP_M = 0.1
|
||||
let lastStepKey: string | null = null
|
||||
let lastTickFrame = -1
|
||||
const tickGridStep = (frame: number, ...coords: number[]) => {
|
||||
if (frame === lastTickFrame) return
|
||||
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
|
||||
if (key === lastStepKey) return
|
||||
lastStepKey = key
|
||||
lastTickFrame = frame
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
// The 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: {
|
||||
wallNode: WallEvent['node']
|
||||
wallId: string
|
||||
side: DoorNode['side']
|
||||
itemRotation: number
|
||||
cursorRotation: number
|
||||
clampedX: number
|
||||
clampedY: number
|
||||
valid: boolean
|
||||
@@ -125,6 +209,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
setGhostPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||
@@ -149,13 +235,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const getPlacementOrientation = (event: WallEvent) => {
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = movingDoorNode.side ?? faceSide
|
||||
const side = sideOverride ?? faceSide
|
||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||
return {
|
||||
side,
|
||||
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
|
||||
cursorRotation:
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +251,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
const { side, itemRotation } = getPlacementOrientation(event)
|
||||
|
||||
const rawLocalX = event.localPosition[0]
|
||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||
@@ -183,8 +267,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
// Alt still hard-disables alignment (no guides). Shift = free-place:
|
||||
// land at the raw cursor but keep showing the alignment guides.
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
freePlace: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -207,7 +293,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
wallId: event.node.id,
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotation,
|
||||
clampedX,
|
||||
clampedY,
|
||||
valid,
|
||||
@@ -216,6 +301,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||
// Same click as the off-wall ghost: one grid-snap tick per grid step,
|
||||
// keyed on the RAW cursor along-wall position (not the snapped clampedX,
|
||||
// whose ~0.5m jumps would tick at a different cadence). Per-frame guard
|
||||
// collapses any duplicate wall events on the same pointermove.
|
||||
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
|
||||
// Keep the REAL node hidden and show a tinted ghost in the wall opening —
|
||||
// green when placeable, red when it collides — the same translucent ghost
|
||||
// the free-follow uses, so validity reads at a glance. The node position is
|
||||
// still written (so the wall cuts the hole at the right spot) but
|
||||
// `visible:false` keeps the pale solid mesh from competing with the ghost.
|
||||
if (currentHostId !== target.wallId) {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [target.clampedX, target.clampedY, 0],
|
||||
@@ -225,6 +320,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
wallId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
visible: false,
|
||||
})
|
||||
markHostDirty(currentHostId)
|
||||
currentHostId = target.wallId
|
||||
@@ -242,25 +338,60 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
})
|
||||
markHostDirtyThrottled(target.wallId)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
// Position the tinted ghost at the wall opening (world frame), facing the
|
||||
// wall normal + the live side (so an R-flip shows correctly). The
|
||||
// wireframe cursor is no longer used on a wall. Tint comes from the SHARED
|
||||
// placement decision — green when placeable (incl. Shift force-place over a
|
||||
// collision), red otherwise — the SAME `placeable` the commit gate uses.
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
const placement = resolveOpeningPlacement({
|
||||
collides: !target.valid,
|
||||
forcePlace: shiftHeld,
|
||||
})
|
||||
// The committed door is a CHILD of the wall mesh (group yaw = -wallAngle)
|
||||
// with wall-local `itemRotation` (0 front / π back). The ghost is a
|
||||
// scene-root world-space group, so its world yaw must be
|
||||
// `-wallAngle + itemRotation` to face the same way as commit.
|
||||
// `cursorRotation` (the old symmetric-wireframe yaw) is π off here.
|
||||
const wallAngle = Math.atan2(
|
||||
target.wallNode.end[1] - target.wallNode.start[1],
|
||||
target.wallNode.end[0] - target.wallNode.start[0],
|
||||
)
|
||||
setGhostPose({
|
||||
position: wallLocalToWorld(
|
||||
target.wallNode,
|
||||
target.clampedX,
|
||||
target.clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(target.event),
|
||||
),
|
||||
target.cursorRotation,
|
||||
target.valid,
|
||||
)
|
||||
rotationY: target.itemRotation - wallAngle,
|
||||
tint: placement.tint,
|
||||
side: target.side,
|
||||
})
|
||||
|
||||
publishOpeningGuidesForWallEvent({
|
||||
wall: target.wallNode,
|
||||
movingId: movingDoorNode.id,
|
||||
centerS: target.clampedX,
|
||||
centerY: target.clampedY,
|
||||
width: movingDoorNode.width,
|
||||
height: movingDoorNode.height,
|
||||
// Doors sit on the floor — no sill/head or vertical alignment guides.
|
||||
includeVertical: false,
|
||||
levelYOffset: getLevelYOffset(),
|
||||
slabElevation: getSlabElevation(target.event),
|
||||
})
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
@@ -268,6 +399,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
@@ -286,20 +418,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
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 (!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
|
||||
|
||||
let placedId: string
|
||||
@@ -320,6 +449,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
parentId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
// The moving node is hidden during free-follow; the committed door
|
||||
// must be visible regardless of the pre-commit free-follow state.
|
||||
visible: true,
|
||||
})
|
||||
useScene.getState().createNode(node, target.wallId as AnyNodeId)
|
||||
placedId = node.id
|
||||
@@ -333,6 +465,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
@@ -344,6 +477,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
wallId: target.wallId,
|
||||
roofSegmentId: undefined,
|
||||
metadata: {},
|
||||
visible: true,
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== target.wallId) {
|
||||
@@ -360,30 +494,116 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
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)
|
||||
// Shift force-places: commit even when the door overlaps another opening.
|
||||
// The preview keeps its red invalid tint as a warning; Shift just lifts the
|
||||
// commit block. Read shift from THIS event so it's never stale at commit.
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
commitToWall(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
// The cursor left the wall mesh. Don't snap back to the origin/original
|
||||
// here — the floor proximity handler (onGridMove) takes over on the same
|
||||
// pointermove: it 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()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
}
|
||||
|
||||
// Reveal the real door node + drop the ghost. Used by the roof-face path,
|
||||
// which previews with the real mesh (the ghost-tint flow is wall-specific).
|
||||
const revealRealNode = () => {
|
||||
setGhostPose(null)
|
||||
const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
|
||||
if (live && live.visible === false) {
|
||||
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
// Free-follow: over open floor there's no wall to host the door, so instead
|
||||
// of dragging the real (pale, near-invisible-on-grid) node around we hide it
|
||||
// and float a red translucent ghost at the cursor — same treatment the raw
|
||||
// `DoorTool` build path uses. The node still re-parents to the level so a
|
||||
// later wall-snap / commit has a clean base, but stays `visible:false` until
|
||||
// a wall is hovered.
|
||||
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
|
||||
freeFollowing = true
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
// Click per grid cell as the ghost slides over open floor (X+Z) — the
|
||||
// same `tickGridStep` the on-wall slide uses, so both feel identical.
|
||||
tickGridStep(frame, localX, localZ)
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(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,
|
||||
visible: false,
|
||||
})
|
||||
currentHostId = levelId
|
||||
} else {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [localX, y, localZ],
|
||||
rotation: [0, yaw, 0],
|
||||
side: sideOverride,
|
||||
visible: false,
|
||||
})
|
||||
}
|
||||
// Float the red (invalid — no wall) ghost at the cursor, level-Y lifted so
|
||||
// it stands on the floor, matching the door's chosen facing (sideOverride
|
||||
// carries the R-flip so the ghost swing direction matches commit).
|
||||
setGhostPose({
|
||||
position: [localX, getLevelYOffset() + y, localZ],
|
||||
rotationY: yaw,
|
||||
tint: 'invalid',
|
||||
side: sideOverride,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
||||
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
||||
// the cursor is over open floor — free-follow it.
|
||||
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||
|
||||
// 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
|
||||
lastFloorPoint = [x, z]
|
||||
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -406,16 +626,23 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target) {
|
||||
onRoofLeave()
|
||||
return
|
||||
}
|
||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||
freeFollowing = false
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = event
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
// Opening guides are wall-specific; clear them when over a roof face.
|
||||
clearOpeningGuides3D()
|
||||
// On a roof face the real mesh is the preview — drop the free-follow ghost
|
||||
// and reveal the node.
|
||||
revealRealNode()
|
||||
if (currentHostId !== target.segment.id) {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: target.position,
|
||||
@@ -425,6 +652,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
wallId: undefined,
|
||||
roofSegmentId: target.segment.id,
|
||||
roofFace: target.face.id,
|
||||
visible: true,
|
||||
})
|
||||
markHostDirty(currentHostId)
|
||||
currentHostId = target.segment.id
|
||||
@@ -442,7 +670,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
// Shift force-places over a colliding roof-face target too (see onWallClick).
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
committed = true
|
||||
const segmentId = target.segment.id
|
||||
|
||||
@@ -464,6 +694,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.face.id,
|
||||
parentId: segmentId,
|
||||
visible: true,
|
||||
})
|
||||
useScene.getState().createNode(node, segmentId as AnyNodeId)
|
||||
placedId = node.id
|
||||
@@ -477,6 +708,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
@@ -489,6 +721,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
roofSegmentId: segmentId,
|
||||
roofFace: target.face.id,
|
||||
metadata: {},
|
||||
visible: true,
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== segmentId) {
|
||||
@@ -509,26 +742,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onRoofLeave = () => {
|
||||
// Mirror onWallLeave: don't revert to origin here — onGridMove takes
|
||||
// over on the same pointermove (snap to a nearby wall or free-follow).
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = 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 = () => {
|
||||
@@ -546,6 +766,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
@@ -556,13 +777,74 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (lastTarget) {
|
||||
onWallClick(lastTarget.event)
|
||||
// Free-following over open floor can't commit (no wall). A wall hover
|
||||
// target commits via commitToWall; a roof face via onRoofClick. Shift
|
||||
// force-places over a colliding wall target (the tint stays red as a
|
||||
// warning); read shift from this pointerup so it's current at commit.
|
||||
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
|
||||
commitToWall(lastTarget)
|
||||
return
|
||||
}
|
||||
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||
}
|
||||
|
||||
// R flips the 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
|
||||
}
|
||||
// Ignore OS key-repeat so a held R doesn't flip many times per press.
|
||||
if (e.repeat) return
|
||||
e.preventDefault()
|
||||
// ALWAYS toggle the persistent flip intent — never a no-op. (The old gate
|
||||
// dropped R before the first pointermove, so initial-placement R needed a
|
||||
// second press.) Then re-render whatever preview is current so the flip
|
||||
// shows live and matches what commit will write.
|
||||
sideOverride = sideOverride === 'front' ? 'back' : 'front'
|
||||
triggerSFX('sfx:item-rotate')
|
||||
if (lastTarget) {
|
||||
// On a wall: re-resolve with the flipped side and re-preview.
|
||||
const next = resolveMoveTarget(lastTarget.event)
|
||||
if (next) {
|
||||
lastTarget = next
|
||||
applyPreview(next)
|
||||
}
|
||||
} else if (lastFloorPoint) {
|
||||
// Free-following: re-run at the same spot so the floating ghost rebuilds
|
||||
// with the flipped side (its swing/hinge geometry depends on `side`).
|
||||
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
|
||||
} else {
|
||||
// No preview yet (R pressed before the first pointermove at initial
|
||||
// placement): flip the hidden node so the FIRST preview/commit already
|
||||
// reflects the chosen side.
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
side: sideOverride,
|
||||
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Shift toggles force-place. Track it live and re-run the on-wall preview so
|
||||
// the tint flips green↔red the instant Shift is pressed/released, even with
|
||||
// the pointer stationary — the ghost and the commit gate read the same
|
||||
// `placeable`. (Commit gates still read shift fresh from their own event.)
|
||||
const onShiftToggle = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Shift') return
|
||||
const held = e.type === 'keydown'
|
||||
if (held === shiftHeld) return
|
||||
shiftHeld = held
|
||||
if (!committed && lastTarget) applyPreview(lastTarget)
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
@@ -571,8 +853,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keydown', onShiftToggle)
|
||||
window.addEventListener('keyup', onShiftToggle)
|
||||
|
||||
return () => {
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||
@@ -593,12 +879,21 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
metadata: original.metadata,
|
||||
visible: original.visible,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
} else if (current && current.visible === false) {
|
||||
// Safety net: a fresh (isNew) clone isn't marked `isTransient`, so the
|
||||
// branch above skips it. If we unmount mid-free-follow it would be left
|
||||
// hidden — reveal it so it never becomes an invisible orphan. (The
|
||||
// `place-preset` movingNode subscription deletes a truly-cancelled
|
||||
// clone separately.)
|
||||
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
|
||||
}
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
@@ -608,8 +903,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keydown', onShiftToggle)
|
||||
window.removeEventListener('keyup', onShiftToggle)
|
||||
}
|
||||
}, [movingDoorNode, exitMoveMode])
|
||||
|
||||
@@ -623,11 +922,26 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
boxGeo.dispose()
|
||||
return geo
|
||||
}, [movingDoorNode])
|
||||
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
<>
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
{/* Placement ghost shown for the whole move (the real pale node stays
|
||||
hidden): red off-wall / colliding, green on a valid wall. Uses the
|
||||
moving node's own dimensions so the ghost matches its type. */}
|
||||
{ghostPose && (
|
||||
<group position={ghostPose.position} rotation-y={ghostPose.rotationY}>
|
||||
<DoorPreview
|
||||
invalid={ghostPose.tint === 'invalid'}
|
||||
node={ghostNode}
|
||||
valid={ghostPose.tint === 'valid'}
|
||||
/>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -516,7 +516,7 @@ export default function DoorPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/door.png"
|
||||
icon="/icons/door.webp"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Door'}
|
||||
width={320}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'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,
|
||||
valid,
|
||||
}: {
|
||||
node: DoorNode
|
||||
invalid?: boolean
|
||||
valid?: 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 the tint flips;
|
||||
// its cleanup only disposes the clones it made.
|
||||
useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid])
|
||||
|
||||
// Geometry is freshly built per `mesh` and owned here — dispose it only
|
||||
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
|
||||
useEffect(
|
||||
() => () => {
|
||||
mesh.traverse((obj) => {
|
||||
const m = obj as { geometry?: { dispose: () => void } }
|
||||
m.geometry?.dispose()
|
||||
})
|
||||
},
|
||||
[mesh],
|
||||
)
|
||||
|
||||
return <primitive object={mesh} />
|
||||
}
|
||||
|
||||
export default DoorPreview
|
||||
+342
-226
@@ -11,6 +11,7 @@ import {
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
@@ -22,9 +23,13 @@ import {
|
||||
useAlignmentGuides,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
clearOpeningGuides3D,
|
||||
publishOpeningGuidesForWallEvent,
|
||||
} from '../shared/opening-guides-runtime'
|
||||
import {
|
||||
getRoofWallOpeningCursorPose,
|
||||
type RoofWallOpeningTarget,
|
||||
@@ -33,6 +38,7 @@ import {
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import DoorPreview from './preview'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
@@ -45,31 +51,76 @@ const FALLBACK_WIDTH = 0.9
|
||||
const FALLBACK_HEIGHT = 2.1
|
||||
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
|
||||
* (the generated base walls under a roof, including coplanar gable ends).
|
||||
* Doors always sit at floor level (clampedY = height/2 — segment base for
|
||||
* 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 draftRef = useRef<DoorNode | null>(null)
|
||||
const cursorGroupRef = useRef<Group>(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. `side`
|
||||
// carries the R-flip so the floating ghost faces the side that will be
|
||||
// committed (its swing/hinge geometry depends on `side`).
|
||||
const [fallbackPose, setFallbackPose] = useState<{
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
side: DoorNode['side']
|
||||
} | null>(null)
|
||||
|
||||
// Ghost preview node — zeroed transform + the live facing side (rebuilds on R).
|
||||
const ghostStub = useMemo(
|
||||
() =>
|
||||
DoorNode.parse({
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: fallbackPose?.side ?? 'front',
|
||||
}),
|
||||
[fallbackPose?.side],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
let hostKind: HostKind = null
|
||||
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
||||
// the grid raycast from the SAME pointermove share the source DOM event's
|
||||
// timeStamp, so the grid handler can detect "a mesh handler already owns
|
||||
// this frame" without depending on event order or on a leave firing (node
|
||||
// events are suppressed during a camera drag, so a sticky boolean would
|
||||
// strand the draft after an orbit; a per-frame timestamp self-heals).
|
||||
let lastMeshEventTime = -1
|
||||
// R flips the 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
|
||||
// Last open-floor cursor point (level-local X/Z) so an R-flip while
|
||||
// free-following can re-render the floating ghost with the new facing.
|
||||
let lastFloorPoint: [number, number, number] | null = null
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
const getSlabElevationForWall = (wall: WallNode) =>
|
||||
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
|
||||
|
||||
const markHostDirty = (hostId: string) => {
|
||||
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||
@@ -86,17 +137,23 @@ const DoorTool: React.FC = () => {
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
setFallbackPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. A door aligns by the plan position of its centre.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
|
||||
// On-host cursor: the green/red wireframe outline tracks a live draft.
|
||||
// Showing it always clears the off-host floating ghost (they never
|
||||
// coexist — a draft means the cursor is on a valid host).
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
setFallbackPose(null)
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
@@ -105,237 +162,158 @@ const DoorTool: React.FC = () => {
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const showFallbackCursor = (event: GridEvent) => {
|
||||
if (draftRef.current) return
|
||||
const [x, y, z] = event.localPosition
|
||||
updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
// Off-host fallback: hide the wireframe outline and float the real door
|
||||
// geometry (tinted invalid) at the cursor so the armed tool is visible.
|
||||
// `sideFlip` (R) flips the facing: a back facing is a π yaw on the floating
|
||||
// ghost, and the door swing/hinge geometry reads `side`.
|
||||
const showGhostAt = (position: [number, number, number]) => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
setFallbackPose({
|
||||
position,
|
||||
rotationY: sideFlip ? Math.PI : 0,
|
||||
side: sideFlip ? 'back' : 'front',
|
||||
})
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
}
|
||||
|
||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
|
||||
}
|
||||
|
||||
const showWallFallbackCursor = (event: WallEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
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
|
||||
// Settle a wall target: alignment snap → clamp → overlap check. Pure read.
|
||||
const resolveWallPlacement = (
|
||||
wall: WallNode,
|
||||
rawLocalX: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bypass: boolean,
|
||||
bypassSnap: boolean,
|
||||
ignoreId?: string,
|
||||
) => {
|
||||
// bypassSnap is set by Shift (see callers). Shift = free-place: land at the
|
||||
// raw cursor but keep the along-wall guides visible. bypass (Alt) still
|
||||
// hard-disables alignment.
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
wallNode: wall,
|
||||
rawLocalX,
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
bypass: bypass && !bypassSnap,
|
||||
freePlace: bypassSnap,
|
||||
})
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
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 { clampedX, clampedY } = clampToWall(wall, localX, width, height)
|
||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
// Shared create/update path for the wall draft — used by the direct
|
||||
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
|
||||
// (reparenting only on an actual wall change to avoid churning the host's
|
||||
// children array, which flashes 0-vertex wall geometry in WebGPU).
|
||||
const applyWallTarget = (args: {
|
||||
wall: WallNode
|
||||
rawLocalX: number
|
||||
side: 'front' | 'back'
|
||||
itemRotation: number
|
||||
cursorRotationY: number
|
||||
bypass: boolean
|
||||
bypassSnap: boolean
|
||||
}) => {
|
||||
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args
|
||||
const width = draftRef.current?.width ?? 0.9
|
||||
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) {
|
||||
const levelId = getLevelId()
|
||||
if (levelId && event.node.parentId === levelId) {
|
||||
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 node = DoorNode.parse({
|
||||
position: [0, height / 2, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: wall.id,
|
||||
parentId: wall.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
|
||||
if (draftRef.current) {
|
||||
// Update the scene store on every move so the 2D floor plan
|
||||
// stays in sync (it re-renders from `node.position`). Only
|
||||
// forward `parentId` / `wallId` when the wall actually changed
|
||||
// — otherwise the reparent path churns the host wall's
|
||||
// `children` array every tick, which re-renders the wall and
|
||||
// briefly draws its 0-vertex placeholder geometry (WebGPU then
|
||||
// flags "Vertex buffer slot 0 ... was not set").
|
||||
const isSameWall = event.node.id === draftRef.current.parentId
|
||||
if (isSameWall) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markHostDirty(event.node.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
// The draft may arrive from a roof-segment face hover.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
const { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||
wall,
|
||||
rawLocalX,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
draftRef.current.id,
|
||||
)
|
||||
|
||||
if (wall.id === draftRef.current.parentId) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markHostDirty(wall.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
// The draft may arrive from a roof-segment face hover.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
wall,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
getSlabElevationForWall(wall),
|
||||
),
|
||||
cursorRotation,
|
||||
cursorRotationY,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
|
||||
if (draftRef.current) {
|
||||
publishOpeningGuidesForWallEvent({
|
||||
wall,
|
||||
movingId: draftRef.current.id,
|
||||
centerS: clampedX,
|
||||
centerY: clampedY,
|
||||
width,
|
||||
height,
|
||||
includeVertical: false,
|
||||
levelYOffset: getLevelYOffset(),
|
||||
slabElevation: getSlabElevationForWall(wall),
|
||||
})
|
||||
}
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: draftRef.current.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const { 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
|
||||
|
||||
// Promote the draft into a permanent door. Shared by the wall-mesh click
|
||||
// and the floor proximity click.
|
||||
const commitDoorAtWall = (
|
||||
wall: WallNode,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
side: 'front' | 'back',
|
||||
itemRotation: number,
|
||||
) => {
|
||||
const draft = draftRef.current
|
||||
if (!draft) return
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -344,18 +322,17 @@ const DoorTool: React.FC = () => {
|
||||
const state = useScene.getState()
|
||||
const doorCount = Object.values(state.nodes).filter((n) => {
|
||||
if (n.type !== 'door') return false
|
||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return wall?.parentId === levelId
|
||||
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return w?.parentId === levelId
|
||||
}).length
|
||||
const name = `Door ${doorCount + 1}`
|
||||
|
||||
const node = DoorNode.parse({
|
||||
name,
|
||||
name: `Door ${doorCount + 1}`,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
wallId: wall.id,
|
||||
parentId: wall.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
doorCategory: draft.doorCategory,
|
||||
@@ -380,19 +357,110 @@ const DoorTool: React.FC = () => {
|
||||
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] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:structure-build')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
}
|
||||
|
||||
// ── Direct wall-mesh hover ──────────────────────────────────────
|
||||
const onWallHover = (event: WallEvent) => {
|
||||
hostKind = 'wall'
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (
|
||||
!isValidWallSideFace(event.normal) ||
|
||||
isCurvedWall(event.node) ||
|
||||
event.node.parentId !== getLevelId()
|
||||
) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
lastWallEvent = event
|
||||
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
|
||||
const flipOffset = sideFlip ? Math.PI : 0
|
||||
const itemRotation = calculateItemRotation(event.normal) + flipOffset
|
||||
const cursorRotation =
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypass = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
applyWallTarget({
|
||||
wall: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
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,
|
||||
)
|
||||
// Shift force-places over a collision (the draft stays red as a warning).
|
||||
if (!valid && !bypassSnap) return
|
||||
|
||||
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
if (hostKind !== 'wall') return
|
||||
lastWallEvent = null
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
// ── Floor free-follow ───────────────────────────────────────────
|
||||
// Over open floor the ghost follows the cursor like a moving item. It does
|
||||
// NOT snap from proximity — snapping engages only when the cursor ray
|
||||
// actually hovers a wall (onWallHover) or roof face (onRoofHover).
|
||||
const onGridFreeFollow = (event: GridEvent) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler processed this exact pointermove (R3F + the
|
||||
// grid raycast share the source DOM event's timeStamp) — it owns the
|
||||
// frame and has snapped the draft, so skip the floor follow this tick.
|
||||
const ts = event.nativeEvent?.timeStamp ?? -1
|
||||
if (ts === lastMeshEventTime) return
|
||||
// Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft
|
||||
// and free-follow the cursor with the invalid (unplaceable) ghost.
|
||||
hostKind = null
|
||||
lastWallEvent = null
|
||||
const [x, y, z] = event.localPosition
|
||||
lastFloorPoint = [x, y + FALLBACK_HEIGHT / 2, z]
|
||||
destroyDraft()
|
||||
showGhostAt(lastFloorPoint)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -414,6 +482,8 @@ const DoorTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
hostKind = 'roof'
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveRoofTarget(event)
|
||||
if (!target) {
|
||||
// On the roof but not over a placeable wall face (slope, soffit,
|
||||
@@ -444,6 +514,8 @@ const DoorTool: React.FC = () => {
|
||||
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
// Opening guides are wall-specific; clear them while over a roof face.
|
||||
clearOpeningGuides3D()
|
||||
updateRoofCursor(target, event.node as RoofNode)
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -451,11 +523,14 @@ const DoorTool: React.FC = () => {
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (!draftRef.current?.roofSegmentId) return
|
||||
const target = resolveRoofTarget(event)
|
||||
if (!target?.valid) return
|
||||
// Shift force-places over a colliding roof-face target (see onWallClick).
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
const { segment, face, position } = target
|
||||
|
||||
const draft = draftRef.current
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -508,59 +583,100 @@ const DoorTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onRoofLeave = () => {
|
||||
if (!draftRef.current?.roofSegmentId) return
|
||||
if (hostKind !== 'roof') return
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
// R flips the door's facing side mid-placement (front ↔ back), like the
|
||||
// committed-selected R flip. ALWAYS toggles the persistent flip intent —
|
||||
// never a no-op (the old `!lastWallEvent` guard dropped R off-wall and before
|
||||
// the first wall hover, so it "needed two presses"). Then re-renders whatever
|
||||
// preview is current so the flip shows live and matches commit.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
if (e.repeat) return
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||
e.preventDefault()
|
||||
sideFlip = !sideFlip
|
||||
triggerSFX('sfx:item-rotate')
|
||||
if (lastWallEvent) {
|
||||
// On a wall: re-resolve + re-preview with the flipped side.
|
||||
onWallHover(lastWallEvent)
|
||||
} else if (lastFloorPoint) {
|
||||
// Off-wall: re-render the floating ghost (showGhostAt reads `sideFlip`).
|
||||
showGhostAt(lastFloorPoint)
|
||||
}
|
||||
// else: no preview yet — `sideFlip` is set, so the first hover/follow uses it.
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallHover)
|
||||
emitter.on('wall:move', onWallHover)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('roof:enter', onRoofHover)
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', showFallbackCursor)
|
||||
emitter.on('grid:move', onGridFreeFollow)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:enter', onWallHover)
|
||||
emitter.off('wall:move', onWallHover)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('roof:enter', onRoofHover)
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', showFallbackCursor)
|
||||
emitter.off('grid:move', onGridFreeFollow)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cursor geometry: door outline.
|
||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
// Cursor geometry: door outline. Static dims, so build it once and dispose on
|
||||
// unmount rather than reallocating (and orphaning) an EdgesGeometry on every
|
||||
// re-render during placement.
|
||||
const edgesGeo = useMemo(() => {
|
||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||
const geo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
return geo
|
||||
}, [])
|
||||
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
<>
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
{fallbackPose && (
|
||||
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
|
||||
<DoorPreview invalid node={ghostStub} />
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user