Adjustments pass: shadow bias, polygon editor UX, space-detection load fix (#502)

* fix(viewer): tune shadow biases to stop acne without detaching shadows

normalBias 0.02 was too small a texel offset for the building-fit 1024
shadow map and brought back self-shadowing acne. Settle on normalBias 0.08
(0.07 and below acnes, 0.1 reads detached) plus depth bias -0.0005 to
suppress the residual acne that a normal bias alone couldn't clear.

Also adds a `?debug=shadowcamera` diagnostic that draws a CameraHelper for
each shadow camera so the fitted frustum can be inspected while tuning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): polygon editor handle UX — focus, cursors, visibility

- vertex/midpoint cylinders ignore scene depth like the edge arrows so
  they stay visible through walls and slabs; vertex radius 0.1 -> 0.08,
  midpoint 0.06 -> 0.05, midpoints use the brighter arrow shade at rest
- during any drag only the active handle stays mounted (other arrows,
  vertices, edge bars and the cross disappear) so the gesture reads clearly
- handles set pointer cursors: move for vertices/midpoints/cross, and a
  screen-space direction-aware resize cursor for the edge arrows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): keep ceiling affordances quiet during any interaction

The ceiling corner brackets' hit boxes caught drag-time hover (spatial
events keep firing during host drags by design), set hoveredId to the
ceiling and flashed the ceiling grid mid-gesture — e.g. while dragging a
slab polygon vertex. Unmount the brackets while ANY interaction scope is
active, and gate CeilingSystem's hover-driven grid reveal on idle scope +
no inputDragging as a second layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): stop room detection resurrecting deleted slabs on load

Two holes in the wall-driven auto slab sync:

- initSpaceDetectionSync started with an empty baseline, so scene
  hydration (one atomic setScene) read as 'every wall changed' and ran a
  full detection pass on every load, recreating auto slabs the user had
  deleted. Seed the baseline from the store at init and treat a level's
  first snapshot as baseline — detection now only reacts to in-session
  wall edits.

- matchesManualFootprint required mutual coverage, so a single manual
  slab spanning multiple rooms never suppressed those rooms' auto slabs
  (only a fraction of it lies inside each room). Suppression now only
  asks whether the ROOM is substantially covered by the union of manual
  slabs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-16 10:55:05 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 9ca3eaa7fe
commit 22c9472066
5 changed files with 161 additions and 36 deletions
+19 -10
View File
@@ -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<string, string>()
// 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<string>()
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)
}
}
@@ -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
@@ -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<string>()
@@ -153,6 +160,15 @@ export const CeilingSystem = () => {
}
}
})
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId])
}, [
tool,
selectedItem,
movingNode,
selectedIds,
activeLevelId,
hoveredId,
scopeIdle,
inputDragging,
])
return null
}
@@ -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<PolygonEditorProps> = ({
// 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<string | null>(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<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
@@ -706,7 +759,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
)
})}
{/* 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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
)
})}
{allowPolygonMove && (
{allowPolygonMove && (!dragState || dragState.mode === 'polygon') && (
<OutlinedCrossHandle
color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
onClick={(e) => {
@@ -1026,14 +1087,22 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
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<PolygonEditorProps> = ({
onPointerEnter: (e) => {
e.stopPropagation()
setHoveredMidpoint(index)
setBodyCursor('move')
},
onPointerLeave: (e) => {
e.stopPropagation()
setHoveredMidpoint(null)
clearBodyCursor()
},
}
@@ -1174,7 +1247,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return (
<OutlinedCylinderHandle
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
// Midpoints read as secondary add-points: the brighter hover
// shade at rest distinguishes them from the vertex handles.
color={EDGE_ARROW_HOVER_COLOR}
height={height}
key={`midpoint-${index}`}
{...handleProps}
@@ -23,6 +23,17 @@ const SHADOWS_DISABLED =
.map((s) => 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<Array<THREE.CameraHelper | null>>([])
const hemiRef = useRef<HemisphereLight>(null)
const ambientRef = useRef<AmbientLight>(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()
}
}
}
}