feat: sync surface polygon editing
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user