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
@@ -141,6 +141,7 @@ type NodeDeps = {
|
||||
highlighted: boolean
|
||||
hovered: boolean
|
||||
moving: boolean
|
||||
liveOverride: LiveNodeOverrides | undefined
|
||||
palette: FloorplanPalette | undefined
|
||||
siblingEpoch: number
|
||||
committedNodes: Record<string, AnyNode> | null
|
||||
@@ -218,7 +219,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const ambientLevelId = useMemo<AnyNodeId | null>(() => {
|
||||
if (selectedLevelId || !ambientBuildingSourceId) return null
|
||||
const building = nodes[ambientBuildingSourceId]
|
||||
if (!building || building.type !== 'building') return null
|
||||
if (building?.type !== 'building') return null
|
||||
let zero: AnyNodeId | null = null
|
||||
let lowestId: AnyNodeId | null = null
|
||||
let lowestIdx = Number.POSITIVE_INFINITY
|
||||
@@ -626,6 +627,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const hovered = hoveredId === id
|
||||
const moving = movingNode?.id === id
|
||||
const live = liveTransforms.get(id)
|
||||
const liveOverride = liveOverrides.get(id)
|
||||
const dependsOnSiblingInputs = !!(
|
||||
def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides
|
||||
)
|
||||
@@ -636,6 +638,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
liveOverride,
|
||||
palette: renderCtx?.palette,
|
||||
siblingEpoch: dependsOnSiblingInputs ? (nodeSiblingEpochs.get(id) ?? 0) : 0,
|
||||
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
||||
@@ -719,7 +722,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
? def.floorplanSiblingOverrides({ nodeId: id, nodes, liveOverrides })
|
||||
: nodes
|
||||
const sourceNode = contextNodes !== nodes ? (contextNodes[id] ?? node) : node
|
||||
const effectiveNode = applyLiveTransform(sourceNode)
|
||||
const overrideNode = liveOverride
|
||||
? ({ ...sourceNode, ...liveOverride } as AnyNode)
|
||||
: sourceNode
|
||||
const effectiveNode = applyLiveTransform(overrideNode)
|
||||
const viewState = {
|
||||
selected,
|
||||
highlighted,
|
||||
@@ -763,7 +769,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const visit = (id: AnyNodeId) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return
|
||||
if ((node as { visible?: boolean }).visible === false) return
|
||||
if (!isFloorplanNodeVisible(node, liveOverrides.get(id))) return
|
||||
buildEntry(id, node)
|
||||
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
||||
if (Array.isArray(childIds)) {
|
||||
@@ -790,7 +796,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const buildingScopedKindSet = new Set(buildingScopedKinds)
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
if (!node || !buildingScopedKindSet.has(node.type)) continue
|
||||
if ((node as { visible?: boolean }).visible === false) continue
|
||||
if (!isFloorplanNodeVisible(node, liveOverrides.get(id as AnyNodeId))) continue
|
||||
const parentId = (node as { parentId?: AnyNodeId | null }).parentId
|
||||
if (parentId !== activeBuildingId) continue
|
||||
const cid = id as AnyNodeId
|
||||
@@ -2093,6 +2099,12 @@ function applyPositionLiveTransform(
|
||||
} as AnyNode
|
||||
}
|
||||
|
||||
function isFloorplanNodeVisible(node: AnyNode, liveOverride?: LiveNodeOverrides): boolean {
|
||||
const overrideVisible = liveOverride?.visible
|
||||
if (typeof overrideVisible === 'boolean') return overrideVisible
|
||||
return (node as { visible?: boolean }).visible !== false
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
node: AnyNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
@@ -2303,6 +2315,8 @@ function computeAffectedSiblingIds(
|
||||
} else if (node.type === 'door' || node.type === 'window') {
|
||||
const hostId = (node as { parentId?: string }).parentId
|
||||
if (hostId) affected.add(hostId as AnyNodeId)
|
||||
const liveHostId = (liveOverrides.get(id) as { parentId?: string } | undefined)?.parentId
|
||||
if (liveHostId) affected.add(liveHostId as AnyNodeId)
|
||||
} else if (node.type === 'gutter') {
|
||||
const roofId = (node as { parentId?: string }).parentId
|
||||
if (roofId) {
|
||||
@@ -2326,6 +2340,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||
'highlighted',
|
||||
'hovered',
|
||||
'moving',
|
||||
'liveOverride',
|
||||
'palette',
|
||||
'siblingEpoch',
|
||||
'committedNodes',
|
||||
|
||||
@@ -43,26 +43,13 @@ export const useKeyboard = ({
|
||||
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
|
||||
}
|
||||
|
||||
// Shift cycles the snapping mode while a snapping-mode-governed draft is
|
||||
// armed: wall / fence build, item placement (build + item tool), and any
|
||||
// active node move (`movingNode` — covers item 3D moves plus the generic
|
||||
// registry move for shelf / spawn / column / stair). For items, free place
|
||||
// moved to Alt, so Shift is free to cycle here too. Elsewhere Shift keeps
|
||||
// its existing meaning — multi-select in plain select mode (no movingNode),
|
||||
// free-place bypass during opening / zone placement — so this predicate
|
||||
// must NOT fire for those. Door / window moves still use Shift for free
|
||||
// place (out of this overhaul's scope), so they're excluded.
|
||||
// Shift cycles the snapping mode (and clean-tap Ctrl the grid step) whenever
|
||||
// there's an active snapping context — i.e. exactly when the HUD shows a
|
||||
// snapping chip. That single source covers wall/fence/item drafting, every
|
||||
// node move (including wall-hosted items), and endpoint/polygon reshaping,
|
||||
// so the keys never silently stop working. Door / window keep Shift = free
|
||||
// place until the modifier model unifies them.
|
||||
const isSnappingCycleContext = () => {
|
||||
const moving = getMovingNode()
|
||||
if (moving?.type === 'door' || moving?.type === 'window') return false
|
||||
return getActiveSnapContext() != null
|
||||
}
|
||||
// Shift cycles the snapping mode (and a clean-tap Ctrl the grid step)
|
||||
// whenever there's an active snapping context — i.e. exactly when the HUD
|
||||
// shows a snapping chip. That single source covers wall/fence/item drafting,
|
||||
// every node move (including wall-hosted items + door/window openings, which
|
||||
// now declare `snapProfile`), and endpoint/polygon reshaping, so the keys
|
||||
// never silently stop working. (Force-place lives on Alt for all of them.)
|
||||
const isSnappingCycleContext = () => getActiveSnapContext() != null
|
||||
|
||||
// A "clean tap" of Ctrl/Meta (pressed and released with NO other key in
|
||||
// between) cycles the grid step — same context as the Shift snapping-mode
|
||||
|
||||
@@ -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
|
||||
if (placementPreviewActive) {
|
||||
usePlacementPreview.getState().clear()
|
||||
if ((nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible === false) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
|
||||
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
|
||||
// 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: node.id as AnyNodeId,
|
||||
selfId: nodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
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
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import { type AlignmentAnchor, resolveAlignment, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
collectAlignmentAnchors,
|
||||
resolveAlignment,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf, useAlignmentGuides } from '@pascal-app/editor'
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
||||
export const WALL_OPENING_ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
/**
|
||||
* Alignment candidates for a wall opening (door / window): only OTHER things
|
||||
* hosted ON a wall — sibling openings and wall-mounted items. Floor/ground
|
||||
* objects are excluded so an opening's along-wall guides line up with what's on
|
||||
* the walls, never with furniture sitting on the floor below.
|
||||
*/
|
||||
export function collectWallOpeningAlignmentCandidates(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
excludeId: string,
|
||||
): AlignmentAnchor[] {
|
||||
return collectAlignmentAnchors(nodes, excludeId).filter((anchor) => {
|
||||
const parentId = (nodes[anchor.nodeId] as { parentId?: string } | undefined)?.parentId
|
||||
return !!parentId && nodes[parentId]?.type === 'wall'
|
||||
})
|
||||
}
|
||||
/**
|
||||
* A wall opening (door / window) can only slide ALONG its host wall, so it can
|
||||
* only satisfy an x- or z-guide when the wall runs along that axis. Below this
|
||||
@@ -16,19 +38,14 @@ const MIN_AXIS_COMPONENT = 0.5
|
||||
* Resolve a wall opening's along-wall position with Figma-style alignment to
|
||||
* other objects, publishing the matching guide as a side effect.
|
||||
*
|
||||
* The probe is the RAW cursor position on the wall (not the 0.5m snap) so
|
||||
* The probe is the RAW cursor position on the wall (not the grid snap) so
|
||||
* off-grid anchors are caught; we then keep only the guide on an axis the wall
|
||||
* runs along and map it to the along-wall coordinate that lands the opening on
|
||||
* it. Falls back to the half-metre snap when nothing aligns, and clears the
|
||||
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
|
||||
* given `width`). `bypass` disables alignment; `bypassSnap` also skips the
|
||||
* half-metre fallback.
|
||||
*
|
||||
* `freePlace` (Shift) is the "place anywhere, but still show me where I'd
|
||||
* align" mode: the opening lands at the EXACT raw cursor (no grid snap, no
|
||||
* jump-to-guide), yet the alignment guides are still computed and shown so the
|
||||
* user keeps the visual reference while overriding the magnetic pull. It
|
||||
* supersedes `bypass`/`bypassSnap` when set.
|
||||
* it. Falls back to the grid snap when nothing aligns, and clears the guide on
|
||||
* bypass / no-match. Returns the localX to use (X-clamped to the wall given
|
||||
* `width`). `bypass` disables alignment — set by the caller when magnetic
|
||||
* ("lines") snap is off; the grid component lives in `snapToHalf`, which is
|
||||
* itself mode-aware (raw cursor when grid snap is off).
|
||||
*/
|
||||
export function resolveWallSlideAlignment(args: {
|
||||
wallNode: WallNode
|
||||
@@ -36,55 +53,9 @@ export function resolveWallSlideAlignment(args: {
|
||||
width: number
|
||||
candidates: readonly AlignmentAnchor[]
|
||||
bypass: boolean
|
||||
bypassSnap?: boolean
|
||||
freePlace?: boolean
|
||||
}): number {
|
||||
const {
|
||||
wallNode,
|
||||
rawLocalX,
|
||||
width,
|
||||
candidates,
|
||||
bypass,
|
||||
bypassSnap = false,
|
||||
freePlace = false,
|
||||
} = args
|
||||
const base = bypassSnap || freePlace ? rawLocalX : snapToHalf(rawLocalX)
|
||||
|
||||
const dxAxis = wallNode.end[0] - wallNode.start[0]
|
||||
const dzAxis = wallNode.end[1] - wallNode.start[1]
|
||||
const axisLength = Math.sqrt(dxAxis * dxAxis + dzAxis * dzAxis)
|
||||
|
||||
// Shift / free-place: land at the raw cursor but still publish the guides so
|
||||
// the user sees alignment relationships without being snapped to them. The
|
||||
// guides are re-resolved at the freely-placed point so they connect to the
|
||||
// opening, not the snap target.
|
||||
if (freePlace) {
|
||||
if (candidates.length === 0 || axisLength < 1e-6) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return base
|
||||
}
|
||||
const c = dxAxis / axisLength
|
||||
const s = dzAxis / axisLength
|
||||
const placedX = Math.max(width / 2, Math.min(axisLength - width / 2, base))
|
||||
const shown = resolveAlignment({
|
||||
moving: [
|
||||
{
|
||||
nodeId: '__wall-opening-draft__',
|
||||
kind: 'corner',
|
||||
x: wallNode.start[0] + placedX * c,
|
||||
z: wallNode.start[1] + placedX * s,
|
||||
},
|
||||
],
|
||||
candidates,
|
||||
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
const axisGuides = shown.guides.filter(
|
||||
(g) => Math.abs(g.axis === 'x' ? c : s) >= MIN_AXIS_COMPONENT,
|
||||
)
|
||||
if (axisGuides.length === 0) useAlignmentGuides.getState().clear()
|
||||
else useAlignmentGuides.getState().set(axisGuides)
|
||||
return placedX
|
||||
}
|
||||
const { wallNode, rawLocalX, width, candidates, bypass } = args
|
||||
const base = snapToHalf(rawLocalX)
|
||||
|
||||
if (bypass || candidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
@@ -160,6 +160,7 @@ const windowHandles: HandleDescriptor<WindowNodeType>[] = [
|
||||
*/
|
||||
export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
kind: 'window',
|
||||
snapProfile: 'item',
|
||||
schemaVersion: 1,
|
||||
schema: WindowNode,
|
||||
category: 'structure',
|
||||
@@ -229,6 +230,8 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place window on wall' },
|
||||
{ key: 'R', label: 'Flip side' },
|
||||
{ key: 'Alt', label: 'Force place' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
|
||||
@@ -2,13 +2,21 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
type WindowNode,
|
||||
} 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 {
|
||||
@@ -32,6 +40,7 @@ import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './windo
|
||||
*/
|
||||
|
||||
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
||||
const nodeId = node.id as AnyNodeId
|
||||
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
||||
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
||||
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
||||
@@ -70,6 +79,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
wallId: string
|
||||
roofSegmentId: undefined
|
||||
roofFace: undefined
|
||||
visible: true
|
||||
} | null = null
|
||||
|
||||
// R flips the window's facing (front ↔ back) mid-placement — see
|
||||
@@ -83,18 +93,29 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
// See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor
|
||||
// as a ghost and isn't committable (it needs a wall). Starts true.
|
||||
let onWall = true
|
||||
// Shift force-place (last apply's modifier) — lets `canCommit` allow an
|
||||
// Alt force-place (last apply's modifier) — lets `canCommit` allow an
|
||||
// overlapping placement, matching the 3D move.
|
||||
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 `MoveWindowTool` (see `doorFloorplanMoveTarget`):
|
||||
// ONE soft `sfx:grid-snap` click per grid step, identical free-following or on a
|
||||
// wall, keyed on the RAW cursor. No separate floor→wall cue (that was the
|
||||
// "double"). 2D `apply` runs once per pointermove, so the step-key dedup suffices.
|
||||
const STEP_M = 0.1
|
||||
// ONE soft `sfx:grid-snap` click each time the window's PLACED position crosses
|
||||
// a step. Keyed on the SNAPPED value, quantized by the live grid step in grid
|
||||
// mode else a gentle fixed cadence — grid mode ticks once per cell, lines/off
|
||||
// tick as the window 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')
|
||||
@@ -104,9 +125,11 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
const freeFollow = (planPoint: readonly [number, number]) => {
|
||||
onWall = false
|
||||
lastValid = null
|
||||
if ((useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, { visible: false })
|
||||
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]],
|
||||
@@ -132,26 +155,18 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
visible: true,
|
||||
} as WindowNode
|
||||
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 `MoveWindowTool` — see
|
||||
// `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer
|
||||
// keeps rendering the window at the 3D tool's last hover (it prefers
|
||||
// `useLiveTransforms` over the scene node for door/window), so the 2D
|
||||
// slide — which writes the scene node — wouldn't show. Guarded on
|
||||
// existence: `clear` allocates a new Map + re-renders.
|
||||
if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) {
|
||||
useLiveTransforms.getState().clear(node.id as AnyNodeId)
|
||||
}
|
||||
forcePlace = modifiers.altKey === true
|
||||
const nodes = useScene.getState().nodes
|
||||
const resolvedPlanPoint = resolveCursor(planPoint)
|
||||
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
||||
@@ -162,25 +177,25 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
return
|
||||
}
|
||||
onWall = true
|
||||
if (placementPreviewActive) {
|
||||
usePlacementPreview.getState().clear()
|
||||
if ((nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible === false) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
|
||||
placementPreviewActive = false
|
||||
}
|
||||
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends), winning over the 0.5m grid snap; falls back
|
||||
// to grid when nothing aligns. Alt bypasses alignment; Shift bypasses all snap.
|
||||
const neighborX =
|
||||
modifiers.altKey || modifiers.shiftKey
|
||||
// openings / wall ends), winning over the grid snap; falls back to grid
|
||||
// when nothing aligns. 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: node.id as AnyNodeId,
|
||||
selfId: nodeId,
|
||||
nodes,
|
||||
})
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
const snappedLocalX = neighborX ?? snapToHalf(hit.localX)
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
hit.wall,
|
||||
snappedLocalX,
|
||||
@@ -189,10 +204,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
node.height,
|
||||
)
|
||||
|
||||
// One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`)
|
||||
// so the wall slide ticks at the same cadence as the off-wall ghost — same
|
||||
// SFX, no separate snap cue.
|
||||
tickGridStep(hit.localX)
|
||||
// One click per real position step, keyed on the SNAPPED along-wall value
|
||||
// so it ticks only when the window actually moves to a new cell.
|
||||
tickGridStep(clampedX)
|
||||
|
||||
const side: WindowNode['side'] = flipped
|
||||
? hit.side === 'front'
|
||||
@@ -211,27 +225,35 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
// overlay's snapshot restores it if the move is reverted.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
visible: true,
|
||||
}
|
||||
|
||||
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 window is free-following — not placeable; the overlay
|
||||
// reverts to the pre-move snapshot. Matches the 3D move.
|
||||
if (!onWall) return false
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
|
||||
if (!live || live.type !== 'window') return false
|
||||
// Block on overlap UNLESS Shift force-places — same `placeable` rule as
|
||||
if (!onWall || !lastValid) return false
|
||||
const live = useScene.getState().nodes[nodeId] as WindowNode | undefined
|
||||
if (live?.type !== 'window') return false
|
||||
// Block on overlap 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,
|
||||
@@ -249,7 +271,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
if (!lastValid) return
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
id: nodeId,
|
||||
data: lastValid,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
consumePlacementDragRelease,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
stripPlacementMetadataFlags,
|
||||
@@ -40,7 +41,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 { WindowFloorProjection } from './floor-projection'
|
||||
import WindowPreview from './preview'
|
||||
import {
|
||||
@@ -149,29 +153,45 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
let currentHostId: string | null = movingWindowNode.parentId
|
||||
let committed = false
|
||||
// Off-wall free-follow: over empty floor the window is parented to the
|
||||
// level and tracks the cursor like an item. `freeFollowing` marks that
|
||||
// state; `lastMeshEventTime` defers the floor handler whenever a wall/roof
|
||||
// mesh event owns the same pointermove — that's the only thing that snaps.
|
||||
// level and tracks the cursor like an item. `freeFollowing` marks that state.
|
||||
let freeFollowing = false
|
||||
let lastMeshEventTime = -1
|
||||
// Last open-floor cursor point (level-local X/Z), so an R-flip while free-
|
||||
// following can re-run the ghost at the same spot with the new facing.
|
||||
let lastFloorPoint: [number, number] | null = null
|
||||
// Live Shift state (force-place) — lets the preview tint re-evaluate when
|
||||
// Shift is pressed/released with the pointer stationary (see `MoveDoorTool`).
|
||||
let shiftHeld = false
|
||||
// Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical
|
||||
// whether free-following over floor or sliding along a wall (the user's
|
||||
// ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the
|
||||
// snapped along-wall value. Guards: `lastStepKey` (cell change) +
|
||||
// `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a
|
||||
// distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`.
|
||||
const STEP_M = 0.1
|
||||
// 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) — lets the preview tint re-evaluate when
|
||||
// Alt is pressed/released with the pointer stationary (see `MoveDoorTool`).
|
||||
let altHeld = false
|
||||
// Movement SFX: ONE soft `sfx:grid-snap` click each time the window'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
|
||||
// window sits in a cell) while lines/off still tick as the window moves.
|
||||
// Guards: `lastStepKey` (cell change) + `lastTickFrame` (one per pointermove).
|
||||
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
|
||||
@@ -208,7 +228,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
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)
|
||||
}
|
||||
@@ -243,10 +263,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
setGhostPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||
// moving window is excluded so it never aligns to itself). Along-wall only;
|
||||
// the floor-plane guides don't cover sill height.
|
||||
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 window is excluded.
|
||||
const alignmentCandidates = collectWallOpeningAlignmentCandidates(
|
||||
useScene.getState().nodes,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
@@ -281,28 +301,23 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const rawLocalX = event.localPosition[0]
|
||||
const rawLocalY = event.localPosition[1]
|
||||
if (!dragAnchor || dragAnchor.wallId !== event.node.id) {
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
dragAnchor = {
|
||||
wallId: event.node.id,
|
||||
rawX: rawLocalX,
|
||||
rawY: rawLocalY,
|
||||
startX: event.node.id === original.parentId ? original.position[0] : rawLocalX,
|
||||
startY:
|
||||
event.node.id === original.parentId
|
||||
? original.position[1]
|
||||
: bypassSnap
|
||||
? rawLocalY
|
||||
: snapToHalf(rawLocalY),
|
||||
event.node.id === original.parentId ? original.position[1] : snapToHalf(rawLocalY),
|
||||
}
|
||||
}
|
||||
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||
const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY)
|
||||
// Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins
|
||||
// over the 0.5m grid when within threshold; Shift bypasses both.
|
||||
const bypassY = event.nativeEvent?.shiftKey === true
|
||||
const sillSnapped = bypassY
|
||||
? null
|
||||
: resolveSillSnap({
|
||||
// Vertical sill alignment (snap + guide) is the magnetic ("lines")
|
||||
// component for Y: a sibling's sill/centre/top wins over the grid when
|
||||
// within threshold, so it runs only when magnetic snap is on; otherwise
|
||||
// the mode-aware `snapToHalf` decides Y.
|
||||
const sillSnapped = isMagneticSnapActive()
|
||||
? resolveSillSnap({
|
||||
wall: event.node,
|
||||
movingId: movingWindowNode.id,
|
||||
localX: targetLocalX,
|
||||
@@ -311,16 +326,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
height: movingWindowNode.height,
|
||||
nodes: useScene.getState().nodes,
|
||||
})
|
||||
const targetLocalY = bypassY ? targetRawLocalY : (sillSnapped ?? snapToHalf(targetRawLocalY))
|
||||
: null
|
||||
const targetLocalY = sillSnapped ?? snapToHalf(targetRawLocalY)
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: targetLocalX,
|
||||
width: movingWindowNode.width,
|
||||
candidates: alignmentCandidates,
|
||||
// Alt still hard-disables alignment (no guides). Shift = free-place:
|
||||
// land at the raw cursor but keep showing the along-wall guides.
|
||||
bypass: event.nativeEvent?.altKey === true,
|
||||
freePlace: event.nativeEvent?.shiftKey === true,
|
||||
// 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,
|
||||
@@ -352,10 +367,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
|
||||
// Same click as the off-wall ghost: one grid-snap tick per grid step,
|
||||
// keyed on the RAW cursor along-wall position (not the snapped clampedX).
|
||||
// One grid-snap tick per real ALONG-WALL step, keyed on the snapped
|
||||
// `clampedX` only — NOT the sill `clampedY`, which tracks the cursor's
|
||||
// vertical position on the wall face and so re-keys on every micro
|
||||
// mouse-move even when the window stays in the same along-wall cell.
|
||||
// Per-frame guard collapses duplicate wall events on the same pointermove.
|
||||
tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0])
|
||||
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 — matching the free-follow
|
||||
// ghost so validity reads at a glance (see MoveDoorTool). The node position
|
||||
@@ -388,7 +405,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
markHostDirtyThrottled(target.wallId)
|
||||
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld })
|
||||
const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: altHeld })
|
||||
// Ghost world yaw must equal the committed wall-CHILD's world yaw
|
||||
// (-wallAngle + itemRotation); `cursorRotation` is π off here. See
|
||||
// `MoveDoorTool.applyPreview`.
|
||||
@@ -424,12 +441,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
// 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
|
||||
@@ -438,7 +457,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
@@ -458,6 +476,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
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
|
||||
@@ -548,11 +569,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
// Shift force-places: commit even when the window overlaps another opening.
|
||||
// The preview keeps its red invalid tint as a warning; Shift just lifts the
|
||||
// commit block. Read shift from THIS event so it's never stale at commit.
|
||||
// Alt force-places: commit even when the window 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()
|
||||
}
|
||||
@@ -585,13 +606,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// Free-follow: over open floor there's no wall to host the window, so hide
|
||||
// the real (pale, near-invisible-on-grid) node and float a red translucent
|
||||
// ghost at the cursor — same treatment the raw `WindowTool` build path uses.
|
||||
const freeFollowAt = (localX: number, localZ: number, frame: number) => {
|
||||
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 window along a wall — the
|
||||
// on-wall `applyPreview` already ticks once per along-wall cell.
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
const levelId = getLevelId()
|
||||
@@ -633,14 +657,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
||||
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
||||
// the cursor is over open floor — free-follow it. No proximity magnet:
|
||||
// snapping engages only when the cursor ray actually hovers a wall.
|
||||
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||
// A wall/roof handler owns the pointer right now — the cursor ray is on a
|
||||
// wall/roof that snaps, so skip the floor follow (see `wallOwnsPointer`).
|
||||
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 ─────────────────────────────────────
|
||||
@@ -657,7 +679,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
ignoreId: movingWindowNode.id,
|
||||
vertical: {
|
||||
kind: 'free',
|
||||
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
|
||||
// `snapToHalf` is mode-aware (raw cursor when grid snap is off).
|
||||
snap: snapToHalf,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -667,12 +690,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target) {
|
||||
onRoofLeave()
|
||||
return
|
||||
}
|
||||
// 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
|
||||
@@ -710,9 +735,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
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
|
||||
|
||||
@@ -819,10 +844,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
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 (tint stays red as a warning);
|
||||
// read shift from this pointerup so it's current at commit.
|
||||
if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) {
|
||||
// read alt from this pointerup so it's current at commit.
|
||||
if (lastTarget && !freeFollowing && (lastTarget.valid || event.altKey)) {
|
||||
commitToWall(lastTarget)
|
||||
return
|
||||
}
|
||||
@@ -859,7 +884,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
} else if (lastFloorPoint) {
|
||||
// Free-following: re-run at the same spot so the floating ghost rebuilds
|
||||
// with the flipped side.
|
||||
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1)
|
||||
freeFollowAt(lastFloorPoint[0], lastFloorPoint[1])
|
||||
} else {
|
||||
// No preview yet (R before the first pointermove): flip the hidden node
|
||||
// so the first preview/commit already reflects the chosen side.
|
||||
@@ -870,13 +895,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
}
|
||||
|
||||
// Shift toggles force-place — re-run the on-wall preview so the tint flips
|
||||
// green↔red live (pointer stationary). Commit gates still read shift fresh.
|
||||
const onShiftToggle = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Shift') return
|
||||
// Alt toggles force-place — re-run the on-wall preview so the tint flips
|
||||
// green↔red live (pointer stationary). Commit gates still read alt fresh.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -892,8 +917,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
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 () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
@@ -941,8 +966,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
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)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
|
||||
import { useLiveNodeOverrides, useRegistry, useScene, type WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
createMaterial,
|
||||
DEFAULT_WINDOW_MATERIAL,
|
||||
@@ -20,6 +20,10 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
}, [node.id])
|
||||
const handlers = useNodeEvents(node, 'window')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
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 material = useMemo(() => {
|
||||
@@ -40,7 +44,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
position={node.position}
|
||||
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,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
calculateItemRotation,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isMagneticSnapActive,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
@@ -38,7 +38,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 { WindowFloorProjection } from './floor-projection'
|
||||
import WindowPreview from './preview'
|
||||
import {
|
||||
@@ -157,7 +160,7 @@ const WindowTool: React.FC = () => {
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. A window aligns by the plan position of its centre
|
||||
// (along-wall only; the floor-plane guides don't cover sill height).
|
||||
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
|
||||
@@ -209,9 +212,11 @@ const WindowTool: React.FC = () => {
|
||||
}
|
||||
|
||||
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
|
||||
// 0.5m grid when within threshold; Shift bypasses both. `movingId` is the
|
||||
// draft's id once it exists (so it's excluded from the sibling scan), or ''
|
||||
// before the draft is created (nothing to exclude yet).
|
||||
// grid when within threshold — it's the magnetic ("lines") component for the
|
||||
// vertical axis, so it runs only when magnetic snap is on; otherwise the
|
||||
// grid `snapToHalf` (itself mode-aware) decides Y. `movingId` is the draft's
|
||||
// id once it exists (so it's excluded from the sibling scan), or '' before
|
||||
// the draft is created (nothing to exclude yet).
|
||||
const resolvePlacementY = (args: {
|
||||
wall: WallNode
|
||||
movingId: string
|
||||
@@ -219,10 +224,9 @@ const WindowTool: React.FC = () => {
|
||||
rawLocalY: number
|
||||
width: number
|
||||
height: number
|
||||
bypassSnap: boolean
|
||||
}): number => {
|
||||
if (args.bypassSnap) return args.rawLocalY
|
||||
const sillY = resolveSillSnap({
|
||||
const sillY = isMagneticSnapActive()
|
||||
? resolveSillSnap({
|
||||
wall: args.wall,
|
||||
movingId: args.movingId,
|
||||
localX: args.localX,
|
||||
@@ -231,6 +235,7 @@ const WindowTool: React.FC = () => {
|
||||
height: args.height,
|
||||
nodes: useScene.getState().nodes,
|
||||
})
|
||||
: null
|
||||
return sillY ?? snapToHalf(args.rawLocalY)
|
||||
}
|
||||
|
||||
@@ -242,19 +247,16 @@ const WindowTool: 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` (mode-aware).
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: wall,
|
||||
rawLocalX,
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: bypass && !bypassSnap,
|
||||
freePlace: bypassSnap,
|
||||
bypass,
|
||||
})
|
||||
const localY = resolvePlacementY({
|
||||
wall,
|
||||
@@ -263,7 +265,6 @@ const WindowTool: React.FC = () => {
|
||||
rawLocalY,
|
||||
width,
|
||||
height,
|
||||
bypassSnap,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
|
||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||
@@ -282,18 +283,8 @@ const WindowTool: React.FC = () => {
|
||||
itemRotation: number
|
||||
cursorRotationY: number
|
||||
bypass: boolean
|
||||
bypassSnap: boolean
|
||||
}) => {
|
||||
const {
|
||||
wall,
|
||||
rawLocalX,
|
||||
rawLocalY,
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotationY,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
} = args
|
||||
const { wall, rawLocalX, rawLocalY, side, itemRotation, cursorRotationY, bypass } = args
|
||||
const width = draftRef.current?.width ?? 1.5
|
||||
const height = draftRef.current?.height ?? 1.5
|
||||
|
||||
@@ -317,7 +308,6 @@ const WindowTool: React.FC = () => {
|
||||
width,
|
||||
height,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
draftRef.current.id,
|
||||
)
|
||||
|
||||
@@ -423,7 +413,7 @@ const WindowTool: 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()
|
||||
}
|
||||
@@ -449,8 +439,6 @@ const WindowTool: 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,
|
||||
@@ -459,8 +447,7 @@ const WindowTool: React.FC = () => {
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotationY: cursorRotation,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
bypass: !isMagneticSnapActive(),
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -478,8 +465,6 @@ const WindowTool: 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,
|
||||
@@ -487,12 +472,11 @@ const WindowTool: React.FC = () => {
|
||||
event.localPosition[1],
|
||||
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
|
||||
|
||||
commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation)
|
||||
event.stopPropagation()
|
||||
@@ -512,9 +496,9 @@ const WindowTool: 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
|
||||
@@ -540,7 +524,8 @@ const WindowTool: React.FC = () => {
|
||||
ignoreId: draftRef.current?.id,
|
||||
vertical: {
|
||||
kind: 'free',
|
||||
snap: event.nativeEvent?.shiftKey === true ? undefined : snapToHalf,
|
||||
// `snapToHalf` is mode-aware (raw cursor when grid snap is off).
|
||||
snap: snapToHalf,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -591,9 +576,9 @@ const WindowTool: 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