Merge origin/main (#407 placement restructure) into opening-proximity-guides
#407 ("Always-visible placement ghosts + true-nearest 2D opening snap") restructured the door/window placement tools: it split the old create-in-resolve into a pure resolveWallPlacement() + side-effecting applyWallTarget(), added an off-host floating ghost (fallbackPose / showGhostAt), unified wall hover into onWallHover, and extracted commit{Door,Window}AtWall. Conflict resolution (door/tool.tsx, window/tool.tsx): - Re-homed the single publishOpeningGuidesForWallEvent() call into applyWallTarget (after the draft update + updateCursor), using that scope (wall, getSlabElevationForWall(wall)); door includeVertical:false, window true. - Routed clearOpeningGuides3D() through showGhostAt so every off-host fallback path clears; kept clears in hideCursor, commit helpers, onRoofHover, teardown. - Made the window sill snap (resolvePlacementY) event-free and call it from the pure resolveWallPlacement, so hover + click both get sill/centre/top snapping; Shift bypasses, the moving draft is excluded via ignoreId. - Dropped the branch's inline onWallClick in favour of #407's onWallClick + commitWindowAtWall (no behavior lost). - Reconstructed both files' import blocks, which the auto-merge had truncated to stubs (only tsc caught it). All other conflicts auto-merged (registry types, floorplan-registry-layer, both move-tools). Verified: typecheck 9/9, biome clean, nodes 169 + core 594 tests pass, editor `bun run build` 7/7. Merge resolution reviewed by Codex (adversarial): no semantic regressions; all #407 behavior preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildBoxVentGeometry } from './geometry'
|
||||
import type { BoxVentNode } from './schema'
|
||||
|
||||
@@ -15,7 +16,7 @@ import type { BoxVentNode } from './schema'
|
||||
* leaving raycast active would cause the preview itself to intercept
|
||||
* the cursor ray and starve the placement tool of `roof:move` events.
|
||||
*/
|
||||
const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
||||
const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolean }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildBoxVentGeometry(node),
|
||||
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
|
||||
@@ -24,17 +25,17 @@ const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0x6c_a3_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||
emissiveIntensity: 0.18,
|
||||
roughness: 0.85,
|
||||
metalness: 0.05,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
opacity: invalid ? 0.4 : 0.35,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -127,11 +127,11 @@ const BoxVentTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<BoxVentPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[0.6, 0.4, 0.6]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
polygonAnchors,
|
||||
resolveAlignment,
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -36,10 +37,10 @@ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from
|
||||
* mesh's X/Z position on rebuild (`mesh.position.x = 0`,
|
||||
* `mesh.position.z = 0`) so the visual transitions smoothly.
|
||||
*
|
||||
* 0.5m grid snap (matches legacy).
|
||||
* Snaps to the editor's configured grid step (Shift bypasses).
|
||||
*/
|
||||
function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
return snapScalar(value, useEditor.getState().gridSnapStep)
|
||||
}
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
snapPointAlongAngleRay,
|
||||
snapPointToGrid,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -93,9 +94,9 @@ export const CeilingTool: React.FC = () => {
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const gridX = Math.round(rawPoint[0] * 2) / 2
|
||||
const gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
|
||||
const gridPosition: [number, number] = bypassSnap
|
||||
? rawPoint
|
||||
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { ChimneyNode, RoofSegmentNode } from '@pascal-app/core'
|
||||
import {
|
||||
type ChimneyNode,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildChimneyGeometry } from './geometry'
|
||||
|
||||
const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||
@@ -15,21 +20,42 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||
color: INVALID_GHOST_COLOR,
|
||||
emissive: INVALID_GHOST_COLOR,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.85,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* The preview needs a segment fixture to build the body height. The
|
||||
* placement tool passes the segment under the cursor; before any
|
||||
* segment is hit, the preview isn't shown at all (the tool guards on
|
||||
* `previewPos`).
|
||||
* placement tool passes the segment under the cursor; when floating
|
||||
* (off-roof fallback), segment is absent — build against RoofSegmentNode
|
||||
* defaults so the ghost renders flat at the grid position with yaw 0.
|
||||
*/
|
||||
const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSegmentNode }) => {
|
||||
const ChimneyPreview = ({
|
||||
node,
|
||||
segment,
|
||||
invalid,
|
||||
}: {
|
||||
node: ChimneyNode
|
||||
segment?: RoofSegmentNode
|
||||
invalid?: boolean
|
||||
}) => {
|
||||
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||
const effectiveSegment = segment ?? RoofSegmentSchema.parse({})
|
||||
|
||||
const geo = useMemo(
|
||||
() => buildChimneyGeometry(node, segment),
|
||||
() => buildChimneyGeometry(node, effectiveSegment),
|
||||
[
|
||||
segment.wallHeight,
|
||||
segment.pitch,
|
||||
segment.roofType,
|
||||
segment.width,
|
||||
segment.depth,
|
||||
effectiveSegment.wallHeight,
|
||||
effectiveSegment.pitch,
|
||||
effectiveSegment.roofType,
|
||||
effectiveSegment.width,
|
||||
effectiveSegment.depth,
|
||||
node.width,
|
||||
node.depth,
|
||||
node.heightAboveRidge,
|
||||
@@ -68,16 +94,10 @@ const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSeg
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh
|
||||
geometry={geo.body}
|
||||
material={ghostMaterial}
|
||||
raycast={() => {
|
||||
/* preview should not intercept the cursor */
|
||||
}}
|
||||
/>
|
||||
{geo.cap && <mesh geometry={geo.cap} material={ghostMaterial} raycast={() => {}} />}
|
||||
{geo.flues && <mesh geometry={geo.flues} material={ghostMaterial} raycast={() => {}} />}
|
||||
{geo.cricket && <mesh geometry={geo.cricket} material={ghostMaterial} raycast={() => {}} />}
|
||||
<mesh geometry={geo.body} material={material} raycast={() => {}} />
|
||||
{geo.cap && <mesh geometry={geo.cap} material={material} raycast={() => {}} />}
|
||||
{geo.flues && <mesh geometry={geo.flues} material={material} raycast={() => {}} />}
|
||||
{geo.cricket && <mesh geometry={geo.cricket} material={material} raycast={() => {}} />}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,12 +144,12 @@ const ChimneyTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<ChimneyPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setSegmentXform(null)
|
||||
setHitLocal(null)
|
||||
setPreviewSegment(null)
|
||||
}}
|
||||
size={[1, 2.5, 1]}
|
||||
/>
|
||||
{activeBuildingId && segmentXform && hitLocal && previewSegment && (
|
||||
// Outer group mirrors the real renderer's `position={segment.position}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildCupolaGeometry } from './geometry'
|
||||
import type { CupolaNode } from './schema'
|
||||
|
||||
@@ -11,7 +12,7 @@ import type { CupolaNode } from './schema'
|
||||
* the ghost stays in lockstep with the committed cupola. Raycast disabled
|
||||
* so the preview doesn't intercept the cursor ray feeding the tool.
|
||||
*/
|
||||
const CupolaPreview = ({ node }: { node: CupolaNode }) => {
|
||||
const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildCupolaGeometry(node),
|
||||
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
||||
@@ -20,17 +21,17 @@ const CupolaPreview = ({ node }: { node: CupolaNode }) => {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0x6c_a3_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||
emissiveIntensity: 0.18,
|
||||
roughness: 0.7,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
opacity: invalid ? 0.4 : 0.35,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -119,11 +119,11 @@ const CupolaTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<CupolaPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[0.8, 1.2, 0.8]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -3,15 +3,14 @@ import {
|
||||
type DoorNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import {
|
||||
getRoofHostedOpeningLevelId,
|
||||
getRoofHostedOpeningPlanPoint,
|
||||
} from '../shared/roof-opening-host'
|
||||
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
||||
import {
|
||||
findClosestWallInPlan,
|
||||
projectWallLocalPointToPlan,
|
||||
@@ -38,16 +37,10 @@ import { clampToWall, hasWallChildOverlap } from './door-math'
|
||||
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
|
||||
// Snapshot of the door's "valid" state at move-start — used by
|
||||
// canCommit to decide whether the current snapped position is OK.
|
||||
const startLevelId = (() => {
|
||||
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
|
||||
// segment → roof → level. Cached at start because the parent chain
|
||||
// doesn't change during a move.
|
||||
const nodes = useScene.getState().nodes
|
||||
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
|
||||
if (roofLevelId) return roofLevelId
|
||||
const wall = nodes[node.parentId as AnyNodeId]
|
||||
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||
})()
|
||||
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
||||
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
||||
// Cached at start because the parent chain doesn't change during a move.
|
||||
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
||||
const originalWall = node.parentId
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
||||
: undefined
|
||||
@@ -57,6 +50,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
||||
metadata: node.metadata,
|
||||
// Absolute: query the wall snap with the TRUE cursor, not the door's
|
||||
// original wall position plus a grab delta. A wall-hosted opening always
|
||||
// belongs to the wall nearest the cursor (the user's rule), and the 3D
|
||||
// move snaps on the wall literally under the ray — relative mode would
|
||||
// anchor the search to the old wall and resist hopping to a closer one
|
||||
// across a thin gap, picking the "far" wall the user reported. It also
|
||||
// makes the 2D Voronoi overlay (classified by cursor) predict the snap.
|
||||
mode: 'absolute',
|
||||
})
|
||||
|
||||
// Track the last successful placement so `commit()` can write it
|
||||
@@ -72,13 +73,83 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
roofFace: undefined
|
||||
} | null = null
|
||||
|
||||
// R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives
|
||||
// the wall-facing side every move, so the flip is a persistent XOR applied on
|
||||
// top of the wall hit, plus a π rotation offset (matching the committed R).
|
||||
let flipped = false
|
||||
// Remember the last apply args so the overlay's R keydown can re-run `apply`
|
||||
// (which has no event of its own) to show the flip immediately.
|
||||
let lastApply: {
|
||||
planPoint: readonly [number, number]
|
||||
modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean }
|
||||
} | null = null
|
||||
// Whether the cursor is currently over a wall. Off-wall the door free-follows
|
||||
// the cursor as a ghost (like the 3D move) and is NOT committable — a door
|
||||
// needs a wall. Starts true so a click before any move keeps the door put.
|
||||
let onWall = true
|
||||
|
||||
// Off-wall: float the faithful door symbol at the cursor (via a synthetic
|
||||
// wall fed to the placement-preview layer) and hide the real node, so the
|
||||
// ghost follows the cursor in 2D instead of the door staying frozen on its
|
||||
// old wall. Mirrors the fresh-placement free-follow.
|
||||
const freeFollow = (planPoint: readonly [number, number]) => {
|
||||
onWall = false
|
||||
lastValid = null
|
||||
if ((useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, { visible: false })
|
||||
}
|
||||
const half = node.width / 2 + 0.5
|
||||
const wall = WallNodeSchema.parse({
|
||||
start: [planPoint[0] - half, planPoint[1]],
|
||||
end: [planPoint[0] + half, planPoint[1]],
|
||||
thickness: 0.1,
|
||||
})
|
||||
const ghost = {
|
||||
...node,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
position: [half, node.position[1], 0] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
visible: true,
|
||||
} as DoorNode
|
||||
usePlacementPreview.getState().set(ghost, wall)
|
||||
}
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
flipSide() {
|
||||
flipped = !flipped
|
||||
if (lastApply) this.apply(lastApply)
|
||||
},
|
||||
apply({ planPoint, modifiers }) {
|
||||
lastApply = { planPoint, modifiers }
|
||||
// Drop any stale live transform left by the 3D `MoveDoorTool` (it
|
||||
// publishes one on every wall hover). The 2D registry layer renders
|
||||
// door/window from `useLiveTransforms` IN PREFERENCE to the scene node,
|
||||
// but this 2D move writes the scene node — so a leftover 3D entry would
|
||||
// freeze the symbol on its wall and the slide wouldn't show. Only the
|
||||
// 2D path runs during an opening move (the panel gates the 3D tool's
|
||||
// events off via `!isOpeningMoveActive`), so nothing re-adds it. Guarded
|
||||
// on existence: `clear` always allocates a new Map + re-renders.
|
||||
if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) {
|
||||
useLiveTransforms.getState().clear(node.id as AnyNodeId)
|
||||
}
|
||||
const nodes = useScene.getState().nodes
|
||||
const resolvedPlanPoint = resolveCursor(planPoint)
|
||||
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
||||
if (!hit) return // pointer off any wall — keep door at last valid position
|
||||
if (!hit) {
|
||||
// Off any wall — free-follow the cursor (not committable).
|
||||
freeFollow(resolvedPlanPoint)
|
||||
return
|
||||
}
|
||||
// Back on a wall — drop the free-follow ghost + reveal the real node.
|
||||
onWall = true
|
||||
usePlacementPreview.getState().clear()
|
||||
if ((nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible === false) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
|
||||
}
|
||||
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends); it competes with — and wins over — the 0.5m
|
||||
@@ -97,10 +168,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX))
|
||||
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
||||
|
||||
// Apply the R-flip on top of the wall-derived side.
|
||||
const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side
|
||||
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
|
||||
|
||||
lastValid = {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
@@ -122,6 +197,11 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
// Off-wall the door is free-following in mid-air — not placeable. The
|
||||
// overlay then reverts to the pre-move snapshot (door returns to its
|
||||
// original wall), matching the 3D move where an open-floor click commits
|
||||
// nothing.
|
||||
if (!onWall) return false
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
|
||||
if (!live || live.type !== 'door') return false
|
||||
// Block commit if the door overlaps any other wall child at its
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
collectAlignmentAnchors,
|
||||
DoorNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
@@ -86,6 +87,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
let currentHostId: string | null = movingDoorNode.parentId
|
||||
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
|
||||
let committed = false
|
||||
// Off-wall free-follow: when the cursor is over empty floor (no wall under
|
||||
// the ray) the door is parented to the level and tracks the cursor like an
|
||||
// item node. `freeFollowing` distinguishes that state so the placement
|
||||
// commit no-ops in open space (a door needs a wall). `lastMeshEventTime`
|
||||
// defers the floor handler whenever a wall/roof mesh event owns the same
|
||||
// pointermove (shared DOM timeStamp) — that's the only thing that snaps.
|
||||
let freeFollowing = false
|
||||
let lastMeshEventTime = -1
|
||||
// The door's chosen facing side. R flips it mid-placement (front ↔ back,
|
||||
// same as the committed-selected R flip) so the user can reorient before
|
||||
// committing. Initialised from the moving node's side.
|
||||
let sideOverride: DoorNode['side'] = movingDoorNode.side
|
||||
let lastTarget: {
|
||||
wallNode: WallEvent['node']
|
||||
wallId: string
|
||||
@@ -154,7 +167,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const getPlacementOrientation = (event: WallEvent) => {
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = movingDoorNode.side ?? faceSide
|
||||
const side = sideOverride ?? faceSide
|
||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||
return {
|
||||
side,
|
||||
@@ -274,11 +287,13 @@ 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
|
||||
}
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
@@ -286,6 +301,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
@@ -304,20 +320,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
// Promote the moving door into its committed wall placement. Shared by the
|
||||
// direct wall-mesh click and the floor proximity click.
|
||||
const commitToWall = (target: NonNullable<typeof lastTarget>) => {
|
||||
if (committed) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
committed = true
|
||||
|
||||
let placedId: string
|
||||
@@ -378,30 +391,84 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (committed) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
commitToWall(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
// The cursor left the wall mesh. Don't snap back to the origin/original
|
||||
// here — the floor proximity handler (onGridMove) takes over on the same
|
||||
// pointermove: it either snaps to a nearby wall or free-follows the
|
||||
// cursor. The wireframe outline + live transform are cleared so the
|
||||
// free-follow path can re-establish them. Reverting the node is left to
|
||||
// onGridMove's free-follow / cancel / commit, so the door never blinks
|
||||
// back to the building origin between a wall and open floor.
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
}
|
||||
|
||||
// Free-follow: the door rides the cursor over empty floor, parented to the
|
||||
// level like an item node (lifted so it stands on the floor). No wall to
|
||||
// attach to, so it is not committable here.
|
||||
const freeFollowAt = (localX: number, localZ: number) => {
|
||||
freeFollowing = true
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
const levelId = getLevelId()
|
||||
const y = movingDoorNode.height / 2
|
||||
// Keep the R-flip visible while free-following: face the chosen side
|
||||
// (back = rotated π) instead of forcing 0, so an R press isn't undone on
|
||||
// the next mousemove.
|
||||
const yaw = sideOverride === 'back' ? Math.PI : 0
|
||||
if (currentHostId !== levelId) {
|
||||
if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId)
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [localX, y, localZ],
|
||||
rotation: [0, yaw, 0],
|
||||
side: sideOverride,
|
||||
parentId: levelId ?? undefined,
|
||||
wallId: undefined,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
currentHostId = levelId
|
||||
} else {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [localX, y, localZ],
|
||||
rotation: [0, yaw, 0],
|
||||
side: sideOverride,
|
||||
})
|
||||
}
|
||||
currentHostId = original.parentId
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
||||
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
||||
// the cursor is over open floor — free-follow it.
|
||||
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||
|
||||
// No proximity magnet: in 3D the wall side faces are big raycast targets,
|
||||
// so snapping engages only when the cursor ray actually hovers a wall
|
||||
// (`onWallMove`). Over open floor the door just follows the cursor.
|
||||
const [x, , z] = event.localPosition
|
||||
freeFollowAt(x, z)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -424,12 +491,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
|
||||
}
|
||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||
freeFollowing = false
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = event
|
||||
@@ -529,26 +598,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
}
|
||||
|
||||
const onRoofLeave = () => {
|
||||
// Mirror onWallLeave: don't revert to origin here — onGridMove takes
|
||||
// over on the same pointermove (snap to a nearby wall or free-follow).
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
}
|
||||
currentHostId = original.parentId
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@@ -576,13 +632,53 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (lastTarget) {
|
||||
onWallClick(lastTarget.event)
|
||||
// Free-following over open floor can't commit (no wall). A wall hover
|
||||
// target commits via commitToWall; a roof face via onRoofClick.
|
||||
if (lastTarget?.valid && !freeFollowing) {
|
||||
commitToWall(lastTarget)
|
||||
return
|
||||
}
|
||||
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||
}
|
||||
|
||||
// R flips the door's facing side mid-placement (front ↔ back), like the
|
||||
// committed-selected R flip — usable before commit, whether snapped to a
|
||||
// wall or free-following. Re-applies the preview so the flip shows live.
|
||||
// No-op on a roof-segment face (those host front-only; nothing to flip).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (committed) return
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Only act where a flip is meaningful — on a wall hover or while
|
||||
// free-following. On a roof face leave it to the default R (no flip, no
|
||||
// sfx) so the cue never fires without a visible effect.
|
||||
const onWall = lastTarget !== null
|
||||
if (!(onWall || freeFollowing)) return
|
||||
e.preventDefault()
|
||||
sideOverride = sideOverride === 'front' ? 'back' : 'front'
|
||||
triggerSFX('sfx:item-rotate')
|
||||
if (onWall) {
|
||||
// On a wall: re-resolve with the flipped side and re-preview.
|
||||
const next = resolveMoveTarget(lastTarget!.event)
|
||||
if (next) {
|
||||
lastTarget = next
|
||||
applyPreview(next)
|
||||
}
|
||||
} else {
|
||||
// Free-following on the level: flip the draft's facing in place.
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
side: sideOverride,
|
||||
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
@@ -591,8 +687,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||
@@ -629,8 +727,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [movingDoorNode, exitMoveMode])
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { EDITOR_LAYER } from '@pascal-app/editor'
|
||||
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { applyGhost } from '../shared/ghost-materials'
|
||||
import type { DoorNode } from './schema'
|
||||
|
||||
/**
|
||||
* Translucent preview of a door — used by the placement tool's floating ghost.
|
||||
*
|
||||
* Builds the door mesh via buildDoorPreviewMesh (so the preview shape stays in
|
||||
* lockstep with committed doors), then applies ghost treatment (translucent,
|
||||
* raycast-off, tinted red if invalid).
|
||||
*
|
||||
* The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox
|
||||
* material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha).
|
||||
*/
|
||||
const DoorPreview = ({ node, invalid }: { node: DoorNode; invalid?: boolean }) => {
|
||||
const mesh = useMemo(() => {
|
||||
const m = buildDoorPreviewMesh(node)
|
||||
m.layers.set(EDITOR_LAYER)
|
||||
return m
|
||||
}, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount])
|
||||
|
||||
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid`
|
||||
// flips; its cleanup only disposes the clones it made.
|
||||
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid])
|
||||
|
||||
// Geometry is freshly built per `mesh` and owned here — dispose it only
|
||||
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
|
||||
useEffect(
|
||||
() => () => {
|
||||
mesh.traverse((obj) => {
|
||||
const m = obj as { geometry?: { dispose: () => void } }
|
||||
m.geometry?.dispose()
|
||||
})
|
||||
},
|
||||
[mesh],
|
||||
)
|
||||
|
||||
return <primitive object={mesh} />
|
||||
}
|
||||
|
||||
export default DoorPreview
|
||||
+273
-237
@@ -11,6 +11,7 @@ import {
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
import DoorPreview from './preview'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
@@ -49,31 +51,61 @@ const FALLBACK_WIDTH = 0.9
|
||||
const FALLBACK_HEIGHT = 2.1
|
||||
const roofFallbackPoint = new Vector3()
|
||||
|
||||
// What currently owns the cursor frame: a wall/roof mesh hover, or null when
|
||||
// the cursor is over open floor (the grid handler then free-follows).
|
||||
type HostKind = 'wall' | 'roof' | null
|
||||
|
||||
/**
|
||||
* Door tool — places DoorNodes on walls and on roof-segment wall faces
|
||||
* (the generated base walls under a roof, including coplanar gable ends).
|
||||
* Doors always sit at floor level (clampedY = height/2 — segment base for
|
||||
* roof-hosted doors).
|
||||
*
|
||||
* The ghost follows the cursor everywhere (like moving an item): over open
|
||||
* floor it floats as an invalid (unplaceable) ghost; the moment the cursor
|
||||
* ray hovers a wall (or roof-segment face) the real draft snaps onto it.
|
||||
* Snapping engages only on an actual mesh hover — no proximity magnet — since
|
||||
* the wall side faces are big raycast targets.
|
||||
*/
|
||||
const DoorTool: React.FC = () => {
|
||||
const draftRef = useRef<DoorNode | null>(null)
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
const edgesRef = useRef<LineSegments>(null!)
|
||||
|
||||
// Off-host floating ghost: the real door geometry follows the cursor over
|
||||
// the grid (tinted invalid). Mutually exclusive with the on-host draft —
|
||||
// when a draft + wireframe is shown this is null and vice-versa.
|
||||
const [fallbackPose, setFallbackPose] = useState<{
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
} | null>(null)
|
||||
|
||||
const ghostStub = useMemo(() => DoorNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }), [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
let hostKind: HostKind = null
|
||||
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
||||
// the grid raycast from the SAME pointermove share the source DOM event's
|
||||
// timeStamp, so the grid handler can detect "a mesh handler already owns
|
||||
// this frame" without depending on event order or on a leave firing (node
|
||||
// events are suppressed during a camera drag, so a sticky boolean would
|
||||
// strand the draft after an orbit; a per-frame timestamp self-heals).
|
||||
let lastMeshEventTime = -1
|
||||
// R flips the door's facing side mid-placement (front ↔ back). On a wall
|
||||
// the chosen side is `getSideFromNormal(normal)` flipped by `sideFlip`;
|
||||
// re-applied to the last wall hover so the flip shows live before commit.
|
||||
let sideFlip = false
|
||||
let lastWallEvent: WallEvent | null = null
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
const getSlabElevationForWall = (wall: WallNode) =>
|
||||
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
|
||||
|
||||
const markHostDirty = (hostId: string) => {
|
||||
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||
@@ -91,17 +123,22 @@ const DoorTool: React.FC = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
setFallbackPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
// after each placement. A door aligns by the plan position of its centre.
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
|
||||
// On-host cursor: the green/red wireframe outline tracks a live draft.
|
||||
// Showing it always clears the off-host floating ghost (they never
|
||||
// coexist — a draft means the cursor is on a valid host).
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
setFallbackPose(null)
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
@@ -110,218 +147,123 @@ const DoorTool: React.FC = () => {
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const showFallbackCursor = (event: GridEvent) => {
|
||||
if (draftRef.current) return
|
||||
const [x, y, z] = event.localPosition
|
||||
updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
// Off-host fallback: hide the wireframe outline and float the real door
|
||||
// geometry (tinted invalid) at the cursor so the armed tool is visible.
|
||||
const showGhostAt = (position: [number, number, number]) => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
setFallbackPose({ position, rotationY: 0 })
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
}
|
||||
|
||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
|
||||
}
|
||||
|
||||
const showWallFallbackCursor = (event: WallEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z])
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
destroyDraft()
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const width = 0.9
|
||||
const height = 2.1
|
||||
// Settle a wall target: alignment snap → clamp → overlap check. Pure read.
|
||||
const resolveWallPlacement = (
|
||||
wall: WallNode,
|
||||
rawLocalX: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bypass: boolean,
|
||||
bypassSnap: boolean,
|
||||
ignoreId?: string,
|
||||
) => {
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
wallNode: wall,
|
||||
rawLocalX,
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
})
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
const node = DoorNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
|
||||
publishOpeningGuidesForWallEvent({
|
||||
wall: event.node,
|
||||
movingId: node.id,
|
||||
centerS: clampedX,
|
||||
centerY: clampedY,
|
||||
width,
|
||||
height,
|
||||
includeVertical: false,
|
||||
levelYOffset: getLevelYOffset(),
|
||||
slabElevation: getSlabElevation(event),
|
||||
})
|
||||
event.stopPropagation()
|
||||
const { clampedX, clampedY } = clampToWall(wall, localX, width, height)
|
||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
// Shared create/update path for the wall draft — used by the direct
|
||||
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
|
||||
// (reparenting only on an actual wall change to avoid churning the host's
|
||||
// children array, which flashes 0-vertex wall geometry in WebGPU).
|
||||
const applyWallTarget = (args: {
|
||||
wall: WallNode
|
||||
rawLocalX: number
|
||||
side: 'front' | 'back'
|
||||
itemRotation: number
|
||||
cursorRotationY: number
|
||||
bypass: boolean
|
||||
bypassSnap: boolean
|
||||
}) => {
|
||||
const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args
|
||||
const width = draftRef.current?.width ?? 0.9
|
||||
const height = draftRef.current?.height ?? 2.1
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
// Draft may be null after a successful placement (the click handler
|
||||
// deletes it and relies on the wall rebuild → pointer-enter cascade to
|
||||
// recreate it). Recreate it here on the first subsequent move so the
|
||||
// preview is ready for the next click without requiring a leave/enter.
|
||||
if (!draftRef.current) {
|
||||
const levelId = getLevelId()
|
||||
if (levelId && event.node.parentId === levelId) {
|
||||
const node = DoorNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
const node = DoorNode.parse({
|
||||
position: [0, height / 2, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: wall.id,
|
||||
parentId: wall.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
|
||||
if (draftRef.current) {
|
||||
// Update the scene store on every move so the 2D floor plan
|
||||
// stays in sync (it re-renders from `node.position`). Only
|
||||
// forward `parentId` / `wallId` when the wall actually changed
|
||||
// — otherwise the reparent path churns the host wall's
|
||||
// `children` array every tick, which re-renders the wall and
|
||||
// briefly draws its 0-vertex placeholder geometry (WebGPU then
|
||||
// flags "Vertex buffer slot 0 ... was not set").
|
||||
const isSameWall = event.node.id === draftRef.current.parentId
|
||||
if (isSameWall) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markHostDirty(event.node.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
// The draft may arrive from a roof-segment face hover.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
const { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||
wall,
|
||||
rawLocalX,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
draftRef.current.id,
|
||||
)
|
||||
|
||||
if (wall.id === draftRef.current.parentId) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markHostDirty(wall.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
// The draft may arrive from a roof-segment face hover.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
wall,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
getSlabElevationForWall(wall),
|
||||
),
|
||||
cursorRotation,
|
||||
cursorRotationY,
|
||||
valid,
|
||||
)
|
||||
|
||||
if (draftRef.current) {
|
||||
publishOpeningGuidesForWallEvent({
|
||||
wall: event.node,
|
||||
wall,
|
||||
movingId: draftRef.current.id,
|
||||
centerS: clampedX,
|
||||
centerY: clampedY,
|
||||
@@ -329,47 +271,25 @@ const DoorTool: React.FC = () => {
|
||||
height,
|
||||
includeVertical: false,
|
||||
levelYOffset: getLevelYOffset(),
|
||||
slabElevation: getSlabElevation(event),
|
||||
slabElevation: getSlabElevationForWall(wall),
|
||||
})
|
||||
}
|
||||
event.stopPropagation()
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: draftRef.current.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
// Promote the draft into a permanent door. Shared by the wall-mesh click
|
||||
// and the floor proximity click.
|
||||
const commitDoorAtWall = (
|
||||
wall: WallNode,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
side: 'front' | 'back',
|
||||
itemRotation: number,
|
||||
) => {
|
||||
const draft = draftRef.current
|
||||
if (!draft) return
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -378,18 +298,17 @@ const DoorTool: React.FC = () => {
|
||||
const state = useScene.getState()
|
||||
const doorCount = Object.values(state.nodes).filter((n) => {
|
||||
if (n.type !== 'door') return false
|
||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return wall?.parentId === levelId
|
||||
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return w?.parentId === levelId
|
||||
}).length
|
||||
const name = `Door ${doorCount + 1}`
|
||||
|
||||
const node = DoorNode.parse({
|
||||
name,
|
||||
name: `Door ${doorCount + 1}`,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
wallId: wall.id,
|
||||
parentId: wall.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
doorCategory: draft.doorCategory,
|
||||
@@ -414,20 +333,108 @@ const DoorTool: React.FC = () => {
|
||||
panicBarHeight: draft.panicBarHeight,
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:structure-build')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
}
|
||||
|
||||
// ── Direct wall-mesh hover ──────────────────────────────────────
|
||||
const onWallHover = (event: WallEvent) => {
|
||||
hostKind = 'wall'
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (
|
||||
!isValidWallSideFace(event.normal) ||
|
||||
isCurvedWall(event.node) ||
|
||||
event.node.parentId !== getLevelId()
|
||||
) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
lastWallEvent = event
|
||||
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
|
||||
const flipOffset = sideFlip ? Math.PI : 0
|
||||
const itemRotation = calculateItemRotation(event.normal) + flipOffset
|
||||
const cursorRotation =
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypass = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
applyWallTarget({
|
||||
wall: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotationY: cursorRotation,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (
|
||||
!isValidWallSideFace(event.normal) ||
|
||||
isCurvedWall(event.node) ||
|
||||
event.node.parentId !== getLevelId()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
|
||||
const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0)
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypass = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
const { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||
event.node,
|
||||
event.localPosition[0],
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
if (hostKind !== 'wall') return
|
||||
lastWallEvent = null
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
// ── Floor free-follow ───────────────────────────────────────────
|
||||
// Over open floor the ghost follows the cursor like a moving item. It does
|
||||
// NOT snap from proximity — snapping engages only when the cursor ray
|
||||
// actually hovers a wall (onWallHover) or roof face (onRoofHover).
|
||||
const onGridFreeFollow = (event: GridEvent) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler processed this exact pointermove (R3F + the
|
||||
// grid raycast share the source DOM event's timeStamp) — it owns the
|
||||
// frame and has snapped the draft, so skip the floor follow this tick.
|
||||
const ts = event.nativeEvent?.timeStamp ?? -1
|
||||
if (ts === lastMeshEventTime) return
|
||||
// Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft
|
||||
// and free-follow the cursor with the invalid (unplaceable) ghost.
|
||||
hostKind = null
|
||||
lastWallEvent = null
|
||||
const [x, y, z] = event.localPosition
|
||||
destroyDraft()
|
||||
showGhostAt([x, y + FALLBACK_HEIGHT / 2, z])
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -449,6 +456,8 @@ const DoorTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
hostKind = 'roof'
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveRoofTarget(event)
|
||||
if (!target) {
|
||||
// On the roof but not over a placeable wall face (slope, soffit,
|
||||
@@ -493,6 +502,7 @@ const DoorTool: React.FC = () => {
|
||||
|
||||
const draft = draftRef.current
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -545,26 +555,44 @@ const DoorTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onRoofLeave = () => {
|
||||
if (!draftRef.current?.roofSegmentId) return
|
||||
if (hostKind !== 'roof') return
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
// R flips the door's facing side mid-placement (front ↔ back), like the
|
||||
// committed-selected R flip. Only meaningful while snapped to a wall (the
|
||||
// off-wall ghost has no orientation), so it acts only then — re-applying
|
||||
// the last wall hover so the snapped preview flips live.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
if (!lastWallEvent) return
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||
e.preventDefault()
|
||||
sideFlip = !sideFlip
|
||||
triggerSFX('sfx:item-rotate')
|
||||
onWallHover(lastWallEvent)
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallHover)
|
||||
emitter.on('wall:move', onWallHover)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('roof:enter', onRoofHover)
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', showFallbackCursor)
|
||||
emitter.on('grid:move', onGridFreeFollow)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
destroyDraft()
|
||||
@@ -572,16 +600,17 @@ const DoorTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:enter', onWallHover)
|
||||
emitter.off('wall:move', onWallHover)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('roof:enter', onRoofHover)
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', showFallbackCursor)
|
||||
emitter.off('grid:move', onGridFreeFollow)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -597,14 +626,21 @@ const DoorTool: React.FC = () => {
|
||||
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
<>
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
{fallbackPose && (
|
||||
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
|
||||
<DoorPreview invalid node={ghostStub} />
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildDormerGhostGeometry } from './geometry'
|
||||
import type { DormerNode } from './schema'
|
||||
|
||||
@@ -12,7 +13,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const DormerPreview = ({ node }: { node: DormerNode }) => {
|
||||
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||
color: INVALID_GHOST_COLOR,
|
||||
emissive: INVALID_GHOST_COLOR,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean }) => {
|
||||
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||
|
||||
const geo = useMemo(
|
||||
() => buildDormerGhostGeometry(node),
|
||||
[node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight],
|
||||
@@ -20,7 +33,7 @@ const DormerPreview = ({ node }: { node: DormerNode }) => {
|
||||
|
||||
useEffect(() => () => geo.dispose(), [geo])
|
||||
|
||||
return <mesh geometry={geo} material={ghostMaterial} raycast={() => {}} />
|
||||
return <mesh geometry={geo} material={material} raycast={() => {}} />
|
||||
}
|
||||
|
||||
export default DormerPreview
|
||||
|
||||
@@ -73,8 +73,8 @@ const DormerTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<DormerPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={clearPreview}
|
||||
size={[1.8, 1.8, 1.4]}
|
||||
/>
|
||||
{activeBuildingId && segmentXform && hitLocal && (
|
||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildDownspoutGeometry } from './geometry'
|
||||
import type { DownspoutRouting } from './routing'
|
||||
import type { DownspoutNode } from './schema'
|
||||
@@ -19,9 +20,11 @@ import type { DownspoutNode } from './schema'
|
||||
const DownspoutPreview = ({
|
||||
node,
|
||||
routing,
|
||||
invalid,
|
||||
}: {
|
||||
node: DownspoutNode
|
||||
routing?: DownspoutRouting | null
|
||||
invalid?: boolean
|
||||
}) => {
|
||||
const geometry = useMemo(
|
||||
() => buildDownspoutGeometry(node, routing),
|
||||
@@ -40,17 +43,17 @@ const DownspoutPreview = ({
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0xff_ff_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.7,
|
||||
metalness: 0.2,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
opacity: invalid ? 0.4 : 0.55,
|
||||
depthWrite: false,
|
||||
side: THREE.FrontSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -167,8 +167,8 @@ const DownspoutTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<DownspoutPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => setTarget(null)}
|
||||
size={[0.2, 2.5, 0.2]}
|
||||
validTarget="gutter"
|
||||
/>
|
||||
{activeBuildingId && target && (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildEyebrowVentGeometry } from './geometry'
|
||||
import type { EyebrowVentNode } from './schema'
|
||||
|
||||
@@ -11,7 +12,7 @@ import type { EyebrowVentNode } from './schema'
|
||||
* so the ghost stays in lockstep with the committed vent. Raycast disabled so
|
||||
* the preview doesn't intercept the cursor ray feeding the tool.
|
||||
*/
|
||||
const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => {
|
||||
const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid?: boolean }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildEyebrowVentGeometry(node),
|
||||
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
|
||||
@@ -20,17 +21,17 @@ const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0x6c_a3_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||
emissiveIntensity: 0.18,
|
||||
roughness: 0.7,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
opacity: invalid ? 0.4 : 0.35,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -122,11 +122,11 @@ const EyebrowVentTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<EyebrowVentPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[1.2, 0.4, 0.5]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildGutterGeometry } from './geometry'
|
||||
import type { GutterNode } from './schema'
|
||||
|
||||
@@ -20,7 +21,7 @@ import type { GutterNode } from './schema'
|
||||
* of the trough walls and visually thicken the ghost relative to the
|
||||
* placed gutter.
|
||||
*/
|
||||
const GutterPreview = ({ node }: { node: GutterNode }) => {
|
||||
const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildGutterGeometry(node),
|
||||
[
|
||||
@@ -39,17 +40,17 @@ const GutterPreview = ({ node }: { node: GutterNode }) => {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0xff_ff_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.7,
|
||||
metalness: 0.2,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
opacity: invalid ? 0.4 : 0.55,
|
||||
depthWrite: false,
|
||||
side: THREE.FrontSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -147,8 +147,8 @@ const GutterTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<GutterPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => setTarget(null)}
|
||||
size={[2, 0.2, 0.25]}
|
||||
/>
|
||||
{activeBuildingId && target && (
|
||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildRidgeVentGeometry } from './geometry'
|
||||
import type { RidgeVentNode } from './schema'
|
||||
|
||||
const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
|
||||
const RidgeVentPreview = ({ node, invalid }: { node: RidgeVentNode; invalid?: boolean }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildRidgeVentGeometry(node),
|
||||
[node.length, node.width, node.height, node.style, node.endCaps],
|
||||
@@ -14,17 +15,17 @@ const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0xff_ff_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.85,
|
||||
metalness: 0.05,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
opacity: invalid ? 0.4 : 0.55,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -140,6 +140,7 @@ const RidgeVentTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<RidgeVentPreview node={previewNode} invalid />}
|
||||
isValidRoofTarget={(event) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
@@ -150,7 +151,6 @@ const RidgeVentTool = () => {
|
||||
return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
|
||||
}}
|
||||
onInvalidTarget={() => setPreviewPos(null)}
|
||||
size={[2, 0.15, 0.35]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import type { Material, Mesh, Object3D, Raycaster } from 'three'
|
||||
|
||||
export const INVALID_GHOST_COLOR = 0xef_44_44
|
||||
|
||||
const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {}
|
||||
|
||||
/**
|
||||
* Apply ghost material treatment to a preview mesh tree.
|
||||
*
|
||||
* Traverses the object tree, disables raycasting on all descendants (prevents
|
||||
* cursor-ray starvation), and clones visible mesh materials to set translucency.
|
||||
*
|
||||
* When `invalid` is true, sets color/emissive to INVALID_GHOST_COLOR and opacity ~0.4.
|
||||
* Otherwise sets opacity ~0.5 while preserving the original color.
|
||||
*
|
||||
* Skips: meshes whose material.visible === false (door/window root hitbox) and
|
||||
* children named 'cutout'.
|
||||
*
|
||||
* Returns cleanup that disposes only the cloned materials (never originals or geometry).
|
||||
*
|
||||
* @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh)
|
||||
* @param opts - { invalid?: boolean } whether to tint red for invalid placement
|
||||
* @returns Cleanup function that disposes the cloned materials
|
||||
*/
|
||||
export function applyGhost(root: Object3D, opts?: { invalid?: boolean }): () => void {
|
||||
const invalid = opts?.invalid ?? false
|
||||
const cloned: Material[] = []
|
||||
|
||||
root.traverse((obj) => {
|
||||
// Disable raycast on every descendant to prevent cursor-ray starvation.
|
||||
obj.raycast = NO_RAYCAST
|
||||
|
||||
const mesh = obj as Mesh
|
||||
if (!mesh.isMesh) return
|
||||
if (mesh.name === 'cutout') return
|
||||
|
||||
const original = mesh.material
|
||||
const wasArray = Array.isArray(original)
|
||||
|
||||
const cloneOne = (mat: Material): Material | null => {
|
||||
// Skip invisible materials (door/window root hitbox).
|
||||
if ((mat as { visible?: boolean }).visible === false) return null
|
||||
const clone = mat.clone()
|
||||
clone.transparent = true
|
||||
clone.depthWrite = false
|
||||
if (invalid) {
|
||||
;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(INVALID_GHOST_COLOR)
|
||||
;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex(
|
||||
INVALID_GHOST_COLOR,
|
||||
)
|
||||
clone.opacity = 0.4
|
||||
} else {
|
||||
clone.opacity = 0.5
|
||||
}
|
||||
cloned.push(clone)
|
||||
return clone
|
||||
}
|
||||
|
||||
if (wasArray) {
|
||||
const clonedMats = original.map(cloneOne).filter((m): m is Material => m !== null)
|
||||
if (clonedMats.length > 0) mesh.material = clonedMats
|
||||
} else {
|
||||
const clone = cloneOne(original)
|
||||
if (clone) mesh.material = clone
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
for (const mat of cloned) {
|
||||
mat.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
sceneRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { DragBoundingBox } from '@pascal-app/editor'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
|
||||
const INVALID_PREVIEW_COLOR = 0xef_44_44
|
||||
@@ -17,6 +17,7 @@ type ValidTarget = 'roof' | 'gutter'
|
||||
|
||||
export function RoofAttachmentFallbackPreview({
|
||||
activeBuildingId,
|
||||
ghost,
|
||||
isValidRoofTarget,
|
||||
lift = 0,
|
||||
onInvalidTarget,
|
||||
@@ -24,10 +25,11 @@ export function RoofAttachmentFallbackPreview({
|
||||
validTarget = 'roof',
|
||||
}: {
|
||||
activeBuildingId: string | null | undefined
|
||||
ghost?: ReactNode
|
||||
isValidRoofTarget?: (event: RoofEvent) => boolean
|
||||
lift?: number
|
||||
onInvalidTarget?: () => void
|
||||
size: [number, number, number]
|
||||
size?: [number, number, number]
|
||||
validTarget?: ValidTarget
|
||||
}) {
|
||||
const [position, setPosition] = useState<[number, number, number] | null>(null)
|
||||
@@ -100,6 +102,13 @@ export function RoofAttachmentFallbackPreview({
|
||||
|
||||
if (!(activeBuildingId && position)) return null
|
||||
|
||||
// When ghost is provided, render the ghost instead of DragBoundingBox
|
||||
if (ghost) {
|
||||
return <group position={position}>{ghost}</group>
|
||||
}
|
||||
|
||||
// Fallback to DragBoundingBox for callers not yet migrated
|
||||
if (!size) return null
|
||||
return (
|
||||
<DragBoundingBox
|
||||
color={INVALID_PREVIEW_COLOR}
|
||||
|
||||
@@ -84,6 +84,32 @@ export function getRoofHostedOpeningLevelId(
|
||||
return (roof.parentId as AnyNodeId | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The level that owns the wall-snap candidates for an opening (door /
|
||||
* window), across all three parentings the 2D move can start from:
|
||||
* - roof-hosted: opening → segment → roof → level (`getRoofHostedOpeningLevelId`).
|
||||
* - wall-hosted (existing opening): parent is a wall → its parent is the level.
|
||||
* - fresh placement (preset/catalog): the clone is parented straight to the
|
||||
* LEVEL (`place-preset` sets `parentId: levelId`), so the parent IS the level.
|
||||
*
|
||||
* The fresh-placement case is the subtle one: treating the parent as always a
|
||||
* wall (`parent.parentId`) resolves a fresh opening's level to the BUILDING,
|
||||
* and `collectLevelWallSegments(building)` finds no walls — so a new door /
|
||||
* window never snapped in 2D. Returns null when the parent chain is none of
|
||||
* the above.
|
||||
*/
|
||||
export function getOpeningHostLevelId(
|
||||
node: { parentId: string | null },
|
||||
nodes: Record<string, AnyNode | undefined>,
|
||||
): AnyNodeId | null {
|
||||
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
|
||||
if (roofLevelId) return roofLevelId
|
||||
const parent = node.parentId ? nodes[node.parentId] : undefined
|
||||
if (!parent) return null
|
||||
if (parent.type === 'level') return parent.id as AnyNodeId
|
||||
return (parent.parentId as AnyNodeId | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
|
||||
* through the face frame, then composed through the segment's and roof's
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectLevelWallSegments,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
isCurvedWall,
|
||||
nearestWallSegment,
|
||||
WALL_SNAP_DISTANCE_M,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -22,8 +24,6 @@ import {
|
||||
* rejects curved walls (mitering + arc + opening would tear in 3D).
|
||||
*/
|
||||
|
||||
const WALL_SNAP_DISTANCE_M = 1.5
|
||||
|
||||
export type WallHit = {
|
||||
wall: WallNode
|
||||
/** Distance along the wall from `start` (clamped to [0, length]). */
|
||||
@@ -61,10 +61,14 @@ export function projectWallLocalPointToPlan(
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every wall under `parentLevelId` and return the closest one to
|
||||
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
|
||||
* `excludeWallId` skips a specific wall (e.g. the current parent during
|
||||
* a re-parent flow if you want a "must change" guard).
|
||||
* Return the single closest wall under `parentLevelId` to `planPoint` — the
|
||||
* wall whose segment-Voronoi cell the point lies in — or `null` if nothing is
|
||||
* within `WALL_SNAP_DISTANCE_M`. `excludeWallId` skips a specific wall.
|
||||
*
|
||||
* The nearest-segment scan + curved-wall filter live in core
|
||||
* (`collectLevelWallSegments` / `nearestWallSegment`) so the editor's 2D
|
||||
* Voronoi debug overlay classifies points with the exact same math — the
|
||||
* overlay is then a faithful picture of where this snaps.
|
||||
*/
|
||||
export function findClosestWallInPlan(
|
||||
planPoint: readonly [number, number],
|
||||
@@ -72,78 +76,36 @@ export function findClosestWallInPlan(
|
||||
parentLevelId: AnyNodeId | null,
|
||||
excludeWallId?: AnyNodeId,
|
||||
): WallHit | null {
|
||||
if (!parentLevelId) return null
|
||||
const level = nodes[parentLevelId]
|
||||
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
|
||||
if (!Array.isArray(childIds)) return null
|
||||
const segments = collectLevelWallSegments(nodes, parentLevelId)
|
||||
const closest = nearestWallSegment(
|
||||
segments,
|
||||
planPoint[0],
|
||||
planPoint[1],
|
||||
WALL_SNAP_DISTANCE_M,
|
||||
excludeWallId,
|
||||
)
|
||||
if (!closest) return null
|
||||
|
||||
let best: WallHit | null = null
|
||||
const { segment, along, perp } = closest
|
||||
// Side determination, calibrated to the 3D wall convention. In wall-local
|
||||
// space the wall extends along +X and its +Z axis is the front-face normal;
|
||||
// `perp >= 0` is consistently the front side (see `closestOnSegment`).
|
||||
const side: 'front' | 'back' = perp >= 0 ? 'front' : 'back'
|
||||
// Wall-local rotation matching 3D `calculateItemRotation`: 0 front, π back.
|
||||
// The node is parented to the wall, so this composes with the wall's own
|
||||
// rotation at render — never a world-space rotation here.
|
||||
const itemRotation = side === 'front' ? 0 : Math.PI
|
||||
|
||||
for (const childId of childIds) {
|
||||
const node = nodes[childId]
|
||||
if (!node || node.type !== 'wall') continue
|
||||
if (childId === excludeWallId) continue
|
||||
const wall = node as WallNode
|
||||
if (isCurvedWall(wall)) continue
|
||||
|
||||
const sx = wall.start[0]
|
||||
const sy = wall.start[1]
|
||||
const dx = wall.end[0] - sx
|
||||
const dy = wall.end[1] - sy
|
||||
const wallLength = Math.hypot(dx, dy)
|
||||
if (wallLength < 1e-6) continue
|
||||
|
||||
const dirX = dx / wallLength
|
||||
const dirY = dy / wallLength
|
||||
|
||||
// Project pointer onto wall axis.
|
||||
const px = planPoint[0] - sx
|
||||
const py = planPoint[1] - sy
|
||||
const along = px * dirX + py * dirY
|
||||
const perpRaw = px * -dirY + py * dirX // signed perpendicular distance
|
||||
const clampedAlong = Math.max(0, Math.min(wallLength, along))
|
||||
|
||||
// Distance from the pointer to the wall segment (not just the line).
|
||||
const closestPointX = sx + dirX * clampedAlong
|
||||
const closestPointY = sy + dirY * clampedAlong
|
||||
const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY)
|
||||
if (distance > WALL_SNAP_DISTANCE_M) continue
|
||||
if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue
|
||||
|
||||
// Side determination, calibrated to the 3D wall convention. In
|
||||
// wall-local space the wall extends along +X and its +Z axis is the
|
||||
// front-face normal. After `mesh.rotation.y = -wallAngle`:
|
||||
// - For a wall going `+X` in plan (wallAngle=0): wall-local +Z
|
||||
// maps to world +Z = plan +Y, so the front face is on plan +Y.
|
||||
// `perpRaw = py` is positive → front.
|
||||
// - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z
|
||||
// maps to world -X = plan -X, so the front face is on plan -X.
|
||||
// `perpRaw = -px` is positive there → front.
|
||||
// So `perpRaw >= 0` is consistently the front side. The earlier
|
||||
// labelling had this flipped, which produced rotations that were
|
||||
// off by 90° on non-horizontal walls.
|
||||
const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back'
|
||||
|
||||
// Rotation in wall-local space — matches 3D `calculateItemRotation`:
|
||||
// 0 when the item faces the front normal (+Z), π for the back. The
|
||||
// node is parented to the wall, so this composes with the wall's
|
||||
// own rotation when rendered. Don't return a world-space rotation
|
||||
// here — the consumer writes this straight into `node.rotation[1]`.
|
||||
const itemRotation = side === 'front' ? 0 : Math.PI
|
||||
|
||||
best = {
|
||||
wall,
|
||||
localX: clampedAlong,
|
||||
perpDistance: perpRaw,
|
||||
side,
|
||||
dirX,
|
||||
dirY,
|
||||
wallLength,
|
||||
itemRotation,
|
||||
}
|
||||
return {
|
||||
wall: segment.wall,
|
||||
localX: along,
|
||||
perpDistance: perp,
|
||||
side,
|
||||
dirX: segment.dirX,
|
||||
dirY: segment.dirY,
|
||||
wallLength: segment.length,
|
||||
itemRotation,
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/** Figma-style along-wall alignment threshold (meters) — parity with the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildFrameGeometry } from './frame-csg'
|
||||
import type { SkylightNode } from './schema'
|
||||
|
||||
@@ -15,7 +16,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const SkylightPreview = ({ node }: { node: SkylightNode }) => {
|
||||
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||
color: INVALID_GHOST_COLOR,
|
||||
emissive: INVALID_GHOST_COLOR,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const SkylightPreview = ({ node, invalid }: { node: SkylightNode; invalid?: boolean }) => {
|
||||
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||
|
||||
const frame = useMemo(
|
||||
() =>
|
||||
buildFrameGeometry({
|
||||
@@ -48,8 +61,8 @@ const SkylightPreview = ({ node }: { node: SkylightNode }) => {
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh geometry={frame} material={ghostMaterial} raycast={() => {}} />
|
||||
<mesh geometry={glass} material={ghostMaterial} raycast={() => {}} />
|
||||
<mesh geometry={frame} material={material} raycast={() => {}} />
|
||||
<mesh geometry={glass} material={material} raycast={() => {}} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,11 +114,11 @@ const SkylightTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<SkylightPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[1.2, 0.2, 1]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
snapPointAlongAngleRay,
|
||||
snapPointToGrid,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -76,9 +77,9 @@ export const SlabTool: React.FC = () => {
|
||||
if (!cursorRef.current) return
|
||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true
|
||||
const gridX = Math.round(rawPoint[0] * 2) / 2
|
||||
const gridZ = Math.round(rawPoint[1] * 2) / 2
|
||||
const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ]
|
||||
const gridPosition: [number, number] = bypassSnap
|
||||
? rawPoint
|
||||
: [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)]
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
const lastPoint = points[points.length - 1]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildSolarPanelGeometry } from './geometry'
|
||||
import type { SolarPanelNode } from './schema'
|
||||
|
||||
@@ -15,7 +16,19 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => {
|
||||
const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
||||
color: INVALID_GHOST_COLOR,
|
||||
emissive: INVALID_GHOST_COLOR,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: boolean }) => {
|
||||
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||
|
||||
const geometry = useMemo(
|
||||
() => buildSolarPanelGeometry(node),
|
||||
[
|
||||
@@ -38,7 +51,7 @@ const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => {
|
||||
return (
|
||||
<mesh
|
||||
geometry={geometry}
|
||||
material={ghostMaterial}
|
||||
material={material}
|
||||
raycast={() => {
|
||||
/* preview should not intercept the cursor */
|
||||
}}
|
||||
|
||||
@@ -135,11 +135,11 @@ const SolarPanelTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<SolarPanelPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[1.8, 0.2, 1.2]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
emitter,
|
||||
type GridEvent,
|
||||
SpawnNode,
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
getFloorStackPreviewPosition,
|
||||
@@ -11,7 +18,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { type Group, Vector3 } from 'three'
|
||||
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
const snapToGrid = (value: number) => snapScalar(value, useEditor.getState().gridSnapStep)
|
||||
const worldVector = new Vector3()
|
||||
|
||||
function getExistingSpawnIds() {
|
||||
@@ -31,14 +38,14 @@ function getLevelLocalPosition(
|
||||
if (!levelObject) {
|
||||
return bypassSnap
|
||||
? [event.localPosition[0], 0, event.localPosition[2]]
|
||||
: [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]
|
||||
: [snapToGrid(event.localPosition[0]), 0, snapToGrid(event.localPosition[2])]
|
||||
}
|
||||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
levelObject.worldToLocal(worldVector)
|
||||
return bypassSnap
|
||||
? [worldVector.x, 0, worldVector.z]
|
||||
: [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)]
|
||||
: [snapToGrid(worldVector.x), 0, snapToGrid(worldVector.z)]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,11 +65,11 @@ const SpawnTool = () => {
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
// Cursor lives in the ToolManager's building-local group. Use
|
||||
// event.localPosition directly (already building-local) with the
|
||||
// same half-meter snap the legacy tool uses.
|
||||
// event.localPosition directly (already building-local), snapped to the
|
||||
// editor's configured grid step (Shift bypasses).
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const nextX = bypassSnap ? event.localPosition[0] : roundToHalf(event.localPosition[0])
|
||||
const nextZ = bypassSnap ? event.localPosition[2] : roundToHalf(event.localPosition[2])
|
||||
const nextX = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0])
|
||||
const nextZ = bypassSnap ? event.localPosition[2] : snapToGrid(event.localPosition[2])
|
||||
const position: [number, number, number] = [nextX, 0, nextZ]
|
||||
const previewNode = SpawnNode.parse({
|
||||
name: 'Spawn Point',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildTurbineVentGeometry } from './geometry'
|
||||
import type { TurbineVentNode } from './schema'
|
||||
|
||||
@@ -12,7 +13,7 @@ import type { TurbineVentNode } from './schema'
|
||||
* lockstep with the committed vent. Raycast is disabled so the preview
|
||||
* doesn't intercept the cursor ray feeding the placement tool.
|
||||
*/
|
||||
const TurbineVentPreview = ({ node }: { node: TurbineVentNode }) => {
|
||||
const TurbineVentPreview = ({ node, invalid }: { node: TurbineVentNode; invalid?: boolean }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildTurbineVentGeometry(node),
|
||||
[node.style, node.diameter, node.height, node.neckHeight, node.vaneCount, node.baseOverhang],
|
||||
@@ -21,17 +22,17 @@ const TurbineVentPreview = ({ node }: { node: TurbineVentNode }) => {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0x6c_a3_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||
emissiveIntensity: 0.18,
|
||||
roughness: 0.6,
|
||||
metalness: 0.2,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
opacity: invalid ? 0.4 : 0.35,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -122,11 +122,11 @@ const TurbineVentTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<TurbineVentPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[0.5, 0.8, 0.5]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
|
||||
@@ -2,16 +2,15 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { snapToHalf, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import {
|
||||
getRoofHostedOpeningLevelId,
|
||||
getRoofHostedOpeningPlanPoint,
|
||||
} from '../shared/roof-opening-host'
|
||||
import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host'
|
||||
import {
|
||||
findClosestWallInPlan,
|
||||
projectWallLocalPointToPlan,
|
||||
@@ -32,15 +31,9 @@ import { clampToWall, hasWallChildOverlap } from './window-math'
|
||||
*/
|
||||
|
||||
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
||||
const startLevelId = (() => {
|
||||
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
|
||||
// segment → roof → level.
|
||||
const nodes = useScene.getState().nodes
|
||||
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
|
||||
if (roofLevelId) return roofLevelId
|
||||
const wall = nodes[node.parentId as AnyNodeId]
|
||||
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||
})()
|
||||
// The level that owns the wall-snap candidates — resolves the wall-hosted,
|
||||
// roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`).
|
||||
const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes)
|
||||
const originalWall = node.parentId
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined)
|
||||
: undefined
|
||||
@@ -50,12 +43,21 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
? projectWallLocalPointToPlan(originalWall, node.position[0])
|
||||
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
|
||||
metadata: node.metadata,
|
||||
// Absolute: query the wall snap with the TRUE cursor (see the matching
|
||||
// comment in `doorFloorplanMoveTarget`). Relative mode anchored the search
|
||||
// to the original wall, which let the window snap to a farther wall across
|
||||
// a thin gap instead of the one under the cursor.
|
||||
mode: 'absolute',
|
||||
})
|
||||
|
||||
// Preserve the source window's local Y — 2D move doesn't have a way
|
||||
// to express vertical motion, so we keep whatever vertical position
|
||||
// the window had when the move started.
|
||||
const startLocalY = node.position[1]
|
||||
// the window had when the move started. A fresh preset/catalog clone is
|
||||
// created at y=0, which would sit the window's centre on the floor (half
|
||||
// below ground); default those to a realistic sill so it floats above
|
||||
// the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`).
|
||||
const DEFAULT_SILL = 0.9
|
||||
const startLocalY = node.position[1] > 0.1 ? node.position[1] : DEFAULT_SILL + node.height / 2
|
||||
|
||||
// Track the last successful placement so `commit()` can write it
|
||||
// atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`.
|
||||
@@ -69,13 +71,72 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
roofFace: undefined
|
||||
} | null = null
|
||||
|
||||
// R flips the window's facing (front ↔ back) mid-placement — see
|
||||
// `doorFloorplanMoveTarget`. `apply` re-derives the side each move, so the
|
||||
// flip is a persistent XOR plus a π rotation offset.
|
||||
let flipped = false
|
||||
let lastApply: {
|
||||
planPoint: readonly [number, number]
|
||||
modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean }
|
||||
} | null = null
|
||||
// See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor
|
||||
// as a ghost and isn't committable (it needs a wall). Starts true.
|
||||
let onWall = true
|
||||
|
||||
const freeFollow = (planPoint: readonly [number, number]) => {
|
||||
onWall = false
|
||||
lastValid = null
|
||||
if ((useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, { visible: false })
|
||||
}
|
||||
const half = node.width / 2 + 0.5
|
||||
const wall = WallNodeSchema.parse({
|
||||
start: [planPoint[0] - half, planPoint[1]],
|
||||
end: [planPoint[0] + half, planPoint[1]],
|
||||
thickness: 0.1,
|
||||
})
|
||||
const ghost = {
|
||||
...node,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
position: [half, startLocalY, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
visible: true,
|
||||
} as WindowNode
|
||||
usePlacementPreview.getState().set(ghost, wall)
|
||||
}
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
flipSide() {
|
||||
flipped = !flipped
|
||||
if (lastApply) this.apply(lastApply)
|
||||
},
|
||||
apply({ planPoint, modifiers }) {
|
||||
lastApply = { planPoint, modifiers }
|
||||
// Drop any stale live transform left by the 3D `MoveWindowTool` — see
|
||||
// `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer
|
||||
// keeps rendering the window at the 3D tool's last hover (it prefers
|
||||
// `useLiveTransforms` over the scene node for door/window), so the 2D
|
||||
// slide — which writes the scene node — wouldn't show. Guarded on
|
||||
// existence: `clear` allocates a new Map + re-renders.
|
||||
if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) {
|
||||
useLiveTransforms.getState().clear(node.id as AnyNodeId)
|
||||
}
|
||||
const nodes = useScene.getState().nodes
|
||||
const resolvedPlanPoint = resolveCursor(planPoint)
|
||||
const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId)
|
||||
if (!hit) return
|
||||
if (!hit) {
|
||||
freeFollow(resolvedPlanPoint)
|
||||
return
|
||||
}
|
||||
onWall = true
|
||||
usePlacementPreview.getState().clear()
|
||||
if ((nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible === false) {
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, { visible: true })
|
||||
}
|
||||
|
||||
// Figma-style along-wall alignment first (edge-to-edge with other
|
||||
// openings / wall ends), winning over the 0.5m grid snap; falls back
|
||||
@@ -99,10 +160,17 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
node.height,
|
||||
)
|
||||
|
||||
const side: WindowNode['side'] = flipped
|
||||
? hit.side === 'front'
|
||||
? 'back'
|
||||
: 'front'
|
||||
: hit.side
|
||||
const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0)
|
||||
|
||||
lastValid = {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
@@ -119,6 +187,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
// Off-wall the window is free-following — not placeable; the overlay
|
||||
// reverts to the pre-move snapshot. Matches the 3D move.
|
||||
if (!onWall) return false
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined
|
||||
if (!live || live.type !== 'window') return false
|
||||
const overlapping = hasWallChildOverlap(
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
isCurvedWall,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
@@ -106,6 +107,15 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
|
||||
let currentHostId: string | null = movingWindowNode.parentId
|
||||
let committed = false
|
||||
// Off-wall free-follow: over empty floor the window is parented to the
|
||||
// level and tracks the cursor like an item. `freeFollowing` marks that
|
||||
// state; `lastMeshEventTime` defers the floor handler whenever a wall/roof
|
||||
// mesh event owns the same pointermove — that's the only thing that snaps.
|
||||
let freeFollowing = false
|
||||
let lastMeshEventTime = -1
|
||||
// The window's chosen facing side. R flips it mid-placement (front ↔ back),
|
||||
// matching the committed-selected R flip. Initialised from the moving node.
|
||||
let sideOverride: WindowNode['side'] = movingWindowNode.side
|
||||
let dragAnchor: {
|
||||
wallId: string
|
||||
rawX: number
|
||||
@@ -146,6 +156,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
|
||||
// Sill-center height used while the window isn't on a wall (free-follow and
|
||||
// proximity). Fresh preset clones are created at position [0,0,0], which
|
||||
// would bury half the window below the floor; default such windows to a
|
||||
// ~0.9m sill so the ghost floats at a realistic height. An existing window
|
||||
// keeps its own sill.
|
||||
const DEFAULT_SILL = 0.9
|
||||
const getSillCenterY = () => {
|
||||
const y = movingWindowNode.position[1]
|
||||
return y > 0.1 ? y : DEFAULT_SILL + movingWindowNode.height / 2
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
@@ -189,9 +210,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = sideOverride ?? faceSide
|
||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||
const itemRotation = calculateItemRotation(event.normal) + rotationOffset
|
||||
const cursorRotation =
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset
|
||||
|
||||
const rawLocalX = event.localPosition[0]
|
||||
const rawLocalY = event.localPosition[1]
|
||||
@@ -318,11 +342,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveMoveTarget(event)
|
||||
if (!target) {
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
@@ -330,6 +356,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
onWallLeave()
|
||||
return
|
||||
@@ -349,21 +376,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
onWallLeave()
|
||||
return
|
||||
}
|
||||
freeFollowing = false
|
||||
lastTarget = target
|
||||
lastRoofEvent = null
|
||||
applyPreview(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
// Promote the moving window into its committed wall placement. Shared by
|
||||
// the direct wall-mesh click and the floor proximity click.
|
||||
const commitToWall = (target: NonNullable<typeof lastTarget>) => {
|
||||
if (committed) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
committed = true
|
||||
|
||||
let placedId: string
|
||||
@@ -429,31 +452,78 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (committed) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event)
|
||||
if (!target?.valid) return
|
||||
commitToWall(target)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
// The cursor left the wall mesh. Don't snap back to the origin/original
|
||||
// here — the floor proximity handler (onGridMove) takes over on the same
|
||||
// pointermove: it snaps to a nearby wall or free-follows the cursor, so
|
||||
// the window never blinks back to the building origin between a wall and
|
||||
// open floor. Revert is left to free-follow / cancel / commit.
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return // No original to restore for duplicates
|
||||
// Move mode: restore to original position while off-wall
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
}
|
||||
|
||||
// Free-follow: the window rides the cursor over empty floor, parented to
|
||||
// the level like an item node, kept at a sensible sill height. No wall to
|
||||
// attach to, so it is not committable here.
|
||||
const freeFollowAt = (localX: number, localZ: number) => {
|
||||
freeFollowing = true
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
const levelId = getLevelId()
|
||||
const sillCenterY = getSillCenterY()
|
||||
// Keep the R-flip visible while free-following (back = rotated π).
|
||||
const yaw = sideOverride === 'back' ? Math.PI : 0
|
||||
if (currentHostId !== levelId) {
|
||||
if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId)
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [localX, sillCenterY, localZ],
|
||||
rotation: [0, yaw, 0],
|
||||
side: sideOverride,
|
||||
parentId: levelId ?? undefined,
|
||||
wallId: undefined,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
currentHostId = levelId
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [localX, sillCenterY, localZ],
|
||||
rotation: [0, yaw, 0],
|
||||
side: sideOverride,
|
||||
})
|
||||
}
|
||||
currentHostId = original.parentId
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (committed) return
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler owns this exact pointermove (shared DOM
|
||||
// timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise
|
||||
// the cursor is over open floor — free-follow it. No proximity magnet:
|
||||
// snapping engages only when the cursor ray actually hovers a wall.
|
||||
if (event.nativeEvent?.timeStamp === lastMeshEventTime) return
|
||||
const [x, , z] = event.localPosition
|
||||
freeFollowAt(x, z)
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -480,12 +550,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveRoofMoveTarget(event)
|
||||
if (!target) {
|
||||
onRoofLeave()
|
||||
return
|
||||
}
|
||||
// Wall-frame drag anchor / live transform don't apply on a roof face.
|
||||
freeFollowing = false
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = event
|
||||
@@ -586,26 +658,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
}
|
||||
|
||||
const onRoofLeave = () => {
|
||||
// Mirror onWallLeave: don't revert to origin here — onGridMove takes
|
||||
// over on the same pointermove (snap to a nearby wall or free-follow).
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
dragAnchor = null
|
||||
lastTarget = null
|
||||
lastRoofEvent = null
|
||||
if (isNew) return
|
||||
if (currentHostId && currentHostId !== original.parentId) {
|
||||
markHostDirty(currentHostId)
|
||||
}
|
||||
currentHostId = original.parentId
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
roofSegmentId: original.roofSegmentId,
|
||||
roofFace: original.roofFace,
|
||||
})
|
||||
if (original.parentId) markHostDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
@@ -633,13 +692,47 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (lastTarget) {
|
||||
onWallClick(lastTarget.event)
|
||||
// Free-following over open floor can't commit (no wall). A wall hover
|
||||
// target commits via commitToWall; a roof face via onRoofClick.
|
||||
if (lastTarget?.valid && !freeFollowing) {
|
||||
commitToWall(lastTarget)
|
||||
return
|
||||
}
|
||||
if (lastRoofEvent) onRoofClick(lastRoofEvent)
|
||||
}
|
||||
|
||||
// R flips the window's facing side mid-placement (front ↔ back), like the
|
||||
// committed-selected R flip — usable before commit, whether snapped to a
|
||||
// wall or free-following. No-op on a roof-segment face (front-only host).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (committed) return
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const onWall = lastTarget !== null
|
||||
if (!(onWall || freeFollowing)) return
|
||||
e.preventDefault()
|
||||
sideOverride = sideOverride === 'front' ? 'back' : 'front'
|
||||
triggerSFX('sfx:item-rotate')
|
||||
if (onWall) {
|
||||
const next = resolveMoveTarget(lastTarget!.event)
|
||||
if (next) {
|
||||
lastTarget = next
|
||||
applyPreview(next)
|
||||
}
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
side: sideOverride,
|
||||
rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
@@ -648,8 +741,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
@@ -687,8 +782,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { EDITOR_LAYER } from '@pascal-app/editor'
|
||||
import { buildWindowPreviewMesh } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { applyGhost } from '../shared/ghost-materials'
|
||||
import type { WindowNode } from './schema'
|
||||
|
||||
/**
|
||||
* Translucent preview of a window — used by the placement tool's floating ghost.
|
||||
*
|
||||
* Builds the window mesh via buildWindowPreviewMesh (so the preview shape stays in
|
||||
* lockstep with committed windows), then applies ghost treatment (translucent,
|
||||
* raycast-off, tinted red if invalid).
|
||||
*
|
||||
* The root mesh's layer is set to EDITOR_LAYER because the invisible hitbox
|
||||
* material on SCENE_LAYER would poison the WebGPU MRT pass (project gotcha).
|
||||
*/
|
||||
const WindowPreview = ({ node, invalid }: { node: WindowNode; invalid?: boolean }) => {
|
||||
const mesh = useMemo(() => {
|
||||
const m = buildWindowPreviewMesh(node)
|
||||
m.layers.set(EDITOR_LAYER)
|
||||
return m
|
||||
}, [
|
||||
node.width,
|
||||
node.height,
|
||||
node.frameDepth,
|
||||
node.openingShape,
|
||||
node.windowType,
|
||||
node.sill,
|
||||
node.sillDepth,
|
||||
node.sillThickness,
|
||||
])
|
||||
|
||||
// Ghost treatment (clone + tint + raycast-off) re-applies if `invalid`
|
||||
// flips; its cleanup only disposes the clones it made.
|
||||
useEffect(() => applyGhost(mesh, { invalid }), [mesh, invalid])
|
||||
|
||||
// Geometry is freshly built per `mesh` and owned here — dispose it only
|
||||
// when the mesh itself is replaced/unmounted, never on an `invalid` toggle.
|
||||
useEffect(
|
||||
() => () => {
|
||||
mesh.traverse((obj) => {
|
||||
const m = obj as { geometry?: { dispose: () => void } }
|
||||
m.geometry?.dispose()
|
||||
})
|
||||
},
|
||||
[mesh],
|
||||
)
|
||||
|
||||
return <primitive object={mesh} />
|
||||
}
|
||||
|
||||
export default WindowPreview
|
||||
+304
-264
@@ -10,6 +10,7 @@ import {
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
worldToSelectedBuildingLocal,
|
||||
} from '../shared/roof-wall-opening-placement'
|
||||
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
|
||||
import WindowPreview from './preview'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
@@ -51,33 +53,65 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
const FALLBACK_WIDTH = 1.5
|
||||
const FALLBACK_HEIGHT = 1.5
|
||||
const FALLBACK_SILL_LIFT = 0.45
|
||||
// Default sill centre for a window snapped from the floor (the floor cursor
|
||||
// carries no wall-face height). 0.9 m sill + half the 1.5 m default height.
|
||||
const DEFAULT_SILL_CENTER_Y = 0.9 + FALLBACK_HEIGHT / 2
|
||||
const roofFallbackPoint = new Vector3()
|
||||
|
||||
// What currently owns the cursor frame: a wall/roof mesh hover, or null when
|
||||
// the cursor is over open floor (the grid handler then free-follows).
|
||||
type HostKind = 'wall' | 'roof' | null
|
||||
|
||||
/**
|
||||
* Window tool — places WindowNodes on walls and on roof-segment wall
|
||||
* faces (the generated base walls under a roof, including coplanar gable
|
||||
* ends — a window can sit in the gable pediment).
|
||||
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
|
||||
*
|
||||
* The ghost follows the cursor everywhere (like moving an item): over open
|
||||
* floor it floats as an invalid (unplaceable) ghost; the moment the cursor ray
|
||||
* hovers a wall (or roof-segment face) the real draft snaps onto it. Snapping
|
||||
* engages only on an actual mesh hover — no proximity magnet.
|
||||
*/
|
||||
const WindowTool: React.FC = () => {
|
||||
const draftRef = useRef<WindowNode | null>(null)
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
const edgesRef = useRef<LineSegments>(null!)
|
||||
|
||||
// Off-host floating ghost: the real window geometry follows the cursor
|
||||
// over the grid (tinted invalid). Mutually exclusive with the on-host draft.
|
||||
const [fallbackPose, setFallbackPose] = useState<{
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
} | null>(null)
|
||||
|
||||
const ghostStub = useMemo(
|
||||
() => WindowNode.parse({ position: [0, 0, 0], rotation: [0, 0, 0] }),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
let hostKind: HostKind = null
|
||||
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
||||
// the grid raycast from the SAME pointermove share the source DOM event's
|
||||
// timeStamp, so the grid handler can detect "a mesh handler already owns
|
||||
// this frame" without depending on event order or on a leave firing (node
|
||||
// events are suppressed during a camera drag, so a sticky boolean would
|
||||
// strand the draft after an orbit; a per-frame timestamp self-heals).
|
||||
let lastMeshEventTime = -1
|
||||
// R flips the window's facing side mid-placement (front ↔ back); re-applied
|
||||
// to the last wall hover so the flip shows live before commit.
|
||||
let sideFlip = false
|
||||
let lastWallEvent: WallEvent | null = null
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
const getSlabElevationForWall = (wall: WallNode) =>
|
||||
spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end)
|
||||
|
||||
const markHostDirty = (hostId: string) => {
|
||||
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
|
||||
@@ -96,6 +130,7 @@ const WindowTool: React.FC = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
setFallbackPose(null)
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
@@ -103,11 +138,15 @@ const WindowTool: React.FC = () => {
|
||||
// (along-wall only; the floor-plane guides don't cover sill height).
|
||||
let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
|
||||
// On-host cursor: the green/red wireframe outline tracks a live draft.
|
||||
// Showing it always clears the off-host floating ghost (they never
|
||||
// coexist — a draft means the cursor is on a valid host).
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
setFallbackPose(null)
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
@@ -116,26 +155,23 @@ const WindowTool: React.FC = () => {
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const showFallbackCursor = (event: GridEvent) => {
|
||||
if (draftRef.current) return
|
||||
const [x, y, z] = event.localPosition
|
||||
updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||
// Off-host fallback: hide the wireframe outline and float the real window
|
||||
// geometry (tinted invalid) at the cursor so the armed tool is visible.
|
||||
const showGhostAt = (position: [number, number, number]) => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
setFallbackPose({ position, rotationY: 0 })
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
}
|
||||
|
||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
||||
}
|
||||
|
||||
const showWallFallbackCursor = (event: WallEvent) => {
|
||||
const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position))
|
||||
updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false)
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
||||
}
|
||||
|
||||
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
|
||||
@@ -143,226 +179,146 @@ const WindowTool: React.FC = () => {
|
||||
// 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: {
|
||||
event: WallEvent
|
||||
wall: WallNode
|
||||
movingId: string
|
||||
localX: number
|
||||
rawLocalY: number
|
||||
width: number
|
||||
height: number
|
||||
bypassSnap: boolean
|
||||
}): number => {
|
||||
const rawY = args.event.localPosition[1]
|
||||
if (args.event.nativeEvent?.shiftKey === true) return rawY
|
||||
if (args.bypassSnap) return args.rawLocalY
|
||||
const sillY = resolveSillSnap({
|
||||
wall: args.event.node,
|
||||
wall: args.wall,
|
||||
movingId: args.movingId,
|
||||
localX: args.localX,
|
||||
localY: rawY,
|
||||
localY: args.rawLocalY,
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
nodes: useScene.getState().nodes,
|
||||
})
|
||||
return sillY ?? snapToHalf(rawY)
|
||||
return sillY ?? snapToHalf(args.rawLocalY)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== levelId) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
destroyDraft()
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const width = 1.5
|
||||
const height = 1.5
|
||||
// Settle a wall target: alignment snap → sill clamp → overlap check.
|
||||
const resolveWallPlacement = (
|
||||
wall: WallNode,
|
||||
rawLocalX: number,
|
||||
rawLocalY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bypass: boolean,
|
||||
bypassSnap: boolean,
|
||||
ignoreId?: string,
|
||||
) => {
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
wallNode: wall,
|
||||
rawLocalX,
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const localY = resolvePlacementY({ event, movingId: '', localX, width, height })
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
const node = WindowNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
|
||||
publishOpeningGuidesForWallEvent({
|
||||
wall: event.node,
|
||||
movingId: node.id,
|
||||
centerS: clampedX,
|
||||
centerY: clampedY,
|
||||
width,
|
||||
height,
|
||||
includeVertical: true,
|
||||
levelYOffset: getLevelYOffset(),
|
||||
slabElevation: getSlabElevation(event),
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const width = draftRef.current?.width ?? 1.5
|
||||
const height = draftRef.current?.height ?? 1.5
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
})
|
||||
const localY = resolvePlacementY({
|
||||
event,
|
||||
movingId: draftRef.current?.id ?? '',
|
||||
wall,
|
||||
movingId: ignoreId ?? '',
|
||||
localX,
|
||||
rawLocalY,
|
||||
width,
|
||||
height,
|
||||
bypassSnap,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
|
||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
// Shared create/update path for the wall draft — used by the direct
|
||||
// wall-mesh hover and the floor proximity snap. Reuses the existing draft
|
||||
// (reparenting only on an actual wall change to avoid churning the host's
|
||||
// children array, which flashes 0-vertex wall geometry in WebGPU).
|
||||
const applyWallTarget = (args: {
|
||||
wall: WallNode
|
||||
rawLocalX: number
|
||||
rawLocalY: number
|
||||
side: 'front' | 'back'
|
||||
itemRotation: number
|
||||
cursorRotationY: number
|
||||
bypass: boolean
|
||||
bypassSnap: boolean
|
||||
}) => {
|
||||
const {
|
||||
wall,
|
||||
rawLocalX,
|
||||
rawLocalY,
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotationY,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
} = args
|
||||
const width = draftRef.current?.width ?? 1.5
|
||||
const height = draftRef.current?.height ?? 1.5
|
||||
|
||||
// Draft may be null after a successful placement (the click handler
|
||||
// deletes it and relies on the wall rebuild → pointer-enter cascade to
|
||||
// recreate it). Recreate it here on the first subsequent move so the
|
||||
// preview is ready for the next click without requiring a leave/enter.
|
||||
if (!draftRef.current) {
|
||||
const levelId = getLevelId()
|
||||
if (levelId && event.node.parentId === levelId) {
|
||||
const node = WindowNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
const node = WindowNode.parse({
|
||||
position: [0, DEFAULT_SILL_CENTER_Y, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: wall.id,
|
||||
parentId: wall.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
|
||||
if (draftRef.current) {
|
||||
// Update the scene store on every move so the 2D floor plan
|
||||
// stays in sync (it re-renders from `node.position`). Only
|
||||
// forward `parentId` / `wallId` when the wall actually changed
|
||||
// — otherwise the reparent path churns the host wall's
|
||||
// `children` array every tick, which re-renders the wall and
|
||||
// briefly draws its 0-vertex placeholder geometry (WebGPU then
|
||||
// flags "Vertex buffer slot 0 ... was not set").
|
||||
const isSameWall = event.node.id === draftRef.current.parentId
|
||||
if (isSameWall) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markHostDirty(event.node.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
// The draft may arrive from a roof-segment face hover.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
const { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||
wall,
|
||||
rawLocalX,
|
||||
rawLocalY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
draftRef.current.id,
|
||||
)
|
||||
|
||||
if (wall.id === draftRef.current.parentId) {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
})
|
||||
markHostDirty(wall.id)
|
||||
} else {
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
// The draft may arrive from a roof-segment face hover.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
wall,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
getSlabElevationForWall(wall),
|
||||
),
|
||||
cursorRotation,
|
||||
cursorRotationY,
|
||||
valid,
|
||||
)
|
||||
|
||||
if (draftRef.current) {
|
||||
publishOpeningGuidesForWallEvent({
|
||||
wall: event.node,
|
||||
wall,
|
||||
movingId: draftRef.current.id,
|
||||
centerS: clampedX,
|
||||
centerY: clampedY,
|
||||
@@ -370,79 +326,44 @@ const WindowTool: React.FC = () => {
|
||||
height,
|
||||
includeVertical: true,
|
||||
levelYOffset: getLevelYOffset(),
|
||||
slabElevation: getSlabElevation(event),
|
||||
slabElevation: getSlabElevationForWall(wall),
|
||||
})
|
||||
}
|
||||
event.stopPropagation()
|
||||
return { clampedX, clampedY, valid }
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = resolveWallSlideAlignment({
|
||||
wallNode: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
width: draftRef.current.width,
|
||||
candidates: alignmentCandidates,
|
||||
bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
|
||||
bypassSnap: event.nativeEvent?.shiftKey === true,
|
||||
})
|
||||
const localY = resolvePlacementY({
|
||||
event,
|
||||
movingId: draftRef.current.id,
|
||||
localX,
|
||||
width: draftRef.current.width,
|
||||
height: draftRef.current.height,
|
||||
})
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
// Promote the draft into a permanent window. Shared by the wall-mesh click
|
||||
// and the floor proximity click.
|
||||
const commitWindowAtWall = (
|
||||
wall: WallNode,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
side: 'front' | 'back',
|
||||
itemRotation: number,
|
||||
) => {
|
||||
const draft = draftRef.current
|
||||
if (!draft) return
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
// Delete transient draft (paused, invisible to undo)
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
|
||||
// Resume → create permanent node (single undoable action)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const levelId = getLevelId()
|
||||
const state = useScene.getState()
|
||||
const windowCount = Object.values(state.nodes).filter((n) => {
|
||||
if (n.type !== 'window') return false
|
||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return wall?.parentId === levelId
|
||||
const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return w?.parentId === levelId
|
||||
}).length
|
||||
const name = `Window ${windowCount + 1}`
|
||||
|
||||
const node = WindowNode.parse({
|
||||
name,
|
||||
name: `Window ${windowCount + 1}`,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
wallId: wall.id,
|
||||
parentId: wall.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
windowType: draft.windowType,
|
||||
@@ -461,20 +382,110 @@ const WindowTool: React.FC = () => {
|
||||
sillThickness: draft.sillThickness,
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useScene.getState().createNode(node, wall.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:structure-build')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
}
|
||||
|
||||
// ── Direct wall-mesh hover ──────────────────────────────────────
|
||||
const onWallHover = (event: WallEvent) => {
|
||||
hostKind = 'wall'
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
if (
|
||||
!isValidWallSideFace(event.normal) ||
|
||||
isCurvedWall(event.node) ||
|
||||
event.node.parentId !== getLevelId()
|
||||
) {
|
||||
destroyDraft()
|
||||
showWallFallbackCursor(event)
|
||||
return
|
||||
}
|
||||
lastWallEvent = event
|
||||
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
|
||||
const flipOffset = sideFlip ? Math.PI : 0
|
||||
const itemRotation = calculateItemRotation(event.normal) + flipOffset
|
||||
const cursorRotation =
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypass = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
applyWallTarget({
|
||||
wall: event.node,
|
||||
rawLocalX: event.localPosition[0],
|
||||
rawLocalY: event.localPosition[1],
|
||||
side,
|
||||
itemRotation,
|
||||
cursorRotationY: cursorRotation,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (
|
||||
!isValidWallSideFace(event.normal) ||
|
||||
isCurvedWall(event.node) ||
|
||||
event.node.parentId !== getLevelId()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide
|
||||
const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0)
|
||||
const bypassSnap = event.nativeEvent?.shiftKey === true
|
||||
const bypass = event.nativeEvent?.altKey === true || bypassSnap
|
||||
|
||||
const { clampedX, clampedY, valid } = resolveWallPlacement(
|
||||
event.node,
|
||||
event.localPosition[0],
|
||||
event.localPosition[1],
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
bypass,
|
||||
bypassSnap,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
if (hostKind !== 'wall') return
|
||||
lastWallEvent = null
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
// ── Floor free-follow ───────────────────────────────────────────
|
||||
// Over open floor the ghost follows the cursor like a moving item. It does
|
||||
// NOT snap from proximity — snapping engages only when the cursor ray
|
||||
// actually hovers a wall (onWallHover) or roof face (onRoofHover).
|
||||
const onGridFreeFollow = (event: GridEvent) => {
|
||||
if (useViewer.getState().cameraDragging) return
|
||||
// A wall/roof mesh handler processed this exact pointermove (R3F + the
|
||||
// grid raycast share the source DOM event's timeStamp) — it owns the
|
||||
// frame and has snapped the draft, so skip the floor follow this tick.
|
||||
const ts = event.nativeEvent?.timeStamp ?? -1
|
||||
if (ts === lastMeshEventTime) return
|
||||
// Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft
|
||||
// and free-follow the cursor with the invalid (unplaceable) ghost.
|
||||
hostKind = null
|
||||
lastWallEvent = null
|
||||
const [x, y, z] = event.localPosition
|
||||
destroyDraft()
|
||||
showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
||||
}
|
||||
|
||||
// ── Roof-segment wall faces ─────────────────────────────────────
|
||||
@@ -501,6 +512,8 @@ const WindowTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onRoofHover = (event: RoofEvent) => {
|
||||
hostKind = 'roof'
|
||||
lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1
|
||||
const target = resolveRoofTarget(event)
|
||||
if (!target) {
|
||||
// On the roof but not over a placeable wall face (slope, soffit,
|
||||
@@ -545,6 +558,7 @@ const WindowTool: React.FC = () => {
|
||||
|
||||
const draft = draftRef.current
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -591,26 +605,44 @@ const WindowTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onRoofLeave = () => {
|
||||
if (!draftRef.current?.roofSegmentId) return
|
||||
if (hostKind !== 'roof') return
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
hostKind = null
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
// R flips the window's facing side mid-placement (front ↔ back), like the
|
||||
// committed-selected R flip. Only meaningful while snapped to a wall (the
|
||||
// off-wall ghost has no orientation), so it acts only then — re-applying
|
||||
// the last wall hover so the snapped preview flips live.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'r' && e.key !== 'R') return
|
||||
if (!lastWallEvent) return
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||
e.preventDefault()
|
||||
sideFlip = !sideFlip
|
||||
triggerSFX('sfx:item-rotate')
|
||||
onWallHover(lastWallEvent)
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallHover)
|
||||
emitter.on('wall:move', onWallHover)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('roof:enter', onRoofHover)
|
||||
emitter.on('roof:move', onRoofHover)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('roof:leave', onRoofLeave)
|
||||
emitter.on('grid:move', showFallbackCursor)
|
||||
emitter.on('grid:move', onGridFreeFollow)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
destroyDraft()
|
||||
@@ -618,16 +650,17 @@ const WindowTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:enter', onWallHover)
|
||||
emitter.off('wall:move', onWallHover)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('roof:enter', onRoofHover)
|
||||
emitter.off('roof:move', onRoofHover)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('roof:leave', onRoofLeave)
|
||||
emitter.off('grid:move', showFallbackCursor)
|
||||
emitter.off('grid:move', onGridFreeFollow)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -643,14 +676,21 @@ const WindowTool: React.FC = () => {
|
||||
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
<>
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
{fallbackPose && (
|
||||
<group position={fallbackPose.position} rotation-y={fallbackPose.rotationY}>
|
||||
<WindowPreview invalid node={ghostStub} />
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user