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:
co-authored by
Claude Fable 5
parent
9ca3eaa7fe
commit
22c9472066
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-8
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user