Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions packages/core/src/lib/space-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Load skips room space sync

High Severity

The initSpaceDetectionSync logic, intended to prevent resurrecting deleted auto slabs, incorrectly skips runSpaceDetection for a level's initial appearance. This prevents spaces from being populated on scene load (affecting features like room painting) and leaves rooms without auto slabs/ceilings when walls are pasted or batch-created onto a new or empty level.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c229c93. Configure here.

if (previous !== (currentSnapshots.get(levelId) ?? '')) {
levelsToUpdate.add(levelId)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -120,8 +122,7 @@ export const CeilingSelectionAffordanceSystem = () => {
phase === 'structure' &&
mode === 'select' &&
structureLayer === 'elements' &&
!movingNode &&
!isCurveReshape &&
scopeIdle &&
currentLevelId !== null

if (!shouldRender) return null
Expand Down
22 changes: 19 additions & 3 deletions packages/editor/src/components/systems/ceiling/ceiling-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>()

Expand Down Expand Up @@ -153,6 +160,15 @@ export const CeilingSystem = () => {
}
}
})
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId])
}, [
tool,
selectedItem,
movingNode,
selectedIds,
activeLevelId,
hoveredId,
scopeIdle,
inputDragging,
])
return null
}
99 changes: 87 additions & 12 deletions packages/editor/src/components/tools/shared/polygon-editor.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +13,7 @@ import {
type Line,
type Object3D,
Shape,
Vector3,
} from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -706,7 +759,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onDragCommitRef.current?.()
updatePreviewPolygon(null)
setDragState(null)
}, [onPolygonChange, updatePreviewPolygon])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stuck cursor after drag cancel

Medium Severity

The document.body cursor can remain stuck (e.g., move or resize) if a drag operation is aborted by an external polygon prop update. This occurs because the dragState is nulled without explicitly calling clearBodyCursor in this scenario, unlike on drag commit or component unmount.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c229c93. Configure here.

clearBodyCursor()
}, [clearBodyCursor, onPolygonChange, updatePreviewPolygon])

// Handle adding a new vertex at midpoint
const handleAddVertex = useCallback(
Expand Down Expand Up @@ -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!]
Expand Down Expand Up @@ -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()
},
}

Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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!]
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Midpoint drag clears move cursor

Medium Severity

Midpoint onPointerLeave always clears the body cursor, and midpoints unmount as soon as any dragState exists. Starting a midpoint insert therefore tears down the hovered handle, fires leave, and drops the move cursor for the rest of the gesture.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c229c93. Configure here.

},
}

Expand All @@ -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}
Expand Down
Loading
Loading