diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 124bcd91..a6372283 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -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() } }, []) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 8304e0d9..90a62df4 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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) } }, [ diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 6dd16c43..4293df6c 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -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, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 16a81e18..874dea93 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -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. diff --git a/packages/editor/src/lib/ceiling-plan-snap.ts b/packages/editor/src/lib/ceiling-plan-snap.ts index d5351cf2..ee4447ed 100644 --- a/packages/editor/src/lib/ceiling-plan-snap.ts +++ b/packages/editor/src/lib/ceiling-plan-snap.ts @@ -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> - 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>, - 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) === 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: [], - } } diff --git a/packages/editor/src/lib/slab-plan-snap.ts b/packages/editor/src/lib/slab-plan-snap.ts new file mode 100644 index 00000000..3902e324 --- /dev/null +++ b/packages/editor/src/lib/slab-plan-snap.ts @@ -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, + }) +} diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts new file mode 100644 index 00000000..95693425 --- /dev/null +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -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> + 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>, + 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) === 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: [], + } +} diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts index d280d2e7..176906cc 100644 --- a/packages/nodes/src/ceiling/floorplan-affordances.ts +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -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('ceiling') -export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance('ceiling') -export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance('ceiling') +const ceilingSnapOptions = { + resolvePlanPoint({ + node, + nodes, + rawPoint, + fallbackPoint, + modifiers, + }: PolygonAffordanceSnapContext) { + const sceneNodes = nodes as Record + return resolveCeilingPlanPointSnap({ + rawPoint, + fallbackPoint, + levelId: resolveLevelId(node, sceneNodes), + excludeId: node.id, + nodes: sceneNodes, + altKey: modifiers.altKey, + }).point + }, +} + +export const ceilingMoveVertexAffordance = createPolygonVertexAffordance( + 'ceiling', + ceilingSnapOptions, +) +export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance( + 'ceiling', + ceilingSnapOptions, +) +export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance( + 'ceiling', + ceilingSnapOptions, +) diff --git a/packages/nodes/src/shared/polygon-vertex-affordance.ts b/packages/nodes/src/shared/polygon-vertex-affordance.ts index 087104e1..cf2e803e 100644 --- a/packages/nodes/src/shared/polygon-vertex-affordance.ts +++ b/packages/nodes/src/shared/polygon-vertex-affordance.ts @@ -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 = { + node: N + nodes: Record + rawPoint: WallPlanPoint + fallbackPoint: WallPlanPoint + modifiers: FloorplanAffordanceModifiers + holeIndex?: number + mode: PolygonAffordanceMode +} + +type PolygonAffordanceOptions = { + resolvePlanPoint?: (context: PolygonAffordanceSnapContext) => WallPlanPoint +} + type PolygonShape = { polygon: ReadonlyArray holes?: ReadonlyArray> @@ -76,11 +94,19 @@ function buildRingPatch( return { holes: nextHoles } } +function resolveAffordancePlanPoint( + options: PolygonAffordanceOptions | undefined, + context: PolygonAffordanceSnapContext, +): WallPlanPoint { + return options?.resolvePlanPoint?.(context) ?? context.fallbackPoint +} + export function createPolygonVertexAffordance( kind: string, + options?: PolygonAffordanceOptions, ): FloorplanAffordance { 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 i === vertexIndex ? [snapped[0], snapped[1]] : p, ) @@ -128,9 +162,10 @@ export function createPolygonVertexAffordance( kind: string, + options?: PolygonAffordanceOptions, ): FloorplanAffordance { 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 i === newVertexIndex ? [snapped[0], snapped[1]] : p, ) @@ -204,9 +247,10 @@ export function createPolygonAddVertexAffordance( kind: string, + options?: PolygonAffordanceOptions, ): FloorplanAffordance { 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 { 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] }) diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index c651646a..dde74c82 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -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 ) diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index 93793060..b7554530 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -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('slab') -export const slabAddVertexAffordance = createPolygonAddVertexAffordance('slab') -export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance('slab') +const slabSnapOptions = { + resolvePlanPoint({ + node, + nodes, + rawPoint, + fallbackPoint, + modifiers, + }: PolygonAffordanceSnapContext) { + const sceneNodes = nodes as Record + return resolveSlabPlanPointSnap({ + rawPoint, + fallbackPoint, + levelId: resolveLevelId(node, sceneNodes), + excludeId: node.id, + nodes: sceneNodes, + altKey: modifiers.altKey, + }).point + }, +} + +export const slabMoveVertexAffordance = createPolygonVertexAffordance( + 'slab', + slabSnapOptions, +) +export const slabAddVertexAffordance = createPolygonAddVertexAffordance( + 'slab', + slabSnapOptions, +) +export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance( + 'slab', + slabSnapOptions, +) diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 1121c6d4..1f734cab 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -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) => {