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
@@ -19,7 +19,6 @@ import {
useLiveTransforms, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { import {
memo, memo,
@@ -31,6 +30,7 @@ import {
useState, useState,
} from 'react' } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { useFloorplanRender } from '../floorplan-render-context' import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
@@ -603,6 +603,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
} }
drag.session.commit() drag.session.commit()
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
clearSurfacePlanSnapFeedback()
dragRef.current = null dragRef.current = null
setActiveDragId(null) setActiveDragId(null)
setRotationOverlay(null) setRotationOverlay(null)
@@ -654,6 +655,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
for (const id of drag.session.affectedIds) overrides.clear(id) for (const id of drag.session.affectedIds) overrides.clear(id)
} }
clearSurfacePlanSnapFeedback()
dragRef.current = null dragRef.current = null
setActiveDragId(null) setActiveDragId(null)
setRotationOverlay(null) setRotationOverlay(null)
@@ -672,7 +674,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// Affordances that publish Figma alignment guides during `apply` // Affordances that publish Figma alignment guides during `apply`
// (fence endpoint) leave them in the store on cancel — `canCommit` // (fence endpoint) leave them in the store on cancel — `canCommit`
// (the pointer-up clear) never runs on a cancel. // (the pointer-up clear) never runs on a cancel.
useAlignmentGuides.getState().clear() clearSurfacePlanSnapFeedback()
// Drop any live overrides the session may have published. No-op // Drop any live overrides the session may have published. No-op
// for affordances whose `apply()` writes straight to scene; the // for affordances whose `apply()` writes straight to scene; the
// override-routed sessions (wall endpoint, wall curve) rely on // override-routed sessions (wall endpoint, wall curve) rely on
@@ -707,7 +709,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
dragRef.current = null dragRef.current = null
} }
// Clear any alignment guide a session left behind on mid-drag unmount. // Clear any alignment guide a session left behind on mid-drag unmount.
useAlignmentGuides.getState().clear() clearSurfacePlanSnapFeedback()
} }
}, []) }, [])
@@ -77,6 +77,7 @@ import {
import { guideEmitter } from '../../lib/guide-events' import { guideEmitter } from '../../lib/guide-events'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
@@ -8507,13 +8508,25 @@ export function FloorplanPanel() {
// moves (the catch-all would otherwise swallow the move event). // moves (the catch-all would otherwise swallow the move event).
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
}) })
if (angleSnap) useAlignmentGuides.getState().clear() let snappedPoint = fallbackPoint
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) if (isSlabBuildActive) {
snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
} else if (angleSnap) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey })
}
// Emit `grid:move` so the registry-driven slab tool also tracks // Emit `grid:move` so the registry-driven slab tool also tracks
// the cursor (its 3D preview needs it). // the cursor (its 3D preview needs it).
@@ -8681,6 +8694,7 @@ export function FloorplanPanel() {
isOpeningPlacementActive, isOpeningPlacementActive,
isPolygonBuildActive, isPolygonBuildActive,
isRoofBuildActive, isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive, isWallBuildActive,
levelId, levelId,
publishFloorplanNavigationPose, publishFloorplanNavigationPose,
@@ -8939,6 +8953,7 @@ export function FloorplanPanel() {
isOpeningPlacementActive, isOpeningPlacementActive,
isPolygonBuildActive, isPolygonBuildActive,
isRoofBuildActive, isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive, isWallBuildActive,
isZoneBuildActive, isZoneBuildActive,
levelId, levelId,
@@ -9141,10 +9156,17 @@ export function FloorplanPanel() {
if (isZoneBuildActive) { if (isZoneBuildActive) {
handleZonePlacementConfirm(fallbackPoint) handleZonePlacementConfirm(fallbackPoint)
} else { } else {
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
// Slab is registry-driven: forward the double-click so the 3D tool // Slab is registry-driven: forward the double-click so the 3D tool
// commits the node (zone has no registry tool, so it commits locally). // commits the node (zone has no registry tool, so it commits locally).
emitFloorplanGridEvent('double-click', planPoint, event) emitFloorplanGridEvent('double-click', snappedPoint, event)
handleSlabPlacementConfirm(fallbackPoint) handleSlabPlacementConfirm(snappedPoint)
} }
}, },
[ [
@@ -4,6 +4,7 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-ap
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react' import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { import {
WALL_FINE_GRID_STEP, WALL_FINE_GRID_STEP,
@@ -50,6 +51,7 @@ type UseFloorplanBackgroundPlacementArgs = {
isOpeningPlacementActive: boolean isOpeningPlacementActive: boolean
isPolygonBuildActive: boolean isPolygonBuildActive: boolean
isRoofBuildActive: boolean isRoofBuildActive: boolean
isSlabBuildActive: boolean
isWallBuildActive: boolean isWallBuildActive: boolean
isZoneBuildActive: boolean isZoneBuildActive: boolean
levelId: string | null levelId: string | null
@@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({
isOpeningPlacementActive, isOpeningPlacementActive,
isPolygonBuildActive, isPolygonBuildActive,
isRoofBuildActive, isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive, isWallBuildActive,
isZoneBuildActive, isZoneBuildActive,
levelId, levelId,
@@ -233,13 +236,22 @@ export function useFloorplanBackgroundPlacement({
// the 2D draft polygon invisible while the 3D tool builds fine). // the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) { if (isPolygonBuildActive) {
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({ const fallbackPoint = snapPolygonDraftPoint({
point: planPoint, point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
}) })
if (!angleSnap) { let snappedPoint = fallbackPoint
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey }) if (isSlabBuildActive) {
snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
} else if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey })
} }
// Emit the grid event so the registry-driven slab tool also // Emit the grid event so the registry-driven slab tool also
@@ -328,6 +340,7 @@ export function useFloorplanBackgroundPlacement({
isOpeningPlacementActive, isOpeningPlacementActive,
isPolygonBuildActive, isPolygonBuildActive,
isRoofBuildActive, isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive, isWallBuildActive,
isZoneBuildActive, isZoneBuildActive,
levelId, levelId,
+14
View File
@@ -234,6 +234,13 @@ export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-dup
export type { SceneGraph } from './lib/scene' export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus' export { triggerSFX } from './lib/sfx-bus'
export {
clearSlabSnapFeedback,
resolveSlabPlanPointSnap,
SLAB_ALIGNMENT_THRESHOLD_M,
type SlabPlanSnapInput,
type SlabPlanSnapResult,
} from './lib/slab-plan-snap'
export { duplicateStairSubtree } from './lib/stair-duplication' export { duplicateStairSubtree } from './lib/stair-duplication'
export { export {
getBuildingLevelsForLevel, getBuildingLevelsForLevel,
@@ -243,6 +250,13 @@ export {
resolveStairPlacementLevelId, resolveStairPlacementLevelId,
resolveStairToLevelId, resolveStairToLevelId,
} from './lib/stair-levels' } from './lib/stair-levels'
export {
clearSurfacePlanSnapFeedback,
resolveSurfacePlanPointSnap,
SURFACE_ALIGNMENT_THRESHOLD_M,
type SurfacePlanSnapInput,
type SurfacePlanSnapResult,
} from './lib/surface-plan-snap'
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/ // `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
// nodes` so they don't need their own copy / their own tailwind-merge // nodes` so they don't need their own copy / their own tailwind-merge
// dependency. // dependency.
+13 -221
View File
@@ -1,232 +1,24 @@
import { import {
type AlignmentAnchor, clearSurfacePlanSnapFeedback,
type AlignmentGuide, resolveSurfacePlanPointSnap,
type AnyNode, SURFACE_ALIGNMENT_THRESHOLD_M,
collectAlignmentAnchors, type SurfacePlanSnapInput,
getWallCurveFrameAt, type SurfacePlanSnapResult,
getWallCurveLength, } from './surface-plan-snap'
isCurvedWall,
resolveAlignment,
resolveLevelId,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
getSegmentGridStep,
snapWallDraftPointDetailed,
type WallDraftSnapKind,
type WallPlanPoint,
type WallSnapRadii,
} from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides'
import useEditor from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
const CEILING_SNAP_MOVING_ID = '__ceiling_snap__' const CEILING_SNAP_MOVING_ID = '__ceiling_snap__'
export const CEILING_ALIGNMENT_THRESHOLD_M = 0.08
const CEILING_WALL_SNAP_RADII = {
endpoint: 0.38,
midpoint: 0.28,
intersection: 0.28,
wall: 0.18,
} satisfies WallSnapRadii
const WALL_SOURCE_MATCH_EPSILON = 0.035
export type CeilingPlanSnapInput = { export const CEILING_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M
rawPoint: WallPlanPoint export type CeilingPlanSnapInput = SurfacePlanSnapInput
fallbackPoint?: WallPlanPoint export type CeilingPlanSnapResult = SurfacePlanSnapResult
levelId?: string | null
excludeId?: string | null
movingId?: string
nodes?: Readonly<Record<string, AnyNode>>
walls?: readonly WallNode[]
candidates?: readonly AlignmentAnchor[]
threshold?: number
altKey?: boolean
magnetic?: boolean
align?: boolean
step?: number
snapRadii?: WallSnapRadii
}
export type CeilingPlanSnapResult = {
point: WallPlanPoint
wallSnap: WallDraftSnapKind | null
guides: AlignmentGuide[]
wallIds: string[]
}
function getLevelWalls(
nodes: Readonly<Record<string, AnyNode>>,
levelId: string | null | undefined,
walls?: readonly WallNode[],
): WallNode[] {
const source =
walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall')
if (!levelId) return source.filter((wall) => wall.visible !== false)
return source.filter(
(wall) =>
wall.visible !== false && resolveLevelId(wall, nodes as Record<string, AnyNode>) === levelId,
)
}
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function wallMidpoint(wall: WallNode): WallPlanPoint {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, 0.5)
return [frame.point.x, frame.point.y]
}
return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
}
function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-9) return distanceSquared(point, start)
const t = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
)
const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t]
return distanceSquared(point, projected)
}
function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) {
if (!isCurvedWall(wall)) {
return distanceToSegmentSquared(point, wall.start, wall.end)
}
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
let bestDistanceSquared = Number.POSITIVE_INFINITY
let previous = getWallCurveFrameAt(wall, 0).point
for (let index = 1; index <= sampleCount; index += 1) {
const current = getWallCurveFrameAt(wall, index / sampleCount).point
const distance = distanceToSegmentSquared(
point,
[previous.x, previous.y],
[current.x, current.y],
)
bestDistanceSquared = Math.min(bestDistanceSquared, distance)
previous = current
}
return bestDistanceSquared
}
function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) {
return walls
.map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) }))
.sort((a, b) => a.distance - b.distance)
.slice(0, count)
.map(({ id }) => id)
}
function findSnapSourceWallIds(
point: WallPlanPoint,
kind: WallDraftSnapKind,
walls: readonly WallNode[],
): string[] {
const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2
if (kind === 'endpoint') {
const endpointMatches = walls.filter(
(wall) =>
distanceSquared(point, wall.start) <= epsilonSquared ||
distanceSquared(point, wall.end) <= epsilonSquared,
)
if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'midpoint') {
const midpointMatches = walls.filter(
(wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared,
)
if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'intersection') {
const crossingMatches = walls.filter(
(wall) => distanceToWallSquared(point, wall) <= epsilonSquared,
)
if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2)
return closestWallIds(point, walls, 2)
}
return closestWallIds(point, walls, 1)
}
export function clearCeilingSnapFeedback() { export function clearCeilingSnapFeedback() {
useAlignmentGuides.getState().clear() clearSurfacePlanSnapFeedback()
useWallSnapIndicator.getState().clear()
} }
export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult { export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult {
const nodes = input.nodes ?? useScene.getState().nodes return resolveSurfacePlanPointSnap({
const walls = getLevelWalls(nodes, input.levelId, input.walls) ...input,
const fallbackPoint = input.fallbackPoint movingId: input.movingId ?? CEILING_SNAP_MOVING_ID,
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint,
walls,
step: input.step ?? getSegmentGridStep(),
magnetic,
snapRadii: input.snapRadii ?? CEILING_WALL_SNAP_RADII,
gridSnap: fallbackPoint ? () => fallbackPoint : undefined,
}) })
if (wallSnap.snap) {
const wallIds = findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls)
useWallSnapIndicator
.getState()
.set({ x: wallSnap.point[0], z: wallSnap.point[1], kind: wallSnap.snap, wallIds })
useAlignmentGuides.getState().clear()
return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds }
}
useWallSnapIndicator.getState().clear()
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const movingId = input.movingId ?? CEILING_SNAP_MOVING_ID
const candidates =
input.candidates ??
collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
if (candidates.length === 0) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const alignment = resolveAlignment({
moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
candidates,
threshold: input.threshold ?? CEILING_ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(alignment.guides)
if (!alignment.snap) {
return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
}
return {
point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz],
wallSnap: null,
guides: alignment.guides,
wallIds: [],
}
} }
+25
View File
@@ -0,0 +1,25 @@
import {
clearSurfacePlanSnapFeedback,
resolveSurfacePlanPointSnap,
SURFACE_ALIGNMENT_THRESHOLD_M,
type SurfacePlanSnapInput,
type SurfacePlanSnapResult,
} from './surface-plan-snap'
const SLAB_SNAP_MOVING_ID = '__slab_snap__'
export const SLAB_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M
export type SlabPlanSnapInput = SurfacePlanSnapInput
export type SlabPlanSnapResult = SurfacePlanSnapResult
export function clearSlabSnapFeedback() {
clearSurfacePlanSnapFeedback()
}
export function resolveSlabPlanPointSnap(input: SlabPlanSnapInput): SlabPlanSnapResult {
return resolveSurfacePlanPointSnap({
...input,
highlightWalls: input.highlightWalls ?? false,
movingId: input.movingId ?? SLAB_SNAP_MOVING_ID,
})
}
@@ -0,0 +1,239 @@
import {
type AlignmentAnchor,
type AlignmentGuide,
type AnyNode,
collectAlignmentAnchors,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
resolveAlignment,
resolveLevelId,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
getSegmentGridStep,
snapWallDraftPointDetailed,
type WallDraftSnapKind,
type WallPlanPoint,
type WallSnapRadii,
} from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides'
import useEditor from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08
const SURFACE_WALL_SNAP_RADII = {
endpoint: 0.38,
midpoint: 0.28,
intersection: 0.28,
wall: 0.18,
} satisfies WallSnapRadii
const WALL_SOURCE_MATCH_EPSILON = 0.035
export type SurfacePlanSnapInput = {
rawPoint: WallPlanPoint
fallbackPoint?: WallPlanPoint
levelId?: string | null
excludeId?: string | null
movingId?: string
nodes?: Readonly<Record<string, AnyNode>>
walls?: readonly WallNode[]
candidates?: readonly AlignmentAnchor[]
threshold?: number
altKey?: boolean
magnetic?: boolean
align?: boolean
highlightWalls?: boolean
step?: number
snapRadii?: WallSnapRadii
}
export type SurfacePlanSnapResult = {
point: WallPlanPoint
wallSnap: WallDraftSnapKind | null
guides: AlignmentGuide[]
wallIds: string[]
}
function getLevelWalls(
nodes: Readonly<Record<string, AnyNode>>,
levelId: string | null | undefined,
walls?: readonly WallNode[],
): WallNode[] {
const source =
walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall')
if (!levelId) return source.filter((wall) => wall.visible !== false)
return source.filter(
(wall) =>
wall.visible !== false && resolveLevelId(wall, nodes as Record<string, AnyNode>) === levelId,
)
}
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function wallMidpoint(wall: WallNode): WallPlanPoint {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, 0.5)
return [frame.point.x, frame.point.y]
}
return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
}
function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-9) return distanceSquared(point, start)
const t = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
)
const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t]
return distanceSquared(point, projected)
}
function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) {
if (!isCurvedWall(wall)) {
return distanceToSegmentSquared(point, wall.start, wall.end)
}
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
let bestDistanceSquared = Number.POSITIVE_INFINITY
let previous = getWallCurveFrameAt(wall, 0).point
for (let index = 1; index <= sampleCount; index += 1) {
const current = getWallCurveFrameAt(wall, index / sampleCount).point
const distance = distanceToSegmentSquared(
point,
[previous.x, previous.y],
[current.x, current.y],
)
bestDistanceSquared = Math.min(bestDistanceSquared, distance)
previous = current
}
return bestDistanceSquared
}
function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) {
return walls
.map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) }))
.sort((a, b) => a.distance - b.distance)
.slice(0, count)
.map(({ id }) => id)
}
function findSnapSourceWallIds(
point: WallPlanPoint,
kind: WallDraftSnapKind,
walls: readonly WallNode[],
): string[] {
const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2
if (kind === 'endpoint') {
const endpointMatches = walls.filter(
(wall) =>
distanceSquared(point, wall.start) <= epsilonSquared ||
distanceSquared(point, wall.end) <= epsilonSquared,
)
if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'midpoint') {
const midpointMatches = walls.filter(
(wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared,
)
if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'intersection') {
const crossingMatches = walls.filter(
(wall) => distanceToWallSquared(point, wall) <= epsilonSquared,
)
if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2)
return closestWallIds(point, walls, 2)
}
return closestWallIds(point, walls, 1)
}
export function clearSurfacePlanSnapFeedback() {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
}
export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult {
const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint,
walls,
step: input.step ?? getSegmentGridStep(),
magnetic,
snapRadii: input.snapRadii ?? SURFACE_WALL_SNAP_RADII,
gridSnap: fallbackPoint ? () => fallbackPoint : undefined,
})
if (wallSnap.snap) {
const wallIds =
input.highlightWalls === false
? []
: findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls)
useWallSnapIndicator.getState().set({
x: wallSnap.point[0],
z: wallSnap.point[1],
kind: wallSnap.snap,
...(wallIds.length > 0 ? { wallIds } : {}),
})
useAlignmentGuides.getState().clear()
return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds }
}
useWallSnapIndicator.getState().clear()
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID
const candidates =
input.candidates ??
collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
if (candidates.length === 0) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const alignment = resolveAlignment({
moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
candidates,
threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(alignment.guides)
if (!alignment.snap) {
return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
}
return {
point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz],
wallSnap: null,
guides: alignment.guides,
wallIds: [],
}
}
@@ -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 { import {
createPolygonAddVertexAffordance, createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance, createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance, createPolygonVertexAffordance,
type PolygonAffordanceSnapContext,
} from '../shared/polygon-vertex-affordance' } from '../shared/polygon-vertex-affordance'
/** /**
@@ -11,6 +13,35 @@ import {
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the * optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
* full contract. * full contract.
*/ */
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>('ceiling') const ceilingSnapOptions = {
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>('ceiling') resolvePlanPoint({
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>('ceiling') 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 { import {
type AnyNode,
type AnyNodeId, type AnyNodeId,
type FloorplanAffordance, type FloorplanAffordance,
type FloorplanAffordanceModifiers,
type FloorplanAffordanceSession, type FloorplanAffordanceSession,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -41,6 +43,22 @@ export type EdgeDragPayload = {
edgeIndex: number 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 = { type PolygonShape = {
polygon: ReadonlyArray<readonly [number, number]> polygon: ReadonlyArray<readonly [number, number]>
holes?: ReadonlyArray<ReadonlyArray<readonly [number, number]>> holes?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>
@@ -76,11 +94,19 @@ function buildRingPatch(
return { holes: nextHoles } 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 }>( export function createPolygonVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string, kind: string,
options?: PolygonAffordanceOptions<N>,
): FloorplanAffordance<N> { ): FloorplanAffordance<N> {
return { return {
start({ node, payload }): FloorplanAffordanceSession { start({ node, payload, nodes }): FloorplanAffordanceSession {
const { vertexIndex, holeIndex } = payload as PolygonVertexPayload const { vertexIndex, holeIndex } = payload as PolygonVertexPayload
const originalRing = getRing(node, holeIndex) const originalRing = getRing(node, holeIndex)
if (!originalRing) { if (!originalRing) {
@@ -96,9 +122,17 @@ export function createPolygonVertexAffordance<N extends PolygonShape & { id: Any
return { return {
affectedIds: [node.id], affectedIds: [node.id],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
? (planPoint as WallPlanPoint) const fallbackPoint = modifiers.shiftKey ? rawPoint : snapPointToGrid(rawPoint)
: snapPointToGrid(planPoint as WallPlanPoint) const snapped = resolveAffordancePlanPoint(options, {
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
holeIndex,
mode: 'move-vertex',
})
const nextRing: [number, number][] = originalRing.map((p, i) => const nextRing: [number, number][] = originalRing.map((p, i) =>
i === vertexIndex ? [snapped[0], snapped[1]] : p, 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 }>( export function createPolygonAddVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string, kind: string,
options?: PolygonAffordanceOptions<N>,
): FloorplanAffordance<N> { ): FloorplanAffordance<N> {
return { return {
start({ node, payload }): FloorplanAffordanceSession { start({ node, payload, nodes }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as AddVertexPayload const { edgeIndex, holeIndex } = payload as AddVertexPayload
const originalRing = getRing(node, holeIndex) const originalRing = getRing(node, holeIndex)
if (!originalRing) { if (!originalRing) {
@@ -171,9 +206,17 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
return { return {
affectedIds: [node.id], affectedIds: [node.id],
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
? (planPoint as WallPlanPoint) const fallbackPoint = modifiers.shiftKey ? rawPoint : snapPointToGrid(rawPoint)
: snapPointToGrid(planPoint as WallPlanPoint) const snapped = resolveAffordancePlanPoint(options, {
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
holeIndex,
mode: 'add-vertex',
})
const nextRing: [number, number][] = initialRing.map((p, i) => const nextRing: [number, number][] = initialRing.map((p, i) =>
i === newVertexIndex ? [snapped[0], snapped[1]] : p, 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 }>( export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string, kind: string,
options?: PolygonAffordanceOptions<N>,
): FloorplanAffordance<N> { ): FloorplanAffordance<N> {
return { return {
start({ node, payload, initialPlanPoint }): FloorplanAffordanceSession { start({ node, payload, initialPlanPoint, nodes }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as EdgeDragPayload const { edgeIndex, holeIndex } = payload as EdgeDragPayload
const originalRing = getRing(node, holeIndex) const originalRing = getRing(node, holeIndex)
if (!originalRing) { if (!originalRing) {
@@ -254,17 +298,33 @@ export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: A
apply({ planPoint, modifiers }) { apply({ planPoint, modifiers }) {
// Project the pointer delta onto the edge normal — that's the // Project the pointer delta onto the edge normal — that's the
// signed perpendicular distance the edge should travel. // signed perpendicular distance the edge should travel.
const deltaX = planPoint[0] - startX const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
const deltaY = planPoint[1] - startY const deltaX = rawPoint[0] - startX
const deltaY = rawPoint[1] - startY
let projection = deltaX * normalX + deltaY * normalY let projection = deltaX * normalX + deltaY * normalY
if (!modifiers.shiftKey) { if (!modifiers.shiftKey) {
// Snap the projection scalar to a 0.5m grid (legacy uses the // Snap the projection scalar to a 0.5m grid (legacy uses the
// same half-meter snap for slab edges). // same half-meter snap for slab edges).
projection = Math.round(projection * 2) / 2 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) => { const nextRing: [number, number][] = originalRing.map((p, i) => {
if (i === edgeStartIndex || i === edgeEndIndex) { 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] return [p[0], p[1]] as [number, number]
}) })
+29 -2
View File
@@ -1,7 +1,12 @@
'use client' 'use client'
import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' 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 { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react' 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 setSelection = useViewer((s) => s.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const slabLevelId = slab ? resolveLevelId(slab, useScene.getState().nodes) : null
const handlePolygonChange = useCallback( const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => { (newPolygon: Array<[number, number]>) => {
clearSlabSnapFeedback()
updateNode(slabId, { polygon: newPolygon }) updateNode(slabId, { polygon: newPolygon })
setSelection({ selectedIds: [slabId] }) 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]), polygon: preview.map(([x, z]) => [x, z] as [number, number]),
}) })
} else { } else {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId) useLiveNodeOverrides.getState().clear(slabId)
} }
markDirty(slabId) markDirty(slabId)
@@ -53,11 +61,28 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
[slabId, markDirty], [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 // Guarantee the override clears if the editor unmounts mid-drag
// (selection change, mode switch) so the slab mesh doesn't get stuck // (selection change, mode switch) so the slab mesh doesn't get stuck
// on a stale polygon. // on a stale polygon.
useEffect(() => { useEffect(() => {
return () => { return () => {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId) useLiveNodeOverrides.getState().clear(slabId)
useScene.getState().markDirty(slabId) useScene.getState().markDirty(slabId)
} }
@@ -69,11 +94,13 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
<PolygonEditor <PolygonEditor
allowEdgeMove allowEdgeMove
color="#a3a3a3" color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)} levelId={slabLevelId ?? undefined}
minVertices={3} minVertices={3}
onDragCommit={handleDragCommit}
onPolygonChange={handlePolygonChange} onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview} onPolygonPreview={handlePolygonPreview}
polygon={slab.polygon} polygon={slab.polygon}
resolvePlanPoint={resolvePolygonEditorPlanPoint}
surfaceHeight={slab.elevation ?? 0.05} 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 { import {
createPolygonAddVertexAffordance, createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance, createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance, createPolygonVertexAffordance,
type PolygonAffordanceSnapContext,
} from '../shared/polygon-vertex-affordance' } from '../shared/polygon-vertex-affordance'
/** /**
@@ -19,6 +21,35 @@ import {
* the slab is selected, every hole's handles appear at the same time. * the slab is selected, every hole's handles appear at the same time.
* Simpler model, no UX downside in practice. * Simpler model, no UX downside in practice.
*/ */
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>('slab') const slabSnapOptions = {
export const slabAddVertexAffordance = createPolygonAddVertexAffordance<SlabNode>('slab') resolvePlanPoint({
export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance<SlabNode>('slab') 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' 'use client'
import { import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useScene,
} from '@pascal-app/core'
import { import {
CursorSphere, CursorSphere,
clearSlabSnapFeedback,
EDITOR_LAYER, EDITOR_LAYER,
markToolCancelConsumed, markToolCancelConsumed,
resolveSlabPlanPointSnap,
triggerSFX, triggerSFX,
useAlignmentGuides,
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -34,8 +28,6 @@ import { SlabNode } from './schema'
*/ */
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function calculateSnapPoint( function calculateSnapPoint(
lastPoint: [number, number], lastPoint: [number, number],
@@ -90,52 +82,11 @@ export const SlabTool: React.FC = () => {
// isn't built with a stale preset's parameters. Unmount-only. // isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), []) useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), [])
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs useEffect(() => () => clearSlabSnapFeedback(), [])
// 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(() => { useEffect(() => {
if (!currentLevelId) return 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) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return if (!cursorRef.current) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
@@ -149,7 +100,12 @@ export const SlabTool: React.FC = () => {
shiftPressed.current || !lastPoint shiftPressed.current || !lastPoint
? gridPosition ? gridPosition
: calculateSnapPoint(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) setSnappedCursorPosition(displayPoint)
if ( if (
points.length > 0 && points.length > 0 &&
@@ -176,7 +132,7 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points) const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] }) setSelection({ selectedIds: [slabId] })
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() clearSlabSnapFeedback()
} else { } else {
// Every non-closing vertex is a "start" tick; the closing click above // Every non-closing vertex is a "start" tick; the closing click above
// fires the structure-build (end) cue. // fires the structure-build (end) cue.
@@ -191,14 +147,14 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points) const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] }) setSelection({ selectedIds: [slabId] })
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() clearSlabSnapFeedback()
} }
} }
const onCancel = () => { const onCancel = () => {
if (points.length > 0) markToolCancelConsumed() if (points.length > 0) markToolCancelConsumed()
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() clearSlabSnapFeedback()
} }
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {