diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 21d7d3e93..7a6c590d2 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -252,15 +252,14 @@ function polygonCoverageRatio(subject: Point2D[], covers: Point2D[][]) { } // Demoted auto surfaces keep their polygon untouched, so a re-closed room -// usually hits the exact-signature manual check. Coverage also handles a room -// deliberately split across multiple manual surfaces: their union suppresses -// a replacement auto surface as long as the pieces substantially belong to -// and cover the room. +// usually hits the exact-signature manual check. Coverage handles the rest: +// a room split across multiple manual surfaces AND a single manual surface +// spanning multiple rooms both suppress a replacement auto surface — what +// matters is that the ROOM is already substantially covered, not that any +// one manual surface belongs to it (a per-surface "mostly inside the room" +// filter dropped multi-room slabs and resurrected deleted auto slabs). function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) { - const roomManualPolygons = manualPolygons.filter( - (manual) => polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD, - ) - return polygonCoverageRatio(roomPolygon, roomManualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD + return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD } function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { @@ -1357,7 +1356,11 @@ export function isSpaceDetectionPaused(): boolean { } export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void { - const previousSnapshots = new Map() + // Baseline from whatever is already in the store. Detection reacts to wall + // edits made IN-SESSION (create / move / delete); it must not re-litigate a + // scene that merely loaded — rerunning on hydration resurrected auto slabs + // the user had deleted in an earlier session. + const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes) let isProcessing = false const unsubscribe = sceneStore.subscribe((state: any) => { @@ -1380,7 +1383,13 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => const levelsToUpdate = new Set() for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { - if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) { + // First sight of a level is a hydration baseline, not a wall edit — + // `setScene` delivers a loaded scene as one atomic update, and a level's + // first wall can't close a room anyway. Record it (below) and only + // react to subsequent changes. + const previous = previousSnapshots.get(levelId) + if (previous === undefined) continue + if (previous !== (currentSnapshots.get(levelId) ?? '')) { levelsToUpdate.add(levelId) } } diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 2c089f8a1..71e787ab6 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -20,10 +20,7 @@ import { } from '../../../lib/ceiling-plan-snap' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor, { isGridSnapActive } from '../../../store/use-editor' -import useInteractionScope, { - useIsCurveReshape, - useMovingNode, -} from '../../../store/use-interaction-scope' +import useInteractionScope from '../../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' const BRACKET_THICKNESS = 0.04 @@ -99,8 +96,13 @@ export const CeilingSelectionAffordanceSystem = () => { const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const structureLayer = useEditor((state) => state.structureLayer) - const movingNode = useMovingNode() - const isCurveReshape = useIsCurveReshape() + // ANY active interaction (moving/placing a node, reshaping a boundary or + // curve, dragging a handle) unmounts the brackets: their ceiling-height hit + // boxes would otherwise catch drag-time hover, set `hoveredId` to the + // ceiling, and flash the ceiling grid mid-gesture (e.g. while dragging a + // slab polygon vertex). The brackets' own corner drag doesn't begin a + // scope, so it can't unmount itself. + const scopeIdle = useInteractionScope((state) => state.scope.kind === 'idle') const currentLevelId = useViewer((state) => state.selection.levelId) const ceilings = useScene( @@ -120,8 +122,7 @@ export const CeilingSelectionAffordanceSystem = () => { phase === 'structure' && mode === 'select' && structureLayer === 'elements' && - !movingNode && - !isCurveReshape && + scopeIdle && currentLevelId !== null if (!shouldRender) return null diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index ce503c53c..0d3821259 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -3,7 +3,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { Color, type Material, type Mesh } from 'three' import useEditor from '../../../store/use-editor' -import { useMovingNode } from '../../../store/use-interaction-scope' +import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope' const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff' const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial' @@ -80,11 +80,18 @@ export const CeilingSystem = () => { const selectedIds = useViewer((state) => state.selection.selectedIds) const activeLevelId = useViewer((state) => state.selection.levelId) const hoveredId = useViewer((state) => state.hoveredId) + // A pending gesture (polygon/handle drag, reshape, move) must not flash the + // ceiling grid when the pointer strays over a ceiling — spatial hover events + // keep firing during host drags by design (see use-node-events), so the + // reveal is gated here, on the consumer. + const inputDragging = useViewer((state) => state.inputDragging) + const scopeIdle = useInteractionScope((state) => state.scope.kind === 'idle') useEffect(() => { const nodes = useScene.getState().nodes const hoveredNode = hoveredId ? nodes[hoveredId as AnyNodeId] : null - const hoveredCeilingId = hoveredNode?.type === 'ceiling' ? hoveredNode.id : null + const hoveredCeilingId = + hoveredNode?.type === 'ceiling' && scopeIdle && !inputDragging ? hoveredNode.id : null const levelsToShowCeilings = new Set() @@ -153,6 +160,15 @@ export const CeilingSystem = () => { } } }) - }, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId]) + }, [ + tool, + selectedItem, + movingNode, + selectedIds, + activeLevelId, + hoveredId, + scopeIdle, + inputDragging, + ]) return null } diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 506cb7070..df66ff384 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -1,6 +1,6 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' -import { createPortal, type ThreeEvent } from '@react-three/fiber' +import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, @@ -13,6 +13,7 @@ import { type Line, type Object3D, Shape, + Vector3, } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' @@ -41,6 +42,19 @@ const EDGE_ARROW_OFFSET = 0.34 // the `NO_RAYCAST` sentinel in node-arrow-handles.tsx. const NO_RAYCAST = () => null +// Maps the screen-space direction of an edge's outward normal to the closest +// directional resize cursor, so an edge arrow advertises the axis it will +// actually drag along regardless of camera orbit. Input is an NDC delta +// (y up); the angle is folded to [0°, 180°) since resize cursors are +// bidirectional. +function resizeCursorForScreenDirection(dx: number, dy: number): string { + const angle = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 180) + 180) % 180 + if (angle < 22.5 || angle >= 157.5) return 'ew-resize' + if (angle < 67.5) return 'nesw-resize' + if (angle < 112.5) return 'ns-resize' + return 'nwse-resize' +} + function createEdgeArrowGeometry() { const shape = new Shape() shape.moveTo(0.22, 0) @@ -201,7 +215,11 @@ export type PolygonMidpointHandleRenderer = ( props: PolygonMidpointHandleRenderProps, ) => React.ReactNode -function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial { +function usePolygonNodeMaterial( + color: string, + opacity = 1, + depthTest = true, +): MeshBasicNodeMaterial { const material = useMemo( () => new MeshBasicNodeMaterial({ @@ -217,7 +235,8 @@ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMateri useEffect(() => { material.color.set(color) material.opacity = opacity - }, [color, material, opacity]) + material.depthTest = depthTest + }, [color, depthTest, material, opacity]) useEffect(() => () => material.dispose(), [material]) return material @@ -239,7 +258,8 @@ function usePolygonArrowMaterial(): MeshBasicNodeMaterial { } // One mesh per handle: lives on SCENE_LAYER with a node material so the -// post-processing ink-edge pass outlines it. +// post-processing ink-edge pass outlines it. Ignores scene depth like the +// edge arrows so the handle stays visible through walls and slabs. function OutlinedCylinderHandle({ radius, height, @@ -255,7 +275,7 @@ function OutlinedCylinderHandle({ position: [number, number, number] } & PolygonHandleHandlers) { const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius]) - const material = usePolygonNodeMaterial(color, opacity) + const material = usePolygonNodeMaterial(color, opacity, false) useEffect(() => () => geometry.dispose(), [geometry]) return ( @@ -452,6 +472,39 @@ export const PolygonEditor: React.FC = ({ // When not using portal, edit at world origin const editY = levelNode ? Y_OFFSET : 0 + const camera = useThree((state) => state.camera) + + // Handle-hover cursor, applied to document.body like the registry arrow + // handles (see handle-arrow.tsx). Tracked in a ref so we only ever clear a + // cursor we set ourselves. + const activeBodyCursorRef = useRef(null) + const setBodyCursor = useCallback((cursor: string) => { + activeBodyCursorRef.current = cursor + document.body.style.cursor = cursor + }, []) + const clearBodyCursor = useCallback(() => { + if (activeBodyCursorRef.current && document.body.style.cursor === activeBodyCursorRef.current) { + document.body.style.cursor = '' + } + activeBodyCursorRef.current = null + }, []) + useEffect(() => () => clearBodyCursor(), [clearBodyCursor]) + + const edgeResizeCursor = useCallback( + (midpoint: [number, number], outwardNormal: [number, number]) => { + const from = new Vector3(midpoint[0], editY, midpoint[1]) + const to = new Vector3(midpoint[0] + outwardNormal[0], editY, midpoint[1] + outwardNormal[1]) + if (levelNode) { + levelNode.localToWorld(from) + levelNode.localToWorld(to) + } + from.project(camera) + to.project(camera) + return resizeCursorForScreenDirection(to.x - from.x, to.y - from.y) + }, + [camera, editY, levelNode], + ) + // Local state for dragging const [dragState, setDragState] = useState(null) const [previewPolygon, setPreviewPolygon] = useState | null>(null) @@ -706,7 +759,8 @@ export const PolygonEditor: React.FC = ({ onDragCommitRef.current?.() updatePreviewPolygon(null) setDragState(null) - }, [onPolygonChange, updatePreviewPolygon]) + clearBodyCursor() + }, [clearBodyCursor, onPolygonChange, updatePreviewPolygon]) // Handle adding a new vertex at midpoint const handleAddVertex = useCallback( @@ -930,13 +984,16 @@ export const PolygonEditor: React.FC = ({ ) })} - {/* Vertex handles - blue cylinders that match surface height */} + {/* Vertex handles - blue cylinders that match surface height. During a + drag only the active handle stays mounted so the gesture reads + clearly (same rule for the cross / edge handles below). */} {displayPolygon.map(([x, z], index) => { const isHovered = hoveredVertex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index + if (dragState && !isDragging) return null const isLinkedHighlighted = isVertexLinkedHighlighted(index) const isHighlighted = isDragging || isHovered || isLinkedHighlighted - const radius = 0.1 + const radius = 0.08 const height = handleHeight const point: [number, number] = [x!, z!] const position: [number, number, number] = [x!, editY + height / 2, z!] @@ -969,10 +1026,14 @@ export const PolygonEditor: React.FC = ({ onPointerEnter: (e) => { e.stopPropagation() setHoveredVertex(index) + setBodyCursor('move') }, onPointerLeave: (e) => { e.stopPropagation() setHoveredVertex(null) + // Keep the cursor for the whole gesture when the pointer slips + // off the handle mid-drag; commit clears it. + if (!dragState?.isDragging) clearBodyCursor() }, } @@ -1006,7 +1067,7 @@ export const PolygonEditor: React.FC = ({ ) })} - {allowPolygonMove && ( + {allowPolygonMove && (!dragState || dragState.mode === 'polygon') && ( { @@ -1026,14 +1087,22 @@ export const PolygonEditor: React.FC = ({ pointerId: e.pointerId, }) }} + onPointerEnter={(e) => { + e.stopPropagation() + setBodyCursor('move') + }} + onPointerLeave={(e) => { + e.stopPropagation() + if (!dragState?.isDragging) clearBodyCursor() + }} position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]} /> )} - {allowEdgeMove && edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => { const isHovered = hoveredEdge === index const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index + if (dragState && !isDragging) return null const isLinkedHighlighted = highlightedEdgeIndices.has(index) const isHighlighted = isDragging || isHovered || isLinkedHighlighted const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET @@ -1100,10 +1169,12 @@ export const PolygonEditor: React.FC = ({ onPointerEnter={(e) => { e.stopPropagation() setHoveredEdge(index) + setBodyCursor(edgeResizeCursor(midpoint, outwardNormal)) }} onPointerLeave={(e) => { e.stopPropagation() setHoveredEdge(null) + if (!dragState?.isDragging) clearBodyCursor() }} position={[arrowX, edgeHandleY, arrowZ]} rotationY={outwardAngle} @@ -1120,7 +1191,7 @@ export const PolygonEditor: React.FC = ({ const isHovered = hoveredMidpoint === index const isLinkedHighlighted = highlightedEdgeIndices.has(index) const isHighlighted = isHovered || isLinkedHighlighted - const radius = 0.06 + const radius = 0.05 const height = handleHeight const point: [number, number] = [x!, z!] const position: [number, number, number] = [x!, editY + height / 2, z!] @@ -1149,10 +1220,12 @@ export const PolygonEditor: React.FC = ({ onPointerEnter: (e) => { e.stopPropagation() setHoveredMidpoint(index) + setBodyCursor('move') }, onPointerLeave: (e) => { e.stopPropagation() setHoveredMidpoint(null) + clearBodyCursor() }, } @@ -1174,7 +1247,9 @@ export const PolygonEditor: React.FC = ({ return ( s.trim()), ).has('shadows') +// Diagnostic toggle: `?debug=shadowcamera` draws a CameraHelper for each +// shadow camera so the building-fit frustum (and thus shadow texel density) +// can be inspected while tuning margins/bias. +const SHADOW_CAMERA_DEBUG = + typeof window !== 'undefined' && + new Set( + (new URLSearchParams(window.location.search).get('debug') ?? '') + .split(',') + .map((s) => s.trim()), + ).has('shadowcamera') + // Shadow darkness for the bright key lights (themes drive most lights past // intensity 1). Runs high so shadowed areas actually lose the sun's // contribution — the ambient/hemisphere/IBL stack provides the fill. The old @@ -32,9 +43,12 @@ const MAX_SHADOW_INTENSITY = 0.75 // `normalBias` is measured in world units. The previous 0.3 moved shadow // lookups 30 cm off their surfaces, visibly detaching wall shadows at the -// floor. Keep only a small offset for acne, with a tiny depth bias alongside it. -const SHADOW_DEPTH_BIAS = -0.0001 -const SHADOW_NORMAL_BIAS = 0.02 +// floor; 0.07 and below still acnes on the building-fit 1024 map (large +// texels), so 0.08 is the smallest acne-free value at that resolution — even +// with the depth bias at -0.0005 (which is itself needed: 0.08 alone still +// showed faint acne at -0.0001). +const SHADOW_DEPTH_BIAS = -0.0005 +const SHADOW_NORMAL_BIAS = 0.08 // Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the // camera): we union the bounds of all registered scene nodes, fit a sphere, and @@ -77,6 +91,7 @@ export function Lights() { const boundsBox = useRef(new THREE.Box3()) // scratch: union AABB const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never) + const shadowHelpers = useRef>([]) const hemiRef = useRef(null) const ambientRef = useRef(null) @@ -170,6 +185,15 @@ export function Lights() { cam.near = near cam.far = far cam.updateProjectionMatrix() + if (SHADOW_CAMERA_DEBUG) { + let helper = shadowHelpers.current[index] + if (!helper) { + helper = new THREE.CameraHelper(cam) + shadowHelpers.current[index] = helper + state.scene.add(helper) + } + helper.update() + } } } }