feat: sync surface polygon editing

This commit is contained in:
Aymeric Rabot
2026-06-09 19:54:35 -04:00
parent 92078741cd
commit e81dc63b28
12 changed files with 523 additions and 311 deletions
+29 -2
View File
@@ -1,7 +1,12 @@
'use client'
import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { PolygonEditor } from '@pascal-app/editor'
import {
clearSlabSnapFeedback,
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
resolveSlabPlanPointSnap,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react'
@@ -30,9 +35,11 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
const setSelection = useViewer((s) => s.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const slabLevelId = slab ? resolveLevelId(slab, useScene.getState().nodes) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
clearSlabSnapFeedback()
updateNode(slabId, { polygon: newPolygon })
setSelection({ selectedIds: [slabId] })
},
@@ -46,6 +53,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
polygon: preview.map(([x, z]) => [x, z] as [number, number]),
})
} else {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId)
}
markDirty(slabId)
@@ -53,11 +61,28 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
[slabId, markDirty],
)
const handleDragCommit = useCallback(() => {
clearSlabSnapFeedback()
}, [])
const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) =>
resolveSlabPlanPointSnap({
rawPoint: context.rawPoint,
fallbackPoint: context.gridPoint,
levelId: slabLevelId,
excludeId: slabId,
altKey: context.nativeEvent?.altKey === true,
}).point,
[slabId, slabLevelId],
)
// Guarantee the override clears if the editor unmounts mid-drag
// (selection change, mode switch) so the slab mesh doesn't get stuck
// on a stale polygon.
useEffect(() => {
return () => {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId)
useScene.getState().markDirty(slabId)
}
@@ -69,11 +94,13 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
<PolygonEditor
allowEdgeMove
color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)}
levelId={slabLevelId ?? undefined}
minVertices={3}
onDragCommit={handleDragCommit}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={slab.polygon}
resolvePlanPoint={resolvePolygonEditorPlanPoint}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
@@ -1,8 +1,10 @@
import type { SlabNode } from '@pascal-app/core'
import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core'
import { resolveSlabPlanPointSnap } from '@pascal-app/editor'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
type PolygonAffordanceSnapContext,
} from '../shared/polygon-vertex-affordance'
/**
@@ -19,6 +21,35 @@ import {
* the slab is selected, every hole's handles appear at the same time.
* Simpler model, no UX downside in practice.
*/
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>('slab')
export const slabAddVertexAffordance = createPolygonAddVertexAffordance<SlabNode>('slab')
export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance<SlabNode>('slab')
const slabSnapOptions = {
resolvePlanPoint({
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
}: PolygonAffordanceSnapContext<SlabNode>) {
const sceneNodes = nodes as Record<string, AnyNode>
return resolveSlabPlanPointSnap({
rawPoint,
fallbackPoint,
levelId: resolveLevelId(node, sceneNodes),
excludeId: node.id,
nodes: sceneNodes,
altKey: modifiers.altKey,
}).point
},
}
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>(
'slab',
slabSnapOptions,
)
export const slabAddVertexAffordance = createPolygonAddVertexAffordance<SlabNode>(
'slab',
slabSnapOptions,
)
export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance<SlabNode>(
'slab',
slabSnapOptions,
)
+13 -57
View File
@@ -1,19 +1,13 @@
'use client'
import {
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useScene,
} from '@pascal-app/core'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
clearSlabSnapFeedback,
EDITOR_LAYER,
markToolCancelConsumed,
resolveSlabPlanPointSnap,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -34,8 +28,6 @@ import { SlabNode } from './schema'
*/
const Y_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function calculateSnapPoint(
lastPoint: [number, number],
@@ -90,52 +82,11 @@ export const SlabTool: React.FC = () => {
// isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), [])
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
// on every cursor move (cursorPosition is in its deps), so clearing guides
// in its cleanup would wipe the guide the instant after each move sets it.
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
useEffect(() => () => clearSlabSnapFeedback(), [])
useEffect(() => {
if (!currentLevelId) return
// Alignment candidates — anchors of every OTHER alignable object. The
// slab's own in-progress vertices are intentionally excluded (no
// self-alignment while drawing).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the drafted vertex onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
// bypasses.
const alignPoint = (
fallback: [number, number],
raw: [number, number],
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
const ar = resolveAlignment({
moving: [{ nodeId: '__slab-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
useAlignmentGuides.getState().set(ar.guides)
let [x, z] = fallback
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
@@ -149,7 +100,12 @@ export const SlabTool: React.FC = () => {
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
const displayPoint = resolveSlabPlanPointSnap({
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true,
}).point
setSnappedCursorPosition(displayPoint)
if (
points.length > 0 &&
@@ -176,7 +132,7 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
useAlignmentGuides.getState().clear()
clearSlabSnapFeedback()
} else {
// Every non-closing vertex is a "start" tick; the closing click above
// fires the structure-build (end) cue.
@@ -191,14 +147,14 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
useAlignmentGuides.getState().clear()
clearSlabSnapFeedback()
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
useAlignmentGuides.getState().clear()
clearSlabSnapFeedback()
}
const onKeyDown = (e: KeyboardEvent) => {