feat: sync surface polygon editing
This commit is contained in:
@@ -19,7 +19,6 @@ import {
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAlignmentGuides } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
memo,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { useFloorplanRender } from '../floorplan-render-context'
|
||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||
@@ -603,6 +603,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
}
|
||||
drag.session.commit()
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
clearSurfacePlanSnapFeedback()
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
setRotationOverlay(null)
|
||||
@@ -654,6 +655,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
for (const id of drag.session.affectedIds) overrides.clear(id)
|
||||
}
|
||||
|
||||
clearSurfacePlanSnapFeedback()
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
setRotationOverlay(null)
|
||||
@@ -672,7 +674,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// Affordances that publish Figma alignment guides during `apply`
|
||||
// (fence endpoint) leave them in the store on cancel — `canCommit`
|
||||
// (the pointer-up clear) never runs on a cancel.
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearSurfacePlanSnapFeedback()
|
||||
// Drop any live overrides the session may have published. No-op
|
||||
// for affordances whose `apply()` writes straight to scene; the
|
||||
// override-routed sessions (wall endpoint, wall curve) rely on
|
||||
@@ -707,7 +709,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
dragRef.current = null
|
||||
}
|
||||
// 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 { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||
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).
|
||||
if (isPolygonBuildActive) {
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
|
||||
let snappedPoint = snapPolygonDraftPoint({
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
})
|
||||
if (angleSnap) useAlignmentGuides.getState().clear()
|
||||
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
|
||||
let snappedPoint = fallbackPoint
|
||||
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
|
||||
// the cursor (its 3D preview needs it).
|
||||
@@ -8681,6 +8694,7 @@ export function FloorplanPanel() {
|
||||
isOpeningPlacementActive,
|
||||
isPolygonBuildActive,
|
||||
isRoofBuildActive,
|
||||
isSlabBuildActive,
|
||||
isWallBuildActive,
|
||||
levelId,
|
||||
publishFloorplanNavigationPose,
|
||||
@@ -8939,6 +8953,7 @@ export function FloorplanPanel() {
|
||||
isOpeningPlacementActive,
|
||||
isPolygonBuildActive,
|
||||
isRoofBuildActive,
|
||||
isSlabBuildActive,
|
||||
isWallBuildActive,
|
||||
isZoneBuildActive,
|
||||
levelId,
|
||||
@@ -9141,10 +9156,17 @@ export function FloorplanPanel() {
|
||||
if (isZoneBuildActive) {
|
||||
handleZonePlacementConfirm(fallbackPoint)
|
||||
} 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
|
||||
// commits the node (zone has no registry tool, so it commits locally).
|
||||
emitFloorplanGridEvent('double-click', planPoint, event)
|
||||
handleSlabPlacementConfirm(fallbackPoint)
|
||||
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
||||
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 { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import {
|
||||
WALL_FINE_GRID_STEP,
|
||||
@@ -50,6 +51,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
isOpeningPlacementActive: boolean
|
||||
isPolygonBuildActive: boolean
|
||||
isRoofBuildActive: boolean
|
||||
isSlabBuildActive: boolean
|
||||
isWallBuildActive: boolean
|
||||
isZoneBuildActive: boolean
|
||||
levelId: string | null
|
||||
@@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
isOpeningPlacementActive,
|
||||
isPolygonBuildActive,
|
||||
isRoofBuildActive,
|
||||
isSlabBuildActive,
|
||||
isWallBuildActive,
|
||||
isZoneBuildActive,
|
||||
levelId,
|
||||
@@ -233,13 +236,22 @@ export function useFloorplanBackgroundPlacement({
|
||||
// the 2D draft polygon invisible while the 3D tool builds fine).
|
||||
if (isPolygonBuildActive) {
|
||||
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
|
||||
let snappedPoint = snapPolygonDraftPoint({
|
||||
const fallbackPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||
angleSnap,
|
||||
})
|
||||
if (!angleSnap) {
|
||||
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
|
||||
let snappedPoint = fallbackPoint
|
||||
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
|
||||
@@ -328,6 +340,7 @@ export function useFloorplanBackgroundPlacement({
|
||||
isOpeningPlacementActive,
|
||||
isPolygonBuildActive,
|
||||
isRoofBuildActive,
|
||||
isSlabBuildActive,
|
||||
isWallBuildActive,
|
||||
isZoneBuildActive,
|
||||
levelId,
|
||||
|
||||
@@ -234,6 +234,13 @@ export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-dup
|
||||
export type { SceneGraph } from './lib/scene'
|
||||
export { applySceneGraphToEditor } from './lib/scene'
|
||||
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 {
|
||||
getBuildingLevelsForLevel,
|
||||
@@ -243,6 +250,13 @@ export {
|
||||
resolveStairPlacementLevelId,
|
||||
resolveStairToLevelId,
|
||||
} 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/
|
||||
// nodes` so they don't need their own copy / their own tailwind-merge
|
||||
// dependency.
|
||||
|
||||
@@ -1,232 +1,24 @@
|
||||
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'
|
||||
clearSurfacePlanSnapFeedback,
|
||||
resolveSurfacePlanPointSnap,
|
||||
SURFACE_ALIGNMENT_THRESHOLD_M,
|
||||
type SurfacePlanSnapInput,
|
||||
type SurfacePlanSnapResult,
|
||||
} from './surface-plan-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 = {
|
||||
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
|
||||
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 const CEILING_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M
|
||||
export type CeilingPlanSnapInput = SurfacePlanSnapInput
|
||||
export type CeilingPlanSnapResult = SurfacePlanSnapResult
|
||||
|
||||
export function clearCeilingSnapFeedback() {
|
||||
useAlignmentGuides.getState().clear()
|
||||
useWallSnapIndicator.getState().clear()
|
||||
clearSurfacePlanSnapFeedback()
|
||||
}
|
||||
|
||||
export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult {
|
||||
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 ?? CEILING_WALL_SNAP_RADII,
|
||||
gridSnap: fallbackPoint ? () => fallbackPoint : undefined,
|
||||
return resolveSurfacePlanPointSnap({
|
||||
...input,
|
||||
movingId: input.movingId ?? CEILING_SNAP_MOVING_ID,
|
||||
})
|
||||
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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