feat: sync surface polygon editing
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { CeilingNode } from '@pascal-app/core'
|
||||
import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core'
|
||||
import { resolveCeilingPlanPointSnap } from '@pascal-app/editor'
|
||||
import {
|
||||
createPolygonAddVertexAffordance,
|
||||
createPolygonMoveEdgeAffordance,
|
||||
createPolygonVertexAffordance,
|
||||
type PolygonAffordanceSnapContext,
|
||||
} from '../shared/polygon-vertex-affordance'
|
||||
|
||||
/**
|
||||
@@ -11,6 +13,35 @@ import {
|
||||
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
|
||||
* full contract.
|
||||
*/
|
||||
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>('ceiling')
|
||||
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>('ceiling')
|
||||
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>('ceiling')
|
||||
const ceilingSnapOptions = {
|
||||
resolvePlanPoint({
|
||||
node,
|
||||
nodes,
|
||||
rawPoint,
|
||||
fallbackPoint,
|
||||
modifiers,
|
||||
}: PolygonAffordanceSnapContext<CeilingNode>) {
|
||||
const sceneNodes = nodes as Record<string, AnyNode>
|
||||
return resolveCeilingPlanPointSnap({
|
||||
rawPoint,
|
||||
fallbackPoint,
|
||||
levelId: resolveLevelId(node, sceneNodes),
|
||||
excludeId: node.id,
|
||||
nodes: sceneNodes,
|
||||
altKey: modifiers.altKey,
|
||||
}).point
|
||||
},
|
||||
}
|
||||
|
||||
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>(
|
||||
'ceiling',
|
||||
ceilingSnapOptions,
|
||||
)
|
||||
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>(
|
||||
'ceiling',
|
||||
ceilingSnapOptions,
|
||||
)
|
||||
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>(
|
||||
'ceiling',
|
||||
ceilingSnapOptions,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceModifiers,
|
||||
type FloorplanAffordanceSession,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -41,6 +43,22 @@ export type EdgeDragPayload = {
|
||||
edgeIndex: number
|
||||
}
|
||||
|
||||
type PolygonAffordanceMode = 'move-vertex' | 'add-vertex' | 'move-edge'
|
||||
|
||||
export type PolygonAffordanceSnapContext<N extends PolygonShape & { id: AnyNodeId }> = {
|
||||
node: N
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rawPoint: WallPlanPoint
|
||||
fallbackPoint: WallPlanPoint
|
||||
modifiers: FloorplanAffordanceModifiers
|
||||
holeIndex?: number
|
||||
mode: PolygonAffordanceMode
|
||||
}
|
||||
|
||||
type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
|
||||
resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
|
||||
}
|
||||
|
||||
type PolygonShape = {
|
||||
polygon: ReadonlyArray<readonly [number, number]>
|
||||
holes?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>
|
||||
@@ -76,11 +94,19 @@ function buildRingPatch(
|
||||
return { holes: nextHoles }
|
||||
}
|
||||
|
||||
function resolveAffordancePlanPoint<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
options: PolygonAffordanceOptions<N> | undefined,
|
||||
context: PolygonAffordanceSnapContext<N>,
|
||||
): WallPlanPoint {
|
||||
return options?.resolvePlanPoint?.(context) ?? context.fallbackPoint
|
||||
}
|
||||
|
||||
export function createPolygonVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
kind: string,
|
||||
options?: PolygonAffordanceOptions<N>,
|
||||
): FloorplanAffordance<N> {
|
||||
return {
|
||||
start({ node, payload }): FloorplanAffordanceSession {
|
||||
start({ node, payload, nodes }): FloorplanAffordanceSession {
|
||||
const { vertexIndex, holeIndex } = payload as PolygonVertexPayload
|
||||
const originalRing = getRing(node, holeIndex)
|
||||
if (!originalRing) {
|
||||
@@ -96,9 +122,17 @@ export function createPolygonVertexAffordance<N extends PolygonShape & { id: Any
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
? (planPoint as WallPlanPoint)
|
||||
: snapPointToGrid(planPoint as WallPlanPoint)
|
||||
const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
|
||||
const fallbackPoint = modifiers.shiftKey ? rawPoint : snapPointToGrid(rawPoint)
|
||||
const snapped = resolveAffordancePlanPoint(options, {
|
||||
node,
|
||||
nodes,
|
||||
rawPoint,
|
||||
fallbackPoint,
|
||||
modifiers,
|
||||
holeIndex,
|
||||
mode: 'move-vertex',
|
||||
})
|
||||
const nextRing: [number, number][] = originalRing.map((p, i) =>
|
||||
i === vertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
@@ -128,9 +162,10 @@ export function createPolygonVertexAffordance<N extends PolygonShape & { id: Any
|
||||
*/
|
||||
export function createPolygonAddVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
kind: string,
|
||||
options?: PolygonAffordanceOptions<N>,
|
||||
): FloorplanAffordance<N> {
|
||||
return {
|
||||
start({ node, payload }): FloorplanAffordanceSession {
|
||||
start({ node, payload, nodes }): FloorplanAffordanceSession {
|
||||
const { edgeIndex, holeIndex } = payload as AddVertexPayload
|
||||
const originalRing = getRing(node, holeIndex)
|
||||
if (!originalRing) {
|
||||
@@ -171,9 +206,17 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
? (planPoint as WallPlanPoint)
|
||||
: snapPointToGrid(planPoint as WallPlanPoint)
|
||||
const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
|
||||
const fallbackPoint = modifiers.shiftKey ? rawPoint : snapPointToGrid(rawPoint)
|
||||
const snapped = resolveAffordancePlanPoint(options, {
|
||||
node,
|
||||
nodes,
|
||||
rawPoint,
|
||||
fallbackPoint,
|
||||
modifiers,
|
||||
holeIndex,
|
||||
mode: 'add-vertex',
|
||||
})
|
||||
const nextRing: [number, number][] = initialRing.map((p, i) =>
|
||||
i === newVertexIndex ? [snapped[0], snapped[1]] : p,
|
||||
)
|
||||
@@ -204,9 +247,10 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
|
||||
*/
|
||||
export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: AnyNodeId }>(
|
||||
kind: string,
|
||||
options?: PolygonAffordanceOptions<N>,
|
||||
): FloorplanAffordance<N> {
|
||||
return {
|
||||
start({ node, payload, initialPlanPoint }): FloorplanAffordanceSession {
|
||||
start({ node, payload, initialPlanPoint, nodes }): FloorplanAffordanceSession {
|
||||
const { edgeIndex, holeIndex } = payload as EdgeDragPayload
|
||||
const originalRing = getRing(node, holeIndex)
|
||||
if (!originalRing) {
|
||||
@@ -254,17 +298,33 @@ export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: A
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Project the pointer delta onto the edge normal — that's the
|
||||
// signed perpendicular distance the edge should travel.
|
||||
const deltaX = planPoint[0] - startX
|
||||
const deltaY = planPoint[1] - startY
|
||||
const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
|
||||
const deltaX = rawPoint[0] - startX
|
||||
const deltaY = rawPoint[1] - startY
|
||||
let projection = deltaX * normalX + deltaY * normalY
|
||||
if (!modifiers.shiftKey) {
|
||||
// Snap the projection scalar to a 0.5m grid (legacy uses the
|
||||
// same half-meter snap for slab edges).
|
||||
projection = Math.round(projection * 2) / 2
|
||||
}
|
||||
const fallbackPoint: WallPlanPoint = [
|
||||
startX + normalX * projection,
|
||||
startY + normalY * projection,
|
||||
]
|
||||
const snappedPoint = resolveAffordancePlanPoint(options, {
|
||||
node,
|
||||
nodes,
|
||||
rawPoint,
|
||||
fallbackPoint,
|
||||
modifiers,
|
||||
holeIndex,
|
||||
mode: 'move-edge',
|
||||
})
|
||||
const normalDistance =
|
||||
(snappedPoint[0] - startX) * normalX + (snappedPoint[1] - startY) * normalY
|
||||
const nextRing: [number, number][] = originalRing.map((p, i) => {
|
||||
if (i === edgeStartIndex || i === edgeEndIndex) {
|
||||
return [p[0] + normalX * projection, p[1] + normalY * projection]
|
||||
return [p[0] + normalX * normalDistance, p[1] + normalY * normalDistance]
|
||||
}
|
||||
return [p[0], p[1]] as [number, number]
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user