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 // Demoted auto surfaces keep their polygon untouched, so a re-closed room
// usually hits the exact-signature manual check. Coverage also handles a room // usually hits the exact-signature manual check. Coverage handles the rest:
// deliberately split across multiple manual surfaces: their union suppresses // a room split across multiple manual surfaces AND a single manual surface
// a replacement auto surface as long as the pieces substantially belong to // spanning multiple rooms both suppress a replacement auto surface — what
// and cover the room. // 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[][]) { function matchesManualFootprint(roomPolygon: Point2D[], manualPolygons: Point2D[][]) {
const roomManualPolygons = manualPolygons.filter( return polygonCoverageRatio(roomPolygon, manualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD
(manual) => polygonCoverageRatio(manual, [roomPolygon]) >= ORPHAN_MERGE_COVERAGE_THRESHOLD,
)
return polygonCoverageRatio(roomPolygon, roomManualPolygons) >= ORPHAN_MERGE_COVERAGE_THRESHOLD
} }
function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) { function pointDistanceToPolygonBoundary(point: Point2D, polygon: Point2D[]) {
@@ -1357,7 +1356,11 @@ export function isSpaceDetectionPaused(): boolean {
} }
export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void { 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 let isProcessing = false
const unsubscribe = sceneStore.subscribe((state: any) => { const unsubscribe = sceneStore.subscribe((state: any) => {
@@ -1380,7 +1383,13 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
const levelsToUpdate = new Set<string>() const levelsToUpdate = new Set<string>()
for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { 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) levelsToUpdate.add(levelId)
} }
} }
@@ -20,10 +20,7 @@ import {
} from '../../../lib/ceiling-plan-snap' } from '../../../lib/ceiling-plan-snap'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { isGridSnapActive } from '../../../store/use-editor' import useEditor, { isGridSnapActive } from '../../../store/use-editor'
import useInteractionScope, { import useInteractionScope from '../../../store/use-interaction-scope'
useIsCurveReshape,
useMovingNode,
} from '../../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
const BRACKET_THICKNESS = 0.04 const BRACKET_THICKNESS = 0.04
@@ -99,8 +96,13 @@ export const CeilingSelectionAffordanceSystem = () => {
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const structureLayer = useEditor((state) => state.structureLayer) const structureLayer = useEditor((state) => state.structureLayer)
const movingNode = useMovingNode() // ANY active interaction (moving/placing a node, reshaping a boundary or
const isCurveReshape = useIsCurveReshape() // 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 currentLevelId = useViewer((state) => state.selection.levelId)
const ceilings = useScene( const ceilings = useScene(
@@ -120,8 +122,7 @@ export const CeilingSelectionAffordanceSystem = () => {
phase === 'structure' && phase === 'structure' &&
mode === 'select' && mode === 'select' &&
structureLayer === 'elements' && structureLayer === 'elements' &&
!movingNode && scopeIdle &&
!isCurveReshape &&
currentLevelId !== null currentLevelId !== null
if (!shouldRender) return null if (!shouldRender) return null
@@ -3,7 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { Color, type Material, type Mesh } from 'three' import { Color, type Material, type Mesh } from 'three'
import useEditor from '../../../store/use-editor' 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_HIGHLIGHT_COLOR = '#ffffff'
const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial' const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial'
@@ -80,11 +80,18 @@ export const CeilingSystem = () => {
const selectedIds = useViewer((state) => state.selection.selectedIds) const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeLevelId = useViewer((state) => state.selection.levelId) const activeLevelId = useViewer((state) => state.selection.levelId)
const hoveredId = useViewer((state) => state.hoveredId) 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(() => { useEffect(() => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const hoveredNode = hoveredId ? nodes[hoveredId as AnyNodeId] : null 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>() 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 return null
} }
@@ -1,6 +1,6 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' 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 { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
BoxGeometry, BoxGeometry,
@@ -13,6 +13,7 @@ import {
type Line, type Line,
type Object3D, type Object3D,
Shape, Shape,
Vector3,
} from 'three' } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants' 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. // the `NO_RAYCAST` sentinel in node-arrow-handles.tsx.
const NO_RAYCAST = () => null 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() { function createEdgeArrowGeometry() {
const shape = new Shape() const shape = new Shape()
shape.moveTo(0.22, 0) shape.moveTo(0.22, 0)
@@ -201,7 +215,11 @@ export type PolygonMidpointHandleRenderer = (
props: PolygonMidpointHandleRenderProps, props: PolygonMidpointHandleRenderProps,
) => React.ReactNode ) => React.ReactNode
function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial { function usePolygonNodeMaterial(
color: string,
opacity = 1,
depthTest = true,
): MeshBasicNodeMaterial {
const material = useMemo( const material = useMemo(
() => () =>
new MeshBasicNodeMaterial({ new MeshBasicNodeMaterial({
@@ -217,7 +235,8 @@ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMateri
useEffect(() => { useEffect(() => {
material.color.set(color) material.color.set(color)
material.opacity = opacity material.opacity = opacity
}, [color, material, opacity]) material.depthTest = depthTest
}, [color, depthTest, material, opacity])
useEffect(() => () => material.dispose(), [material]) useEffect(() => () => material.dispose(), [material])
return material return material
@@ -239,7 +258,8 @@ function usePolygonArrowMaterial(): MeshBasicNodeMaterial {
} }
// One mesh per handle: lives on SCENE_LAYER with a node material so the // 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({ function OutlinedCylinderHandle({
radius, radius,
height, height,
@@ -255,7 +275,7 @@ function OutlinedCylinderHandle({
position: [number, number, number] position: [number, number, number]
} & PolygonHandleHandlers) { } & PolygonHandleHandlers) {
const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius]) 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]) useEffect(() => () => geometry.dispose(), [geometry])
return ( return (
@@ -452,6 +472,39 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// When not using portal, edit at world origin // When not using portal, edit at world origin
const editY = levelNode ? Y_OFFSET : 0 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 // Local state for dragging
const [dragState, setDragState] = useState<DragState | null>(null) const [dragState, setDragState] = useState<DragState | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null) const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
@@ -706,7 +759,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onDragCommitRef.current?.() onDragCommitRef.current?.()
updatePreviewPolygon(null) updatePreviewPolygon(null)
setDragState(null) setDragState(null)
}, [onPolygonChange, updatePreviewPolygon]) clearBodyCursor()
}, [clearBodyCursor, onPolygonChange, updatePreviewPolygon])
// Handle adding a new vertex at midpoint // Handle adding a new vertex at midpoint
const handleAddVertex = useCallback( 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) => { {displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index const isHovered = hoveredVertex === index
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
if (dragState && !isDragging) return null
const isLinkedHighlighted = isVertexLinkedHighlighted(index) const isLinkedHighlighted = isVertexLinkedHighlighted(index)
const isHighlighted = isDragging || isHovered || isLinkedHighlighted const isHighlighted = isDragging || isHovered || isLinkedHighlighted
const radius = 0.1 const radius = 0.08
const height = handleHeight const height = handleHeight
const point: [number, number] = [x!, z!] const point: [number, number] = [x!, z!]
const position: [number, number, number] = [x!, editY + height / 2, z!] const position: [number, number, number] = [x!, editY + height / 2, z!]
@@ -969,10 +1026,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerEnter: (e) => { onPointerEnter: (e) => {
e.stopPropagation() e.stopPropagation()
setHoveredVertex(index) setHoveredVertex(index)
setBodyCursor('move')
}, },
onPointerLeave: (e) => { onPointerLeave: (e) => {
e.stopPropagation() e.stopPropagation()
setHoveredVertex(null) 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 <OutlinedCrossHandle
color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR} color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
onClick={(e) => { onClick={(e) => {
@@ -1026,14 +1087,22 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
pointerId: e.pointerId, 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]]} position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
/> />
)} )}
{allowEdgeMove && {allowEdgeMove &&
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => { edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
const isHovered = hoveredEdge === index const isHovered = hoveredEdge === index
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
if (dragState && !isDragging) return null
const isLinkedHighlighted = highlightedEdgeIndices.has(index) const isLinkedHighlighted = highlightedEdgeIndices.has(index)
const isHighlighted = isDragging || isHovered || isLinkedHighlighted const isHighlighted = isDragging || isHovered || isLinkedHighlighted
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
@@ -1100,10 +1169,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerEnter={(e) => { onPointerEnter={(e) => {
e.stopPropagation() e.stopPropagation()
setHoveredEdge(index) setHoveredEdge(index)
setBodyCursor(edgeResizeCursor(midpoint, outwardNormal))
}} }}
onPointerLeave={(e) => { onPointerLeave={(e) => {
e.stopPropagation() e.stopPropagation()
setHoveredEdge(null) setHoveredEdge(null)
if (!dragState?.isDragging) clearBodyCursor()
}} }}
position={[arrowX, edgeHandleY, arrowZ]} position={[arrowX, edgeHandleY, arrowZ]}
rotationY={outwardAngle} rotationY={outwardAngle}
@@ -1120,7 +1191,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const isHovered = hoveredMidpoint === index const isHovered = hoveredMidpoint === index
const isLinkedHighlighted = highlightedEdgeIndices.has(index) const isLinkedHighlighted = highlightedEdgeIndices.has(index)
const isHighlighted = isHovered || isLinkedHighlighted const isHighlighted = isHovered || isLinkedHighlighted
const radius = 0.06 const radius = 0.05
const height = handleHeight const height = handleHeight
const point: [number, number] = [x!, z!] const point: [number, number] = [x!, z!]
const position: [number, number, number] = [x!, editY + height / 2, z!] const position: [number, number, number] = [x!, editY + height / 2, z!]
@@ -1149,10 +1220,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onPointerEnter: (e) => { onPointerEnter: (e) => {
e.stopPropagation() e.stopPropagation()
setHoveredMidpoint(index) setHoveredMidpoint(index)
setBodyCursor('move')
}, },
onPointerLeave: (e) => { onPointerLeave: (e) => {
e.stopPropagation() e.stopPropagation()
setHoveredMidpoint(null) setHoveredMidpoint(null)
clearBodyCursor()
}, },
} }
@@ -1174,7 +1247,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return ( return (
<OutlinedCylinderHandle <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} height={height}
key={`midpoint-${index}`} key={`midpoint-${index}`}
{...handleProps} {...handleProps}
@@ -23,6 +23,17 @@ const SHADOWS_DISABLED =
.map((s) => s.trim()), .map((s) => s.trim()),
).has('shadows') ).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 // Shadow darkness for the bright key lights (themes drive most lights past
// intensity 1). Runs high so shadowed areas actually lose the sun's // intensity 1). Runs high so shadowed areas actually lose the sun's
// contribution — the ambient/hemisphere/IBL stack provides the fill. The old // 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 // `normalBias` is measured in world units. The previous 0.3 moved shadow
// lookups 30 cm off their surfaces, visibly detaching wall shadows at the // 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. // floor; 0.07 and below still acnes on the building-fit 1024 map (large
const SHADOW_DEPTH_BIAS = -0.0001 // texels), so 0.08 is the smallest acne-free value at that resolution — even
const SHADOW_NORMAL_BIAS = 0.02 // 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 // 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 // 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 boundsBox = useRef(new THREE.Box3()) // scratch: union AABB
const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere
const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never) const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never)
const shadowHelpers = useRef<Array<THREE.CameraHelper | null>>([])
const hemiRef = useRef<HemisphereLight>(null) const hemiRef = useRef<HemisphereLight>(null)
const ambientRef = useRef<AmbientLight>(null) const ambientRef = useRef<AmbientLight>(null)
@@ -170,6 +185,15 @@ export function Lights() {
cam.near = near cam.near = near
cam.far = far cam.far = far
cam.updateProjectionMatrix() 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()
}
} }
} }
} }