fix(editor): placement grid — wall-anchored, perf, first-frame wall seed
Validated live by Wassim. - Wall grid is anchored to the wall PLANE (its foot), not the moving ghost, so sliding a door/window only moves the reveal patch — the lattice stays a fixed snap reference instead of "following" the opening. - DoubleSide so the lattice renders when an opening is handled from the far side. - depthTest is conditional: ON for the floor (the ground occludes a sub-floor lattice) and OFF on a wall (visible through the wall from the opposite side). - Resolution change is a uniform write only — `cellSize` no longer rebuilds the uniform + material (which recompiled the shader and stalled on every step). - Y follow snaps instantly (was a lerp); `gridY` state only updates on change. - Reveal radius 5 → 12. - Door/window publish the wall surface on mount (+ claim the pointer for the wall) so the grid is vertical from the FIRST frame — no horizontal flash. - Export the active-placement-surface module so the opening tools can publish. - Drop the editor-side "Show Grid" setting: the 3D grid is now purely a placement aid (shown only while placing/moving in grid-snap mode). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
67a558a883
commit
5933a00247
@@ -4,7 +4,7 @@ import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-
|
|||||||
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { MathUtils, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
|
import { DoubleSide, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
|
||||||
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
||||||
@@ -16,7 +16,7 @@ import useInteractionScope, { getMovingNode } from '../../store/use-interaction-
|
|||||||
// Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
|
// Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
|
||||||
// grid-snap mode — much tighter than the idle reveal so only the area you're
|
// grid-snap mode — much tighter than the idle reveal so only the area you're
|
||||||
// about to snap into lights up.
|
// about to snap into lights up.
|
||||||
const PLACEMENT_REVEAL_RADIUS = 5
|
const PLACEMENT_REVEAL_RADIUS = 12
|
||||||
|
|
||||||
const UP = new Vector3(0, 1, 0)
|
const UP = new Vector3(0, 1, 0)
|
||||||
// PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal
|
// PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal
|
||||||
@@ -54,13 +54,25 @@ export const Grid = ({
|
|||||||
const cursorPositionRef = useRef(new Vector2(0, 0))
|
const cursorPositionRef = useRef(new Vector2(0, 0))
|
||||||
// Scratch for reading a moving node's world Y (surface elevation) each frame.
|
// Scratch for reading a moving node's world Y (surface elevation) each frame.
|
||||||
const worldPosRef = useRef(new Vector3())
|
const worldPosRef = useRef(new Vector3())
|
||||||
|
// Scratch for the wall-anchored branch: invert the plane orientation to map the
|
||||||
|
// ghost into plane-local XY (the cursor reveal) without re-centring the mesh.
|
||||||
|
const invQuatRef = useRef(new Quaternion())
|
||||||
|
const wallCursorRef = useRef(new Vector3())
|
||||||
|
// Last Y pushed to `gridY` state, so the per-frame surface follow only triggers
|
||||||
|
// a React re-render when the height actually changes (not every frame).
|
||||||
|
const lastGridYRef = useRef<number | null>(null)
|
||||||
|
|
||||||
// Reveal radius + baseline alpha are uniforms so a placement/move can shrink
|
// Reveal radius + baseline alpha are uniforms so a placement/move can shrink
|
||||||
// the grid to a tight cursor patch (and drop the always-on baseline) without
|
// the grid to a tight cursor patch (and drop the always-on baseline) without
|
||||||
// rebuilding the shader. Driven each frame in `useFrame`.
|
// rebuilding the shader. Driven each frame in `useFrame`.
|
||||||
const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius])
|
const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius])
|
||||||
const baseAlphaUniform = useMemo(() => uniform(0.4), [])
|
const baseAlphaUniform = useMemo(() => uniform(0.4), [])
|
||||||
const cellSizeUniform = useMemo(() => uniform(cellSize), [cellSize])
|
// Created once and driven by `.value` each frame (see `useFrame`). Keying this
|
||||||
|
// on `cellSize` rebuilt the uniform AND the material `useMemo` below on every
|
||||||
|
// `gridSnapStep` change — a full shader recompile that stalled hard whenever
|
||||||
|
// the grid resolution changed. The live cell size is a uniform write only.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: created once on purpose; `.value` is driven each frame.
|
||||||
|
const cellSizeUniform = useMemo(() => uniform(cellSize), [])
|
||||||
const patchAlphaUniform = useMemo(() => uniform(1), [])
|
const patchAlphaUniform = useMemo(() => uniform(1), [])
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
@@ -124,6 +136,14 @@ export const Grid = ({
|
|||||||
colorNode: gridColor,
|
colorNode: gridColor,
|
||||||
opacityNode: finalAlpha,
|
opacityNode: finalAlpha,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
|
// `depthTest` is toggled per-frame in `useFrame`: ON for the floor lattice
|
||||||
|
// (so the ground occludes a sub-floor grid) and OFF on a wall (so the
|
||||||
|
// lattice shows through the wall when the opening is handled from the far
|
||||||
|
// side). Default ON for the floor case.
|
||||||
|
depthTest: true,
|
||||||
|
// Wall-plane placements are handled from either side of the wall, so the
|
||||||
|
// lattice must render from both faces.
|
||||||
|
side: DoubleSide,
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
cellThickness,
|
cellThickness,
|
||||||
@@ -150,13 +170,10 @@ export const Grid = ({
|
|||||||
useCeilingEvents()
|
useCeilingEvents()
|
||||||
|
|
||||||
// Track the last world-space cursor hit. The reveal-fade shader reads
|
// Track the last world-space cursor hit. The reveal-fade shader reads
|
||||||
// `positionLocal.xy` (vertex position on the un-transformed plane), and
|
// `positionLocal.xy` (vertex position on the un-transformed plane), and the
|
||||||
// the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z`
|
// laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the
|
||||||
// relative to the mesh origin. The mesh origin itself is lerped each
|
// mesh origin. The cursor is recomputed every frame from the stored world hit
|
||||||
// frame toward the active building's world XZ (see `useFrame` below),
|
// so the reveal stays put regardless of where the mesh origin sits.
|
||||||
// so the local-frame cursor must be recomputed every frame from the
|
|
||||||
// stored world cursor — otherwise the ring drifts whenever the grid is
|
|
||||||
// mid-lerp (e.g. just after a building rotation commits).
|
|
||||||
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
|
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
@@ -169,7 +186,7 @@ export const Grid = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame(() => {
|
||||||
const { levelId } = useViewer.getState().selection
|
const { levelId } = useViewer.getState().selection
|
||||||
let levelY = 0
|
let levelY = 0
|
||||||
if (levelId) {
|
if (levelId) {
|
||||||
@@ -198,26 +215,50 @@ export const Grid = ({
|
|||||||
const gridMesh = gridRef.current
|
const gridMesh = gridRef.current
|
||||||
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
|
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
|
||||||
if (onWall && surfacePoint) {
|
if (onWall && surfacePoint) {
|
||||||
// Vertical surface: drop the plane onto the wall at the contact point and
|
// Wall-anchored lattice: orient the plane into the wall and pin the mesh to
|
||||||
// orient it into the wall plane. The patch reveals centred there — a wall
|
// the plane's FOOT (the point on the wall plane closest to the world origin)
|
||||||
// has no world-anchored floor lattice to track.
|
// — never the moving ghost. Sliding the opening along the wall then only
|
||||||
gridMesh.position.copy(surfacePoint)
|
// moves the reveal patch (the cursor uniform); the snap lattice stays put.
|
||||||
|
// (Copying `surfacePoint` here made the grid follow the item — useless.)
|
||||||
gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
|
gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
|
||||||
cursorPositionRef.current.set(0, 0)
|
const planeOffset = surfacePoint.dot(surfaceNormal)
|
||||||
setGridY(surfacePoint.y)
|
gridMesh.position.copy(surfaceNormal).multiplyScalar(planeOffset)
|
||||||
|
// Cursor → plane-local XY: rotate (ghost − anchor) by the inverse plane
|
||||||
|
// orientation. Both lie in the plane, so the resulting local Z is ~0.
|
||||||
|
invQuatRef.current.copy(gridMesh.quaternion).invert()
|
||||||
|
wallCursorRef.current
|
||||||
|
.copy(surfacePoint)
|
||||||
|
.sub(gridMesh.position)
|
||||||
|
.applyQuaternion(invQuatRef.current)
|
||||||
|
cursorPositionRef.current.set(wallCursorRef.current.x, wallCursorRef.current.y)
|
||||||
|
if (lastGridYRef.current !== surfacePoint.y) {
|
||||||
|
lastGridYRef.current = surfacePoint.y
|
||||||
|
setGridY(surfacePoint.y)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Horizontal: keep the lattice anchored to world XZ (0,0); only the Y
|
// Horizontal: keep the lattice anchored to world XZ (0,0); only the Y
|
||||||
// origin follows the surface height (floor / shelf top), lerped. Cursor
|
// origin follows the surface height (floor / shelf top). Snap directly —
|
||||||
// uniform tracks the world cursor (mirrored on Z for the laid-flat plane).
|
// the old lerp made the grid visibly drift up to a new floor height.
|
||||||
|
// Cursor uniform tracks the world cursor (mirrored on Z for the flat plane).
|
||||||
const targetY = surfacePoint ? surfacePoint.y : levelY
|
const targetY = surfacePoint ? surfacePoint.y : levelY
|
||||||
const newY = MathUtils.lerp(gridMesh.position.y, targetY, 12 * delta)
|
gridMesh.position.set(0, targetY, 0)
|
||||||
gridMesh.position.set(0, newY, 0)
|
|
||||||
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
|
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
|
||||||
const world = lastWorldCursorRef.current
|
const world = lastWorldCursorRef.current
|
||||||
if (world) {
|
if (world) {
|
||||||
cursorPositionRef.current.set(world.x, -world.z)
|
cursorPositionRef.current.set(world.x, -world.z)
|
||||||
}
|
}
|
||||||
setGridY(newY)
|
if (lastGridYRef.current !== targetY) {
|
||||||
|
lastGridYRef.current = targetY
|
||||||
|
setGridY(targetY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor grid depth-tests against the scene (ground occludes a sub-floor
|
||||||
|
// lattice); the wall grid ignores depth so it stays visible through the wall
|
||||||
|
// when the opening is being handled from the opposite side.
|
||||||
|
if (material.depthTest === onWall) {
|
||||||
|
material.depthTest = !onWall
|
||||||
|
material.needsUpdate = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// While placing/moving: in grid-snap mode shrink to a tight cursor patch
|
// While placing/moving: in grid-snap mode shrink to a tight cursor patch
|
||||||
@@ -243,7 +284,9 @@ export const Grid = ({
|
|||||||
baseAlphaUniform.value = 0
|
baseAlphaUniform.value = 0
|
||||||
cellSizeUniform.value = useEditor.getState().gridSnapStep
|
cellSizeUniform.value = useEditor.getState().gridSnapStep
|
||||||
patchAlphaUniform.value = 1.5
|
patchAlphaUniform.value = 1.5
|
||||||
gridRef.current.visible = useViewer.getState().showGrid && snapPatchVisible
|
// The 3D grid is purely a placement aid now (no user-facing show/hide
|
||||||
|
// setting): visible only while actively placing/moving in grid-snap mode.
|
||||||
|
gridRef.current.visible = snapPatchVisible
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pass the geometry as a prop instead of a JSX child so the mesh
|
// Pass the geometry as a prop instead of a JSX child so the mesh
|
||||||
@@ -261,6 +304,12 @@ export const Grid = ({
|
|||||||
return (
|
return (
|
||||||
// Orientation is driven imperatively in `useFrame` (horizontal by default,
|
// Orientation is driven imperatively in `useFrame` (horizontal by default,
|
||||||
// tilted into the wall plane while placing on a wall), so no static rotation.
|
// tilted into the wall plane while placing on a wall), so no static rotation.
|
||||||
<mesh geometry={geometry} layers={GRID_LAYER} material={material} ref={gridRef} />
|
<mesh
|
||||||
|
geometry={geometry}
|
||||||
|
layers={GRID_LAYER}
|
||||||
|
material={material}
|
||||||
|
ref={gridRef}
|
||||||
|
renderOrder={1}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ export function SettingsPanel({
|
|||||||
const clearScene = useScene((state) => state.clearScene)
|
const clearScene = useScene((state) => state.clearScene)
|
||||||
const resetSelection = useViewer((state) => state.resetSelection)
|
const resetSelection = useViewer((state) => state.resetSelection)
|
||||||
const exportScene = useViewer((state) => state.exportScene)
|
const exportScene = useViewer((state) => state.exportScene)
|
||||||
const showGrid = useViewer((state) => state.showGrid)
|
|
||||||
const shadows = useViewer((state) => state.shadows)
|
const shadows = useViewer((state) => state.shadows)
|
||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
||||||
@@ -331,16 +330,6 @@ export function SettingsPanel({
|
|||||||
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
|
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-sm">Show Grid</div>
|
|
||||||
<div className="text-muted-foreground text-xs">Visible only in the editor</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={showGrid}
|
|
||||||
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-sm">Shadows</div>
|
<div className="font-medium text-sm">Shadows</div>
|
||||||
|
|||||||
@@ -210,6 +210,12 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
|
|||||||
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
|
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
|
||||||
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
||||||
export { type Selection, useSelection } from './hooks/use-selection'
|
export { type Selection, useSelection } from './hooks/use-selection'
|
||||||
|
export {
|
||||||
|
clearPlacementSurface,
|
||||||
|
getPlacementSurface,
|
||||||
|
type PlacementSurface,
|
||||||
|
publishPlacementSurface,
|
||||||
|
} from './lib/active-placement-surface'
|
||||||
export {
|
export {
|
||||||
CEILING_ALIGNMENT_THRESHOLD_M,
|
CEILING_ALIGNMENT_THRESHOLD_M,
|
||||||
type CeilingPlanSnapInput,
|
type CeilingPlanSnapInput,
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
|
clearPlacementSurface,
|
||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
|
publishPlacementSurface,
|
||||||
stripPlacementMetadataFlags,
|
stripPlacementMetadataFlags,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
@@ -28,7 +30,7 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
clearOpeningGuides3D,
|
clearOpeningGuides3D,
|
||||||
@@ -230,6 +232,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
setGhostPose(null)
|
setGhostPose(null)
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — only OTHER things on a wall (sibling openings +
|
// Alignment candidates — only OTHER things on a wall (sibling openings +
|
||||||
@@ -402,6 +405,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
rotationY: ghostYaw,
|
rotationY: ghostYaw,
|
||||||
depth: movingDoorNode.frameDepth ?? 0.07,
|
depth: movingDoorNode.frameDepth ?? 0.07,
|
||||||
})
|
})
|
||||||
|
// Publish the wall surface so the snap grid tilts into the wall plane at
|
||||||
|
// the opening (its outward normal is the door's facing, +Z by `ghostYaw`).
|
||||||
|
publishPlacementSurface(
|
||||||
|
new Vector3(...ghostWorldPos),
|
||||||
|
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
|
||||||
|
)
|
||||||
|
|
||||||
publishOpeningGuidesForWallEvent({
|
publishOpeningGuidesForWallEvent({
|
||||||
wall: target.wallNode,
|
wall: target.wallNode,
|
||||||
@@ -569,6 +578,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
const revealRealNode = () => {
|
const revealRealNode = () => {
|
||||||
setGhostPose(null)
|
setGhostPose(null)
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
|
const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
|
||||||
if (live && live.visible === false) {
|
if (live && live.visible === false) {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
|
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
|
||||||
@@ -629,8 +639,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
tint: 'invalid',
|
tint: 'invalid',
|
||||||
side: sideOverride,
|
side: sideOverride,
|
||||||
})
|
})
|
||||||
// Off-wall (no host) floating ghost — no direction triangle.
|
// Off-wall (no host) floating ghost — no direction triangle, no wall grid.
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
@@ -903,6 +914,43 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
window.addEventListener('keydown', onAltToggle)
|
window.addEventListener('keydown', onAltToggle)
|
||||||
window.addEventListener('keyup', onAltToggle)
|
window.addEventListener('keyup', onAltToggle)
|
||||||
|
|
||||||
|
// Seed the wall snap surface on mount so the grid tilts into the wall on the
|
||||||
|
// FIRST frame — before any pointer move. Without it the grid briefly shows
|
||||||
|
// the moving node's horizontal fallback until the first `wall:move` publishes.
|
||||||
|
// Only applies to a door already hosted on a wall (not a fresh placement or a
|
||||||
|
// roof-segment host).
|
||||||
|
if (!isNew && movingDoorNode.wallId) {
|
||||||
|
const hostWall = useScene.getState().nodes[movingDoorNode.wallId as AnyNodeId]
|
||||||
|
if (hostWall?.type === 'wall') {
|
||||||
|
const wallAngle = Math.atan2(
|
||||||
|
hostWall.end[1] - hostWall.start[1],
|
||||||
|
hostWall.end[0] - hostWall.start[0],
|
||||||
|
)
|
||||||
|
const ghostYaw = movingDoorNode.rotation[1] - wallAngle
|
||||||
|
const seedPos = wallLocalToWorld(
|
||||||
|
hostWall,
|
||||||
|
movingDoorNode.position[0],
|
||||||
|
movingDoorNode.position[1],
|
||||||
|
getLevelYOffset(),
|
||||||
|
spatialGridManager.getSlabElevationForWall(
|
||||||
|
hostWall.parentId ?? '',
|
||||||
|
hostWall.start,
|
||||||
|
hostWall.end,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
publishPlacementSurface(
|
||||||
|
new Vector3(...seedPos),
|
||||||
|
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
|
||||||
|
)
|
||||||
|
// Claim the pointer for the wall so the floor free-follow stands down for
|
||||||
|
// the first frames after grab. Otherwise the first `grid:move` (the door
|
||||||
|
// mesh occludes the wall under the cursor, so no `wall:move` fires yet)
|
||||||
|
// takes the off-wall branch and clears the seeded surface — the grid would
|
||||||
|
// flash back to horizontal before `wall:move` re-publishes the vertical one.
|
||||||
|
markWallOwnedPointer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||||
| DoorNode
|
| DoorNode
|
||||||
@@ -938,6 +986,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
|
clearPlacementSurface,
|
||||||
consumePlacementDragRelease,
|
consumePlacementDragRelease,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
|
publishPlacementSurface,
|
||||||
snapToHalf,
|
snapToHalf,
|
||||||
stripPlacementMetadataFlags,
|
stripPlacementMetadataFlags,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
@@ -29,7 +31,7 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
clearOpeningGuides3D,
|
clearOpeningGuides3D,
|
||||||
@@ -263,6 +265,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
setGhostPose(null)
|
setGhostPose(null)
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — only OTHER things on a wall (sibling openings +
|
// Alignment candidates — only OTHER things on a wall (sibling openings +
|
||||||
@@ -442,6 +445,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
rotationY: ghostYaw,
|
rotationY: ghostYaw,
|
||||||
depth: movingWindowNode.frameDepth ?? 0.07,
|
depth: movingWindowNode.frameDepth ?? 0.07,
|
||||||
})
|
})
|
||||||
|
// Publish the wall surface so the snap grid tilts into the wall plane at
|
||||||
|
// the opening (its outward normal is the window's facing, +Z by `ghostYaw`).
|
||||||
|
publishPlacementSurface(
|
||||||
|
new Vector3(...ghostWorldPos),
|
||||||
|
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
|
||||||
|
)
|
||||||
|
|
||||||
publishOpeningGuidesForWallEvent({
|
publishOpeningGuidesForWallEvent({
|
||||||
wall: target.wallNode,
|
wall: target.wallNode,
|
||||||
@@ -612,6 +621,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
const revealRealNode = () => {
|
const revealRealNode = () => {
|
||||||
setGhostPose(null)
|
setGhostPose(null)
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
||||||
| WindowNode
|
| WindowNode
|
||||||
| undefined
|
| undefined
|
||||||
@@ -669,8 +679,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
floorY: getLevelYOffset(),
|
floorY: getLevelYOffset(),
|
||||||
side: sideOverride,
|
side: sideOverride,
|
||||||
})
|
})
|
||||||
// Off-wall (no host) floating ghost — no direction triangle.
|
// Off-wall (no host) floating ghost — no direction triangle, no wall grid.
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
@@ -939,6 +950,43 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
window.addEventListener('keydown', onAltToggle)
|
window.addEventListener('keydown', onAltToggle)
|
||||||
window.addEventListener('keyup', onAltToggle)
|
window.addEventListener('keyup', onAltToggle)
|
||||||
|
|
||||||
|
// Seed the wall snap surface on mount so the grid tilts into the wall on the
|
||||||
|
// FIRST frame — before any pointer move. Without it the grid briefly shows
|
||||||
|
// the moving node's horizontal fallback until the first `wall:move` publishes.
|
||||||
|
// Only applies to a window already hosted on a wall (not a fresh placement or
|
||||||
|
// a roof-segment host).
|
||||||
|
if (!isNew && movingWindowNode.wallId) {
|
||||||
|
const hostWall = useScene.getState().nodes[movingWindowNode.wallId as AnyNodeId]
|
||||||
|
if (hostWall?.type === 'wall') {
|
||||||
|
const wallAngle = Math.atan2(
|
||||||
|
hostWall.end[1] - hostWall.start[1],
|
||||||
|
hostWall.end[0] - hostWall.start[0],
|
||||||
|
)
|
||||||
|
const ghostYaw = movingWindowNode.rotation[1] - wallAngle
|
||||||
|
const seedPos = wallLocalToWorld(
|
||||||
|
hostWall,
|
||||||
|
movingWindowNode.position[0],
|
||||||
|
movingWindowNode.position[1],
|
||||||
|
getLevelYOffset(),
|
||||||
|
spatialGridManager.getSlabElevationForWall(
|
||||||
|
hostWall.parentId ?? '',
|
||||||
|
hostWall.start,
|
||||||
|
hostWall.end,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
publishPlacementSurface(
|
||||||
|
new Vector3(...seedPos),
|
||||||
|
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
|
||||||
|
)
|
||||||
|
// Claim the pointer for the wall so the floor free-follow stands down for
|
||||||
|
// the first frames after grab. Otherwise the first `grid:move` (the window
|
||||||
|
// mesh occludes the wall under the cursor, so no `wall:move` fires yet)
|
||||||
|
// takes the off-wall branch and clears the seeded surface — the grid would
|
||||||
|
// flash back to horizontal before `wall:move` re-publishes the vertical one.
|
||||||
|
markWallOwnedPointer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||||
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
||||||
@@ -973,6 +1021,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementSurface()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
|
|||||||
Reference in New Issue
Block a user