fix(editor): door/window move — fix 2D+3D FPS collapse + finish modifier migration
The 3D MoveDoor/MoveWindow tools wrote useScene every frame during a move (freeFollowAt + applyPreview alternating): the wall:move (R3F) / grid:move (DOM) de-dup compared event.timeStamp across two event systems with different clocks, so it never matched and the floor free-follow ran during on-wall slides too, ping-ponging the host and churning the nodes ref → framerate collapse in both 2D and 3D. Replace it with a single-clock wall-ownership window (performance.now, ~4 frames): the floor follow stands down while a wall/roof hit is fresh. On-wall slides now write no scene per frame (mesh + useLiveTransforms only). Lower the live wall-cutout throttle 120→60ms now that the per-frame churn is gone. Also completes the door/window modifier-model migration (#10): Shift=cycle / Alt=force-place, fully mode-driven snap, snapProfile:'item'; exclude ground-line candidates from along-wall opening alignment; emit the move SFX once per snapped step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8a57105eec
commit
76096ffe72
@@ -166,6 +166,7 @@ const doorHandles: HandleDescriptor<DoorNodeType>[] = [
|
||||
*/
|
||||
export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
kind: 'door',
|
||||
snapProfile: 'item',
|
||||
schemaVersion: 1,
|
||||
schema: DoorNode,
|
||||
category: 'structure',
|
||||
@@ -251,6 +252,8 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place door on wall' },
|
||||
{ key: 'R', label: 'Flip side' },
|
||||
{ key: 'Alt', label: 'Force place' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -3,12 +3,20 @@ import {
|
||||
type DoorNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import {
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePlacementPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
||||
import {
|
||||
@@ -36,6 +44,7 @@ import { clampToWall, hasWallChildOverlap } from './door-math'
|
||||
*/
|
||||
|
||||
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
|
||||
const nodeId = node.id as AnyNodeId
|
||||
// Snapshot of the door's "valid" state at move-start — used by
|
||||
// canCommit to decide whether the current snapped position is OK.
|
||||
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
||||
@@ -72,6 +81,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
wallId: string
|
||||
roofSegmentId: undefined
|
||||
roofFace: undefined
|
||||
visible: true
|
||||
} | null = null
|
||||
|
||||
// R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives
|
||||
@@ -88,20 +98,30 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
// 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-
|
||||
// Alt force-place (last apply's modifier) — lets `canCommit` allow an
|
||||
// overlapping placement, matching the 3D move. Read in `canCommit` so an Alt-
|
||||
// held commit over a collision lands instead of reverting.
|
||||
let forcePlace = false
|
||||
let liveTransformActive = useLiveTransforms.getState().transforms.has(nodeId)
|
||||
let liveOverrideKey: string | null = null
|
||||
let placementPreviewActive = usePlacementPreview.getState().node?.id === nodeId
|
||||
|
||||
const setLiveOverride = (key: string, values: Record<string, unknown>) => {
|
||||
if (liveOverrideKey === key) return
|
||||
liveOverrideKey = key
|
||||
useLiveNodeOverrides.getState().set(nodeId, values)
|
||||
}
|
||||
|
||||
// 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
|
||||
// each time the door's PLACED position crosses a step. Keyed on the SNAPPED
|
||||
// position, quantized by the live grid step in grid mode else a gentle fixed
|
||||
// cadence — so grid mode ticks once per cell (not on every micro mouse-move
|
||||
// while the door sits in a cell) while lines/off still tick as the door moves.
|
||||
const FREE_STEP_M = 0.1
|
||||
let lastStepKey: string | null = null
|
||||
const tickGridStep = (...coords: number[]) => {
|
||||
const key = coords.map((c) => Math.round(c / STEP_M)).join(',')
|
||||
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M
|
||||
const key = coords.map((c) => Math.round(c / step)).join(',')
|
||||
if (key !== lastStepKey) {
|
||||
lastStepKey = key
|
||||
triggerSFX('sfx:grid-snap')
|
||||
@@ -115,9 +135,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
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 })
|
||||
if (liveTransformActive) {
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
liveTransformActive = false
|
||||
}
|
||||
setLiveOverride('free-follow', { visible: false })
|
||||
const half = node.width / 2 + 0.5
|
||||
const wall = WallNodeSchema.parse({
|
||||
start: [planPoint[0] - half, planPoint[1]],
|
||||
@@ -144,28 +166,18 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
visible: true,
|
||||
} as DoorNode
|
||||
usePlacementPreview.getState().set(ghost, wall)
|
||||
placementPreviewActive = true
|
||||
}
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
affectedIds: [nodeId],
|
||||
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)
|
||||
}
|
||||
forcePlace = modifiers.altKey === true
|
||||
const nodes = useScene.getState().nodes
|
||||
const resolvedPlanPoint = resolveCursor(planPoint)
|
||||
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
||||
@@ -178,32 +190,30 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
}
|
||||
// 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 })
|
||||
if (placementPreviewActive) {
|
||||
usePlacementPreview.getState().clear()
|
||||
placementPreviewActive = false
|
||||
}
|
||||
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends); it competes with — and wins over — the 0.5m
|
||||
// grid snap. Falls back to the grid snap when nothing aligns. Alt
|
||||
// bypasses alignment; Shift bypasses all snap.
|
||||
const neighborX =
|
||||
modifiers.altKey || modifiers.shiftKey
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: node.id as AnyNodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
// openings / wall ends); it competes with — and wins over — the grid
|
||||
// snap. Follows the magnetic ("lines") mode; the grid component lives in
|
||||
// `snapToHalf` (mode-aware → raw when grid is off).
|
||||
const neighborX = !isMagneticSnapActive()
|
||||
? null
|
||||
: snapLocalXToNeighbors({
|
||||
wall: hit.wall,
|
||||
localX: hit.localX,
|
||||
width: node.width,
|
||||
selfId: nodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? 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)
|
||||
// One click per real position step, keyed on the SNAPPED along-wall value
|
||||
// so it ticks only when the door actually moves to a new cell.
|
||||
tickGridStep(clampedX)
|
||||
|
||||
// Apply the R-flip on top of the wall-derived side.
|
||||
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
|
||||
@@ -219,35 +229,38 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
// overlay's snapshot restores it if the move is reverted.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
visible: true,
|
||||
}
|
||||
|
||||
// Build the updates atomically — position + rotation + side +
|
||||
// parentId + wallId in a single scene write. The current door's
|
||||
// parent might be a different wall; re-anchoring requires moving
|
||||
// the node in the parent's children list (the registry's
|
||||
// updateNode does this when parentId changes).
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: lastValid,
|
||||
},
|
||||
])
|
||||
setLiveOverride(`wall:${hit.wall.id}:${side}`, {
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
side,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
visible: true,
|
||||
})
|
||||
useLiveTransforms.getState().set(nodeId, {
|
||||
position: lastValid.position,
|
||||
rotation: itemRotation,
|
||||
})
|
||||
liveTransformActive = true
|
||||
},
|
||||
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 another wall child — UNLESS Shift
|
||||
if (!onWall || !lastValid) return false
|
||||
const live = useScene.getState().nodes[nodeId] as DoorNode | undefined
|
||||
if (live?.type !== 'door') return false
|
||||
// Block commit if the door overlaps another wall child — UNLESS Alt
|
||||
// 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],
|
||||
lastValid.parentId,
|
||||
lastValid.position[0],
|
||||
lastValid.position[1],
|
||||
live.width,
|
||||
live.height,
|
||||
live.id,
|
||||
@@ -266,7 +279,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
if (!lastValid) return
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
id: nodeId,
|
||||
data: lastValid,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
DoorNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
isValidWallSideFace,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
@@ -38,7 +39,10 @@ import {
|
||||
resolveRoofWallOpeningTarget,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveOpeningPlacement } from '../shared/wall-attach-target'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import {
|
||||
collectWallOpeningAlignmentCandidates,
|
||||
resolveWallSlideAlignment,
|
||||
} from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import DoorPreview from './preview'
|
||||
|
||||
@@ -129,35 +133,48 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
// 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.
|
||||
// commit no-ops in open space (a door needs a wall).
|
||||
let freeFollowing = false
|
||||
let lastMeshEventTime = -1
|
||||
// Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change
|
||||
// Last open-floor cursor point (level-local X/Z), so an R-flip or Alt 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
|
||||
// The floor free-follow (`grid:move`, a DOM event) and the wall/roof snap
|
||||
// (`wall:move`/`roof:move`, R3F mesh events) are INDEPENDENT event streams
|
||||
// with different clocks, so the old `event.timeStamp` de-dup never matched —
|
||||
// the free-follow ran during on-wall slides too, and both wrote the scene
|
||||
// node every frame (a per-frame `nodes` churn that tanked 2D + 3D framerate).
|
||||
// Instead, stamp one monotonic clock whenever a wall/roof hit owns the
|
||||
// pointer; the floor handler stands down while that stamp is fresh. `wall:move`
|
||||
// fires every frame on-wall, so the stamp stays fresh across the pointermove
|
||||
// interval and the free-follow only re-engages once the cursor is off any wall.
|
||||
let wallOwnedPointerAt = Number.NEGATIVE_INFINITY
|
||||
// ~4 frames: comfortably longer than the pointermove interval (so a fast
|
||||
// on-wall slide never lets the floor follow slip through) yet short enough
|
||||
// that leaving a wall re-engages the free-follow without a perceptible stick.
|
||||
const WALL_OWNS_POINTER_MS = 64
|
||||
const markWallOwnedPointer = () => {
|
||||
wallOwnedPointerAt = performance.now()
|
||||
}
|
||||
const wallOwnsPointer = () => performance.now() - wallOwnedPointerAt < WALL_OWNS_POINTER_MS
|
||||
// Live Alt state (force-place). Tracked here so the preview tint can be
|
||||
// re-evaluated when Alt is pressed/released with the pointer stationary —
|
||||
// the stored WallEvent carries a STALE altKey from the last move.
|
||||
let altHeld = false
|
||||
// Movement SFX: ONE soft `sfx:grid-snap` click each time the door's PLACED
|
||||
// position crosses a step. Keyed on the SNAPPED value (passed by the caller),
|
||||
// quantized by the live grid step in grid mode, else a gentle fixed cadence —
|
||||
// so grid mode ticks once per cell (not on every micro mouse-move while the
|
||||
// door sits in a cell) while lines/off still tick as the door moves. Two
|
||||
// guards prevent a doubled cue: `lastStepKey` (cell change) + `lastTickFrame`
|
||||
// (one per pointermove — wall + grid paths can both run on the same move).
|
||||
const FREE_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(',')
|
||||
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : FREE_STEP_M
|
||||
const key = coords.map((c) => Math.round(c / step)).join(',')
|
||||
if (key === lastStepKey) return
|
||||
lastStepKey = key
|
||||
lastTickFrame = frame
|
||||
@@ -188,7 +205,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
const now = globalThis.performance?.now?.() ?? Date.now()
|
||||
const last = lastHostDirtyAt.get(hostId) ?? 0
|
||||
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
|
||||
if (now - last > 120) {
|
||||
if (now - last > 60) {
|
||||
lastHostDirtyAt.set(hostId, now)
|
||||
markHostDirty(hostId)
|
||||
}
|
||||
@@ -213,9 +230,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
setGhostPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||
// moving door is excluded so it never aligns to itself).
|
||||
const alignmentCandidates = collectAlignmentAnchors(
|
||||
// Alignment candidates — only OTHER things on a wall (sibling openings +
|
||||
// wall-mounted items), never ground objects, so the along-wall guides don't
|
||||
// line up with furniture on the floor. The moving door is excluded.
|
||||
const alignmentCandidates = collectWallOpeningAlignmentCandidates(
|
||||
useScene.getState().nodes,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
@@ -267,10 +285,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingDoorNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
// 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,
|
||||
// Along-wall alignment follows the magnetic ("lines") mode; the grid
|
||||
// component lives in `snapToHalf` (itself mode-aware).
|
||||
bypass: !isMagneticSnapActive(),
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
@@ -301,11 +318,11 @@ 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])
|
||||
// One grid-snap tick per real position step, keyed on the SNAPPED
|
||||
// along-wall position so it ticks only when the door actually moves to a
|
||||
// new cell (not on every micro mouse-move). Per-frame guard collapses any
|
||||
// duplicate wall events on the same pointermove.
|
||||
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.clampedX)
|
||||
// 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
|
||||
@@ -341,12 +358,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
// 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
|
||||
// placement decision — green when placeable (incl. Alt 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,
|
||||
forcePlace: altHeld,
|
||||
})
|
||||
// The committed door is a CHILD of the wall mesh (group yaw = -wallAngle)
|
||||
// with wall-local `itemRotation` (0 front / π back). The ghost is a
|
||||
@@ -385,12 +402,14 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
// Valid wall hit owns the pointer for the next few frames; the floor
|
||||
// free-follow stands down until the cursor genuinely leaves the wall.
|
||||
markWallOwnedPointer()
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
@@ -399,7 +418,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
@@ -418,6 +436,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
// Valid wall hit owns the pointer for the next few frames; the floor
|
||||
// free-follow stands down until the cursor genuinely leaves the wall.
|
||||
markWallOwnedPointer()
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
@@ -503,11 +524,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
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.
|
||||
// Alt force-places: commit even when the door overlaps another opening.
|
||||
// The preview keeps its red invalid tint as a warning; Alt just lifts the
|
||||
// commit block. Read alt from THIS event so it's never stale at commit.
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
if (!target.valid && event.nativeEvent?.altKey !== true) return
|
||||
commitToWall(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -543,13 +564,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
// `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) => {
|
||||
const freeFollowAt = (localX: number, localZ: 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)
|
||||
// No snap SFX here: the free-follow fires off-wall (an invalid red ghost,
|
||||
// not a placeable position) AND interleaves with the on-wall slide on the
|
||||
// same pointer move (R3F `wall:move` and DOM `grid:move` carry different
|
||||
// timestamps, so the de-dupe guard can't merge them). Emitting here was the
|
||||
// source of the constant click while sliding a door along a wall — the
|
||||
// on-wall `applyPreview` already ticks once per along-wall cell.
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
const levelId = getLevelId()
|
||||
@@ -593,17 +617,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
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.
|
||||
// (`onWallMove`). A wall/roof handler owning the pointer right now means the
|
||||
// cursor is on a wall/roof that snaps — skip the floor follow (see
|
||||
// `wallOwnsPointer`). Over open floor the door just follows the cursor.
|
||||
if (wallOwnsPointer()) return
|
||||
const [x, , z] = event.localPosition
|
||||
lastFloorPoint = [x, z]
|
||||
freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1)
|
||||
freeFollowAt(x, z)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -626,12 +648,14 @@ 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
|
||||
}
|
||||
// Valid roof hit owns the pointer for the next few frames; the floor
|
||||
// free-follow stands down until the cursor genuinely leaves the roof.
|
||||
markWallOwnedPointer()
|
||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||
freeFollowing = false
|
||||
dragAnchor = null
|
||||
@@ -670,9 +694,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (committed) return
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
// Shift force-places over a colliding roof-face target too (see onWallClick).
|
||||
// Alt force-places over a colliding roof-face target too (see onWallClick).
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
if (!target.valid && event.nativeEvent?.altKey !== true) return
|
||||
committed = true
|
||||
const segmentId = target.segment.id
|
||||
|
||||
@@ -778,10 +802,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
// Free-following over open floor can't commit (no wall). A wall hover
|
||||
// target commits via commitToWall; a roof face via onRoofClick. Shift
|
||||
// target commits via commitToWall; a roof face via onRoofClick. Alt
|
||||
// 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)) {
|
||||
// warning); read alt from this pointerup so it's current at commit.
|
||||
if (lastTarget && !freeFollowing && (lastTarget.valid || event.altKey)) {
|
||||
commitToWall(lastTarget)
|
||||
return
|
||||
}
|
||||
@@ -821,7 +845,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
} 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)
|
||||
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1])
|
||||
} else {
|
||||
// No preview yet (R pressed before the first pointermove at initial
|
||||
// placement): flip the hidden node so the FIRST preview/commit already
|
||||
@@ -833,15 +857,15 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Alt toggles force-place. Track it live and re-run the on-wall preview so
|
||||
// the tint flips green↔red the instant Alt 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
|
||||
// `placeable`. (Commit gates still read alt fresh from their own event.)
|
||||
const onAltToggle = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Alt') return
|
||||
const held = e.type === 'keydown'
|
||||
if (held === shiftHeld) return
|
||||
shiftHeld = held
|
||||
if (held === altHeld) return
|
||||
altHeld = held
|
||||
if (!committed && lastTarget) applyPreview(lastTarget)
|
||||
}
|
||||
|
||||
@@ -857,8 +881,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keydown', onShiftToggle)
|
||||
window.addEventListener('keyup', onShiftToggle)
|
||||
window.addEventListener('keydown', onAltToggle)
|
||||
window.addEventListener('keyup', onAltToggle)
|
||||
|
||||
return () => {
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||
@@ -907,8 +931,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keydown', onShiftToggle)
|
||||
window.removeEventListener('keyup', onShiftToggle)
|
||||
window.removeEventListener('keydown', onAltToggle)
|
||||
window.removeEventListener('keyup', onAltToggle)
|
||||
}
|
||||
}, [movingDoorNode, exitMoveMode])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { type DoorNode, useLiveNodeOverrides, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { type Mesh, MeshBasicMaterial } from 'three'
|
||||
@@ -16,6 +16,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
const handlers = useNodeEvents(node, 'door')
|
||||
const liveVisible = useLiveNodeOverrides((s) => {
|
||||
const visible = s.get(node.id)?.visible
|
||||
return typeof visible === 'boolean' ? visible : undefined
|
||||
})
|
||||
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||
|
||||
const mesh = (
|
||||
@@ -26,7 +30,7 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
rotation={node.rotation}
|
||||
visible={node.visible}
|
||||
visible={liveVisible ?? node.visible}
|
||||
{...(isTransient ? {} : handlers)}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
DoorNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
calculateItemRotation,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isMagneticSnapActive,
|
||||
isValidWallSideFace,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
resolveRoofWallOpeningTarget,
|
||||
worldToSelectedBuildingLocal,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import {
|
||||
collectWallOpeningAlignmentCandidates,
|
||||
resolveWallSlideAlignment,
|
||||
} from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import DoorPreview from './preview'
|
||||
|
||||
@@ -143,7 +146,7 @@ const DoorTool: React.FC = () => {
|
||||
|
||||
// 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, '')
|
||||
let alignmentCandidates = collectWallOpeningAlignmentCandidates(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
|
||||
@@ -194,19 +197,17 @@ const DoorTool: React.FC = () => {
|
||||
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.
|
||||
// `bypass` disables along-wall alignment — set when magnetic ("lines")
|
||||
// snap is off. The grid component lives in `snapToHalf`, which is itself
|
||||
// mode-aware (raw cursor when grid is off).
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: wall,
|
||||
rawLocalX,
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: bypass && !bypassSnap,
|
||||
freePlace: bypassSnap,
|
||||
bypass,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
|
||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||
@@ -224,9 +225,8 @@ const DoorTool: React.FC = () => {
|
||||
itemRotation: number
|
||||
cursorRotationY: number
|
||||
bypass: boolean
|
||||
bypassSnap: boolean
|
||||
}) => {
|
||||
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args
|
||||
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass } = args
|
||||
const width = draftRef.current?.width ?? 0.9
|
||||
const height = draftRef.current?.height ?? 2.1
|
||||
|
||||
@@ -249,7 +249,6 @@ const DoorTool: React.FC = () => {
|
||||
width,
|
||||
height,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
draftRef.current.id,
|
||||
)
|
||||
|
||||
@@ -361,7 +360,7 @@ const DoorTool: React.FC = () => {
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:structure-build')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
alignmentCandidates = collectWallOpeningAlignmentCandidates(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
}
|
||||
@@ -387,17 +386,13 @@ const DoorTool: React.FC = () => {
|
||||
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,
|
||||
bypass: !isMagneticSnapActive(),
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -415,20 +410,16 @@ const DoorTool: React.FC = () => {
|
||||
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,
|
||||
!isMagneticSnapActive(),
|
||||
draftRef.current.id,
|
||||
)
|
||||
// Shift force-places over a collision (the draft stays red as a warning).
|
||||
if (!valid && !bypassSnap) return
|
||||
// Alt force-places over a collision (the draft stays red as a warning).
|
||||
if (!valid && event.nativeEvent?.altKey !== true) return
|
||||
|
||||
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
|
||||
event.stopPropagation()
|
||||
@@ -448,9 +439,9 @@ const DoorTool: React.FC = () => {
|
||||
// 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.
|
||||
// A wall/roof mesh handler processed this pointermove (shared DOM
|
||||
// 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
|
||||
@@ -523,9 +514,9 @@ const DoorTool: React.FC = () => {
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
if (!draftRef.current?.roofSegmentId) return
|
||||
const target = resolveRoofTarget(event)
|
||||
// Shift force-places over a colliding roof-face target (see onWallClick).
|
||||
// Alt force-places over a colliding roof-face target (see onWallClick).
|
||||
if (!target) return
|
||||
if (!target.valid && event.nativeEvent?.shiftKey !== true) return
|
||||
if (!target.valid && event.nativeEvent?.altKey !== true) return
|
||||
const { segment, face, position } = target
|
||||
|
||||
const draft = draftRef.current
|
||||
|
||||
Reference in New Issue
Block a user