feat: add ceiling wall snap feedback
This commit is contained in:
@@ -64,6 +64,7 @@ import {
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||||
import {
|
import {
|
||||||
alignFloorplanDraftPoint,
|
alignFloorplanDraftPoint,
|
||||||
buildFloorplanItemEntry,
|
buildFloorplanItemEntry,
|
||||||
@@ -8425,17 +8426,22 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
if (isCeilingBuildActive) {
|
if (isCeilingBuildActive) {
|
||||||
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
|
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
|
||||||
// the previous vertex. Alignment runs only when angle snap is OFF
|
// the previous vertex. Wall magnetic snap may still win, while
|
||||||
// (first vertex, or Shift held) — when the angle is being locked,
|
// generic alignment runs only when angle snap is OFF (first vertex,
|
||||||
// pulling the vertex sideways would break it.
|
// or Shift held) so it does not pull a locked angle sideways.
|
||||||
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
||||||
let snappedPoint = snapPolygonDraftPoint({
|
const fallbackPoint = snapPolygonDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
||||||
angleSnap,
|
angleSnap,
|
||||||
})
|
})
|
||||||
if (angleSnap) useAlignmentGuides.getState().clear()
|
const snappedPoint = resolveCeilingPlanPointSnap({
|
||||||
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
|
rawPoint: planPoint,
|
||||||
|
fallbackPoint,
|
||||||
|
levelId,
|
||||||
|
altKey: event.altKey,
|
||||||
|
align: !angleSnap,
|
||||||
|
}).point
|
||||||
|
|
||||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||||
setCursorPoint((previousPoint) =>
|
setCursorPoint((previousPoint) =>
|
||||||
@@ -8676,6 +8682,7 @@ export function FloorplanPanel() {
|
|||||||
isPolygonBuildActive,
|
isPolygonBuildActive,
|
||||||
isRoofBuildActive,
|
isRoofBuildActive,
|
||||||
isWallBuildActive,
|
isWallBuildActive,
|
||||||
|
levelId,
|
||||||
publishFloorplanNavigationPose,
|
publishFloorplanNavigationPose,
|
||||||
smoothFloorplanNavigationView,
|
smoothFloorplanNavigationView,
|
||||||
referenceScaleDraft,
|
referenceScaleDraft,
|
||||||
@@ -8934,6 +8941,7 @@ export function FloorplanPanel() {
|
|||||||
isRoofBuildActive,
|
isRoofBuildActive,
|
||||||
isWallBuildActive,
|
isWallBuildActive,
|
||||||
isZoneBuildActive,
|
isZoneBuildActive,
|
||||||
|
levelId,
|
||||||
roofDraftStart,
|
roofDraftStart,
|
||||||
setCursorPoint,
|
setCursorPoint,
|
||||||
setFenceDraftEnd,
|
setFenceDraftEnd,
|
||||||
@@ -9110,25 +9118,33 @@ export function FloorplanPanel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const snappedPoint = snapPolygonDraftPoint({
|
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
|
||||||
|
const fallbackPoint = snapPolygonDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||||
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
|
angleSnap,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isCeilingBuildActive) {
|
if (isCeilingBuildActive) {
|
||||||
emitFloorplanGridEvent('double-click', planPoint, event)
|
const snappedPoint = resolveCeilingPlanPointSnap({
|
||||||
|
rawPoint: planPoint,
|
||||||
|
fallbackPoint,
|
||||||
|
levelId,
|
||||||
|
altKey: event.altKey,
|
||||||
|
align: !angleSnap,
|
||||||
|
}).point
|
||||||
|
emitFloorplanGridEvent('double-click', snappedPoint, event)
|
||||||
handleCeilingPlacementConfirm(snappedPoint)
|
handleCeilingPlacementConfirm(snappedPoint)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isZoneBuildActive) {
|
if (isZoneBuildActive) {
|
||||||
handleZonePlacementConfirm(snappedPoint)
|
handleZonePlacementConfirm(fallbackPoint)
|
||||||
} else {
|
} else {
|
||||||
// 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', planPoint, event)
|
||||||
handleSlabPlacementConfirm(snappedPoint)
|
handleSlabPlacementConfirm(fallbackPoint)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -9142,6 +9158,7 @@ export function FloorplanPanel() {
|
|||||||
isPolygonDraftBuildActive,
|
isPolygonDraftBuildActive,
|
||||||
isRoofBuildActive,
|
isRoofBuildActive,
|
||||||
isZoneBuildActive,
|
isZoneBuildActive,
|
||||||
|
levelId,
|
||||||
shiftPressed,
|
shiftPressed,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
|
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
|
||||||
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
||||||
|
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +52,7 @@ type UseFloorplanBackgroundPlacementArgs = {
|
|||||||
isRoofBuildActive: boolean
|
isRoofBuildActive: boolean
|
||||||
isWallBuildActive: boolean
|
isWallBuildActive: boolean
|
||||||
isZoneBuildActive: boolean
|
isZoneBuildActive: boolean
|
||||||
|
levelId: string | null
|
||||||
roofDraftStart: WallPlanPoint | null
|
roofDraftStart: WallPlanPoint | null
|
||||||
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||||
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||||
@@ -107,6 +109,7 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
isRoofBuildActive,
|
isRoofBuildActive,
|
||||||
isWallBuildActive,
|
isWallBuildActive,
|
||||||
isZoneBuildActive,
|
isZoneBuildActive,
|
||||||
|
levelId,
|
||||||
roofDraftStart,
|
roofDraftStart,
|
||||||
setCursorPoint,
|
setCursorPoint,
|
||||||
setFenceDraftEnd,
|
setFenceDraftEnd,
|
||||||
@@ -149,17 +152,22 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
|
|
||||||
if (isCeilingBuildActive) {
|
if (isCeilingBuildActive) {
|
||||||
// Align the committed vertex the same way the move-preview did, so
|
// Align the committed vertex the same way the move-preview did, so
|
||||||
// the placed point matches what the user saw. Skip when angle snap
|
// the placed point matches what the user saw. Wall magnetic snap may
|
||||||
// owns the vertex (matches the move branch).
|
// still win; generic alignment is skipped when angle snap owns the
|
||||||
|
// vertex (matches the move branch).
|
||||||
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
|
||||||
let snappedPoint = snapPolygonDraftPoint({
|
const fallbackPoint = snapPolygonDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
||||||
angleSnap,
|
angleSnap,
|
||||||
})
|
})
|
||||||
if (!angleSnap) {
|
const snappedPoint = resolveCeilingPlanPointSnap({
|
||||||
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
|
rawPoint: planPoint,
|
||||||
}
|
fallbackPoint,
|
||||||
|
levelId,
|
||||||
|
altKey: event.altKey,
|
||||||
|
align: !angleSnap,
|
||||||
|
}).point
|
||||||
|
|
||||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||||
handleCeilingPlacementPoint(snappedPoint)
|
handleCeilingPlacementPoint(snappedPoint)
|
||||||
@@ -322,6 +330,7 @@ export function useFloorplanBackgroundPlacement({
|
|||||||
isRoofBuildActive,
|
isRoofBuildActive,
|
||||||
isWallBuildActive,
|
isWallBuildActive,
|
||||||
isZoneBuildActive,
|
isZoneBuildActive,
|
||||||
|
levelId,
|
||||||
roofDraftStart,
|
roofDraftStart,
|
||||||
setCursorPoint,
|
setCursorPoint,
|
||||||
setFenceDraftEnd,
|
setFenceDraftEnd,
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { sceneRegistry } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
DEFAULT_WALL_HEIGHT,
|
||||||
|
getWallCurveFrameAt,
|
||||||
|
getWallCurveLength,
|
||||||
|
getWallThickness,
|
||||||
|
isCurvedWall,
|
||||||
|
resolveLevelId,
|
||||||
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
|
import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { memo, useRef } from 'react'
|
import { memo, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
|
import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
@@ -34,6 +47,14 @@ const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
|
|||||||
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
|
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
|
||||||
const MARKER = 0.13 // world-meter base size of the floor glyph
|
const MARKER = 0.13 // world-meter base size of the floor glyph
|
||||||
const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
|
const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
|
||||||
|
const WALL_TOP_HIGHLIGHT_LIFT = 0.035
|
||||||
|
const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018
|
||||||
|
const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14
|
||||||
|
const WALL_TOP_GLOW_HEIGHT = 0.026
|
||||||
|
const WALL_TOP_GLOW_OVERHANG = 0.36
|
||||||
|
const WALL_TOP_END_OVERHANG = 0.08
|
||||||
|
const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45
|
||||||
|
const NO_RAYCAST = () => null
|
||||||
|
|
||||||
// Shared resources — one material + unit geometries, so snap churn during a
|
// Shared resources — one material + unit geometries, so snap churn during a
|
||||||
// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
|
// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
|
||||||
@@ -45,17 +66,41 @@ const beaconMaterial = new MeshBasicNodeMaterial({
|
|||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.9,
|
opacity: 0.9,
|
||||||
})
|
})
|
||||||
|
const wallTopHighlightMaterial = new MeshBasicNodeMaterial({
|
||||||
|
color: BEACON_COLOR,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.88,
|
||||||
|
})
|
||||||
|
const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({
|
||||||
|
color: BEACON_COLOR,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.26,
|
||||||
|
})
|
||||||
const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
|
const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
|
||||||
// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
|
// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
|
||||||
// a flat plate; circles/triangles lie flat via an X rotation at the mesh.
|
// a flat plate; circles/triangles lie flat via an X rotation at the mesh.
|
||||||
const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
|
const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
|
||||||
|
const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1)
|
||||||
const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
|
const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
|
||||||
const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
|
const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
|
||||||
|
|
||||||
export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
||||||
const point = useWallSnapIndicator((s) => s.point)
|
const point = useWallSnapIndicator((s) => s.point)
|
||||||
const levelId = useViewer((s) => s.selection.levelId)
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
const groupRef = useRef<Group>(null)
|
const groupRef = useRef<Group>(null)
|
||||||
|
const highlightedWalls = useMemo(() => {
|
||||||
|
if (!point?.wallIds?.length) return []
|
||||||
|
return point.wallIds
|
||||||
|
.map((wallId) => nodes[wallId as AnyNodeId])
|
||||||
|
.filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false)
|
||||||
|
}, [nodes, point?.wallIds])
|
||||||
|
|
||||||
// Track the active level's building-local Y each frame so the beacon stands
|
// Track the active level's building-local Y each frame so the beacon stands
|
||||||
// on the floor being edited, not the building base — same source the
|
// on the floor being edited, not the building base — same source the
|
||||||
@@ -70,6 +115,9 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
|||||||
if (!point) return null
|
if (!point) return null
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group ref={groupRef}>
|
||||||
|
{highlightedWalls.map((wall) => (
|
||||||
|
<WallTopHighlight key={wall.id} nodes={nodes} wall={wall} />
|
||||||
|
))}
|
||||||
<mesh
|
<mesh
|
||||||
geometry={PILLAR_GEOMETRY}
|
geometry={PILLAR_GEOMETRY}
|
||||||
layers={EDITOR_LAYER}
|
layers={EDITOR_LAYER}
|
||||||
@@ -82,6 +130,103 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type WallTopHighlightSegment = {
|
||||||
|
angle: number
|
||||||
|
center: [number, number]
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
|
||||||
|
const levelId = resolveLevelId(wall, nodes as Record<string, AnyNode>)
|
||||||
|
const slabElevation = spatialGridManager.getSlabElevationForWall(
|
||||||
|
levelId,
|
||||||
|
wall.start,
|
||||||
|
wall.end,
|
||||||
|
wall.curveOffset ?? 0,
|
||||||
|
wall.thickness,
|
||||||
|
)
|
||||||
|
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||||
|
return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHighlightSegment(start: [number, number], end: [number, number]) {
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dz = end[1] - start[1]
|
||||||
|
const length = Math.hypot(dx, dz)
|
||||||
|
if (length < 1e-6) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
angle: -Math.atan2(dz, dx),
|
||||||
|
center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number],
|
||||||
|
length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] {
|
||||||
|
if (!isCurvedWall(wall)) {
|
||||||
|
const segment = buildHighlightSegment(wall.start, wall.end)
|
||||||
|
return segment ? [segment] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleCount = Math.max(
|
||||||
|
8,
|
||||||
|
Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH),
|
||||||
|
)
|
||||||
|
const segments: WallTopHighlightSegment[] = []
|
||||||
|
let previous = getWallCurveFrameAt(wall, 0).point
|
||||||
|
for (let index = 1; index <= sampleCount; index += 1) {
|
||||||
|
const current = getWallCurveFrameAt(wall, index / sampleCount).point
|
||||||
|
const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y])
|
||||||
|
if (segment) segments.push(segment)
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function WallTopHighlight({
|
||||||
|
nodes,
|
||||||
|
wall,
|
||||||
|
}: {
|
||||||
|
nodes: Readonly<Record<string, AnyNode>>
|
||||||
|
wall: WallNode
|
||||||
|
}) {
|
||||||
|
const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall])
|
||||||
|
const y = getWallTopY(wall, nodes)
|
||||||
|
const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24)
|
||||||
|
const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<group key={`${wall.id}:${index}`}>
|
||||||
|
<mesh
|
||||||
|
frustumCulled={false}
|
||||||
|
geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
material={wallTopHighlightGlowMaterial}
|
||||||
|
position={[segment.center[0], y - 0.003, segment.center[1]]}
|
||||||
|
raycast={NO_RAYCAST}
|
||||||
|
renderOrder={1003}
|
||||||
|
rotation={[0, segment.angle, 0]}
|
||||||
|
scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
|
||||||
|
/>
|
||||||
|
<mesh
|
||||||
|
frustumCulled={false}
|
||||||
|
geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
material={wallTopHighlightMaterial}
|
||||||
|
position={[segment.center[0], y + 0.002, segment.center[1]]}
|
||||||
|
raycast={NO_RAYCAST}
|
||||||
|
renderOrder={1004}
|
||||||
|
rotation={[0, segment.angle, 0]}
|
||||||
|
scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
|
/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
|
||||||
function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
|
function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
|
||||||
const y = FLOOR_LIFT
|
const y = FLOOR_LIFT
|
||||||
|
|||||||
+18
-2
@@ -13,6 +13,10 @@ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import {
|
||||||
|
clearCeilingSnapFeedback,
|
||||||
|
resolveCeilingPlanPointSnap,
|
||||||
|
} from '../../../lib/ceiling-plan-snap'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { snapToHalf } from '../../tools/item/placement-math'
|
import { snapToHalf } from '../../tools/item/placement-math'
|
||||||
@@ -84,6 +88,7 @@ function clearCornerDragPreview(drag: CornerDragState) {
|
|||||||
if (drag.inputDraggingSet) {
|
if (drag.inputDraggingSet) {
|
||||||
useViewer.getState().setInputDragging(drag.previousInputDragging)
|
useViewer.getState().setInputDragging(drag.previousInputDragging)
|
||||||
}
|
}
|
||||||
|
clearCeilingSnapFeedback()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CeilingSelectionAffordanceSystem = () => {
|
export const CeilingSelectionAffordanceSystem = () => {
|
||||||
@@ -284,10 +289,21 @@ const CeilingSelectionAffordance = ({
|
|||||||
const initialCorner = drag.initialPolygon[drag.cornerIndex]
|
const initialCorner = drag.initialPolygon[drag.cornerIndex]
|
||||||
if (!initialCorner) return
|
if (!initialCorner) return
|
||||||
|
|
||||||
const nextPosition: [number, number] = [
|
const rawNextPosition: [number, number] = [
|
||||||
|
initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]),
|
||||||
|
initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]),
|
||||||
|
]
|
||||||
|
const gridNextPosition: [number, number] = [
|
||||||
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
|
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
|
||||||
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
|
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
|
||||||
]
|
]
|
||||||
|
const nextPosition = resolveCeilingPlanPointSnap({
|
||||||
|
rawPoint: rawNextPosition,
|
||||||
|
fallbackPoint: gridNextPosition,
|
||||||
|
levelId,
|
||||||
|
excludeId: drag.ceilingId,
|
||||||
|
altKey: event.altKey,
|
||||||
|
}).point
|
||||||
|
|
||||||
if (
|
if (
|
||||||
drag.previousSnappedPosition &&
|
drag.previousSnappedPosition &&
|
||||||
@@ -354,7 +370,7 @@ const CeilingSelectionAffordance = ({
|
|||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
clearCornerDragPreview(drag)
|
clearCornerDragPreview(drag)
|
||||||
}
|
}
|
||||||
}, [effectiveCeiling.id, getHandlePlanePoint, selectCeilingForEdit])
|
}, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let frameId = 0
|
let frameId = 0
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ type DragState = {
|
|||||||
pointerId: number
|
pointerId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PolygonEditorPlanPointSnapContext = {
|
||||||
|
rawPoint: [number, number]
|
||||||
|
gridPoint: [number, number]
|
||||||
|
mode: DragState['mode']
|
||||||
|
vertexIndex: number | null
|
||||||
|
edgeIndex?: number
|
||||||
|
initialPosition: [number, number]
|
||||||
|
initialPolygon: Array<[number, number]>
|
||||||
|
nativeEvent?: GridEvent['nativeEvent']
|
||||||
|
}
|
||||||
|
|
||||||
export interface PolygonEditorProps {
|
export interface PolygonEditorProps {
|
||||||
polygon: Array<[number, number]>
|
polygon: Array<[number, number]>
|
||||||
color?: string
|
color?: string
|
||||||
@@ -120,6 +131,8 @@ export interface PolygonEditorProps {
|
|||||||
showMidpointHandles?: boolean
|
showMidpointHandles?: boolean
|
||||||
/** Whether hovering a handle should also tint its connected edges and endpoint handles. */
|
/** Whether hovering a handle should also tint its connected edges and endpoint handles. */
|
||||||
highlightConnectedHandles?: boolean
|
highlightConnectedHandles?: boolean
|
||||||
|
/** Optional host-owned point snapper. Defaults to the existing half-grid snap. */
|
||||||
|
resolvePlanPoint?: (context: PolygonEditorPlanPointSnapContext) => [number, number]
|
||||||
/** Optional vertex handle renderer for host-specific affordances. */
|
/** Optional vertex handle renderer for host-specific affordances. */
|
||||||
renderVertexHandle?: PolygonVertexHandleRenderer
|
renderVertexHandle?: PolygonVertexHandleRenderer
|
||||||
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
|
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
|
||||||
@@ -397,6 +410,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
showBorderLine = true,
|
showBorderLine = true,
|
||||||
showMidpointHandles = true,
|
showMidpointHandles = true,
|
||||||
highlightConnectedHandles = false,
|
highlightConnectedHandles = false,
|
||||||
|
resolvePlanPoint,
|
||||||
renderMidpointHandle,
|
renderMidpointHandle,
|
||||||
renderVertexHandle,
|
renderVertexHandle,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -731,9 +745,21 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const point = levelNode ? event.localPosition : event.position
|
const point = levelNode ? event.localPosition : event.position
|
||||||
const gridX = snapToHalf(point[0])
|
const rawPoint: [number, number] = [point[0], point[2]]
|
||||||
const gridZ = snapToHalf(point[2])
|
const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
|
||||||
const newPosition: [number, number] = [gridX, gridZ]
|
const newPosition =
|
||||||
|
dragState?.isDragging && resolvePlanPoint
|
||||||
|
? resolvePlanPoint({
|
||||||
|
rawPoint,
|
||||||
|
gridPoint,
|
||||||
|
mode: dragState.mode,
|
||||||
|
vertexIndex: dragState.vertexIndex,
|
||||||
|
edgeIndex: dragState.edgeIndex,
|
||||||
|
initialPosition: dragState.initialPosition,
|
||||||
|
initialPolygon: dragState.initialPolygon,
|
||||||
|
nativeEvent: event.nativeEvent,
|
||||||
|
})
|
||||||
|
: gridPoint
|
||||||
|
|
||||||
// Play snap sound when cursor moves to a new grid cell during drag
|
// Play snap sound when cursor moves to a new grid cell during drag
|
||||||
if (
|
if (
|
||||||
@@ -788,7 +814,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
}
|
}
|
||||||
}, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon])
|
}, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon])
|
||||||
|
|
||||||
// Set up pointer up listener for ending drag
|
// Set up pointer up listener for ending drag
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
WALL_JOIN_SNAP_RADIUS,
|
WALL_JOIN_SNAP_RADIUS,
|
||||||
type WallDraftSnapResult,
|
type WallDraftSnapResult,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
|
type WallSnapRadii,
|
||||||
} from './wall-snap-geometry'
|
} from './wall-snap-geometry'
|
||||||
|
|
||||||
// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
|
// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
|
||||||
@@ -30,6 +31,7 @@ export {
|
|||||||
type WallDraftSnapKind,
|
type WallDraftSnapKind,
|
||||||
type WallDraftSnapResult,
|
type WallDraftSnapResult,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
|
type WallSnapRadii,
|
||||||
} from './wall-snap-geometry'
|
} from './wall-snap-geometry'
|
||||||
|
|
||||||
export const WALL_GRID_STEP = 0.5
|
export const WALL_GRID_STEP = 0.5
|
||||||
@@ -345,6 +347,8 @@ type SnapWallDraftArgs = {
|
|||||||
* local-axis grid at `step`.
|
* local-axis grid at `step`.
|
||||||
*/
|
*/
|
||||||
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
|
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
|
||||||
|
/** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
|
||||||
|
snapRadii?: WallSnapRadii
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
|
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
|
||||||
@@ -357,13 +361,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
|||||||
step: overrideStep,
|
step: overrideStep,
|
||||||
magnetic = true,
|
magnetic = true,
|
||||||
gridSnap,
|
gridSnap,
|
||||||
|
snapRadii,
|
||||||
} = args
|
} = args
|
||||||
|
|
||||||
// Discrete special points (corner / midpoint / crossing) are taken from the
|
// Discrete special points (corner / midpoint / crossing) are taken from the
|
||||||
// raw cursor so an interim grid snap can't mask them. A corner always wins,
|
// raw cursor so an interim grid snap can't mask them. A corner always wins,
|
||||||
// then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
|
// then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
|
||||||
if (magnetic) {
|
if (magnetic) {
|
||||||
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
|
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii)
|
||||||
if (special) return special
|
if (special) return special
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +382,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
|
|||||||
: snapPointToGrid(point, step)
|
: snapPointToGrid(point, step)
|
||||||
|
|
||||||
if (magnetic) {
|
if (magnetic) {
|
||||||
const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
|
const wallSnap = findWallSnapTarget(basePoint, walls, {
|
||||||
|
ignoreWallIds,
|
||||||
|
radius: snapRadii?.wall,
|
||||||
|
})
|
||||||
if (wallSnap) return { point: wallSnap, snap: 'wall' }
|
if (wallSnap) return { point: wallSnap, snap: 'wall' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ describe('findWallSpecialPointSnap', () => {
|
|||||||
// handled separately by findWallSnapTarget, not a special point.
|
// handled separately by findWallSnapTarget, not a special point.
|
||||||
expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
|
expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('honors tighter per-call radii without changing defaults', () => {
|
||||||
|
const walls = [makeWall([0, 0], [4, 0])]
|
||||||
|
|
||||||
|
expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint')
|
||||||
|
expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('findWallSnapTarget (edge / along-wall)', () => {
|
describe('findWallSnapTarget (edge / along-wall)', () => {
|
||||||
@@ -76,4 +83,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
|
|||||||
const walls = [makeWall([0, 0], [4, 0])]
|
const walls = [makeWall([0, 0], [4, 0])]
|
||||||
expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
|
expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('honors a tighter wall-body radius', () => {
|
||||||
|
const walls = [makeWall([0, 0], [4, 0])]
|
||||||
|
|
||||||
|
expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type WallPlanPoint = [number, number]
|
|||||||
/** Which kind of existing-geometry snap produced a drafted point. */
|
/** Which kind of existing-geometry snap produced a drafted point. */
|
||||||
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
|
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
|
||||||
|
|
||||||
|
export type WallSnapRadii = Partial<Record<WallDraftSnapKind, number>>
|
||||||
|
|
||||||
export type WallDraftSnapResult = {
|
export type WallDraftSnapResult = {
|
||||||
point: WallPlanPoint
|
point: WallPlanPoint
|
||||||
/**
|
/**
|
||||||
@@ -119,9 +121,10 @@ export function findWallEndpointFromRaw(
|
|||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
ignoreWallIds?: string[],
|
ignoreWallIds?: string[],
|
||||||
|
radius = WALL_ENDPOINT_SNAP_RADIUS,
|
||||||
): WallPlanPoint | null {
|
): WallPlanPoint | null {
|
||||||
const ignored = new Set(ignoreWallIds ?? [])
|
const ignored = new Set(ignoreWallIds ?? [])
|
||||||
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
|
const radiusSquared = radius ** 2
|
||||||
let best: WallPlanPoint | null = null
|
let best: WallPlanPoint | null = null
|
||||||
let bestDistSq = Number.POSITIVE_INFINITY
|
let bestDistSq = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
@@ -152,9 +155,10 @@ export function findWallMidpointFromRaw(
|
|||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
ignoreWallIds?: string[],
|
ignoreWallIds?: string[],
|
||||||
|
radius = WALL_MIDPOINT_SNAP_RADIUS,
|
||||||
): WallPlanPoint | null {
|
): WallPlanPoint | null {
|
||||||
const ignored = new Set(ignoreWallIds ?? [])
|
const ignored = new Set(ignoreWallIds ?? [])
|
||||||
const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
|
const radiusSquared = radius ** 2
|
||||||
let best: WallPlanPoint | null = null
|
let best: WallPlanPoint | null = null
|
||||||
let bestDistSq = Number.POSITIVE_INFINITY
|
let bestDistSq = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
@@ -202,10 +206,11 @@ export function findWallIntersectionFromRaw(
|
|||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
ignoreWallIds?: string[],
|
ignoreWallIds?: string[],
|
||||||
|
radius = WALL_INTERSECTION_SNAP_RADIUS,
|
||||||
): WallPlanPoint | null {
|
): WallPlanPoint | null {
|
||||||
const ignored = new Set(ignoreWallIds ?? [])
|
const ignored = new Set(ignoreWallIds ?? [])
|
||||||
const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
|
const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
|
||||||
const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2
|
const radiusSquared = radius ** 2
|
||||||
let best: WallPlanPoint | null = null
|
let best: WallPlanPoint | null = null
|
||||||
let bestDistSq = Number.POSITIVE_INFINITY
|
let bestDistSq = Number.POSITIVE_INFINITY
|
||||||
|
|
||||||
@@ -257,12 +262,13 @@ export function findWallSpecialPointSnap(
|
|||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
ignoreWallIds?: string[],
|
ignoreWallIds?: string[],
|
||||||
|
radii?: WallSnapRadii,
|
||||||
): WallDraftSnapResult | null {
|
): WallDraftSnapResult | null {
|
||||||
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
|
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint)
|
||||||
if (endpoint) return { point: endpoint, snap: 'endpoint' }
|
if (endpoint) return { point: endpoint, snap: 'endpoint' }
|
||||||
|
|
||||||
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
|
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint)
|
||||||
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
|
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection)
|
||||||
return nearestCandidate(point, [
|
return nearestCandidate(point, [
|
||||||
midpoint && { point: midpoint, snap: 'midpoint' },
|
midpoint && { point: midpoint, snap: 'midpoint' },
|
||||||
intersection && { point: intersection, snap: 'intersection' },
|
intersection && { point: intersection, snap: 'intersection' },
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export { useFreshPlacementVisibility } from './components/tools/shared/fresh-pla
|
|||||||
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
|
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
|
||||||
export {
|
export {
|
||||||
PolygonEditor,
|
PolygonEditor,
|
||||||
|
type PolygonEditorPlanPointSnapContext,
|
||||||
type PolygonEditorProps,
|
type PolygonEditorProps,
|
||||||
} from './components/tools/shared/polygon-editor'
|
} from './components/tools/shared/polygon-editor'
|
||||||
export {
|
export {
|
||||||
@@ -108,6 +109,7 @@ export {
|
|||||||
type WallDraftSnapKind,
|
type WallDraftSnapKind,
|
||||||
type WallDraftSnapResult,
|
type WallDraftSnapResult,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
|
type WallSnapRadii,
|
||||||
} from './components/tools/wall/wall-drafting'
|
} from './components/tools/wall/wall-drafting'
|
||||||
// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the
|
// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the
|
||||||
// existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the
|
// existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the
|
||||||
@@ -172,6 +174,13 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
|
|||||||
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
|
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
|
||||||
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
||||||
export { type Selection, useSelection } from './hooks/use-selection'
|
export { type Selection, useSelection } from './hooks/use-selection'
|
||||||
|
export {
|
||||||
|
CEILING_ALIGNMENT_THRESHOLD_M,
|
||||||
|
type CeilingPlanSnapInput,
|
||||||
|
type CeilingPlanSnapResult,
|
||||||
|
clearCeilingSnapFeedback,
|
||||||
|
resolveCeilingPlanPointSnap,
|
||||||
|
} from './lib/ceiling-plan-snap'
|
||||||
export { EDITOR_LAYER } from './lib/constants'
|
export { EDITOR_LAYER } from './lib/constants'
|
||||||
// Helper libs used by the kind-owned roof / stair / elevator panels.
|
// Helper libs used by the kind-owned roof / stair / elevator panels.
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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 CEILING_SNAP_MOVING_ID = '__ceiling_snap__'
|
||||||
|
export const CEILING_ALIGNMENT_THRESHOLD_M = 0.08
|
||||||
|
const CEILING_WALL_SNAP_RADII = {
|
||||||
|
endpoint: 0.38,
|
||||||
|
midpoint: 0.28,
|
||||||
|
intersection: 0.28,
|
||||||
|
wall: 0.18,
|
||||||
|
} satisfies WallSnapRadii
|
||||||
|
const WALL_SOURCE_MATCH_EPSILON = 0.035
|
||||||
|
|
||||||
|
export type CeilingPlanSnapInput = {
|
||||||
|
rawPoint: WallPlanPoint
|
||||||
|
fallbackPoint?: WallPlanPoint
|
||||||
|
levelId?: string | null
|
||||||
|
excludeId?: string | null
|
||||||
|
movingId?: string
|
||||||
|
nodes?: Readonly<Record<string, AnyNode>>
|
||||||
|
walls?: readonly WallNode[]
|
||||||
|
candidates?: readonly AlignmentAnchor[]
|
||||||
|
threshold?: number
|
||||||
|
altKey?: boolean
|
||||||
|
magnetic?: boolean
|
||||||
|
align?: boolean
|
||||||
|
step?: number
|
||||||
|
snapRadii?: WallSnapRadii
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CeilingPlanSnapResult = {
|
||||||
|
point: WallPlanPoint
|
||||||
|
wallSnap: WallDraftSnapKind | null
|
||||||
|
guides: AlignmentGuide[]
|
||||||
|
wallIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelWalls(
|
||||||
|
nodes: Readonly<Record<string, AnyNode>>,
|
||||||
|
levelId: string | null | undefined,
|
||||||
|
walls?: readonly WallNode[],
|
||||||
|
): WallNode[] {
|
||||||
|
const source =
|
||||||
|
walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall')
|
||||||
|
if (!levelId) return source.filter((wall) => wall.visible !== false)
|
||||||
|
|
||||||
|
return source.filter(
|
||||||
|
(wall) =>
|
||||||
|
wall.visible !== false && resolveLevelId(wall, nodes as Record<string, AnyNode>) === levelId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) {
|
||||||
|
const dx = a[0] - b[0]
|
||||||
|
const dz = a[1] - b[1]
|
||||||
|
return dx * dx + dz * dz
|
||||||
|
}
|
||||||
|
|
||||||
|
function wallMidpoint(wall: WallNode): WallPlanPoint {
|
||||||
|
if (isCurvedWall(wall)) {
|
||||||
|
const frame = getWallCurveFrameAt(wall, 0.5)
|
||||||
|
return [frame.point.x, frame.point.y]
|
||||||
|
}
|
||||||
|
return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) {
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dz = end[1] - start[1]
|
||||||
|
const lengthSquared = dx * dx + dz * dz
|
||||||
|
if (lengthSquared < 1e-9) return distanceSquared(point, start)
|
||||||
|
|
||||||
|
const t = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
|
||||||
|
)
|
||||||
|
const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t]
|
||||||
|
return distanceSquared(point, projected)
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) {
|
||||||
|
if (!isCurvedWall(wall)) {
|
||||||
|
return distanceToSegmentSquared(point, wall.start, wall.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
|
||||||
|
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||||
|
let previous = getWallCurveFrameAt(wall, 0).point
|
||||||
|
for (let index = 1; index <= sampleCount; index += 1) {
|
||||||
|
const current = getWallCurveFrameAt(wall, index / sampleCount).point
|
||||||
|
const distance = distanceToSegmentSquared(
|
||||||
|
point,
|
||||||
|
[previous.x, previous.y],
|
||||||
|
[current.x, current.y],
|
||||||
|
)
|
||||||
|
bestDistanceSquared = Math.min(bestDistanceSquared, distance)
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
return bestDistanceSquared
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) {
|
||||||
|
return walls
|
||||||
|
.map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) }))
|
||||||
|
.sort((a, b) => a.distance - b.distance)
|
||||||
|
.slice(0, count)
|
||||||
|
.map(({ id }) => id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSnapSourceWallIds(
|
||||||
|
point: WallPlanPoint,
|
||||||
|
kind: WallDraftSnapKind,
|
||||||
|
walls: readonly WallNode[],
|
||||||
|
): string[] {
|
||||||
|
const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2
|
||||||
|
|
||||||
|
if (kind === 'endpoint') {
|
||||||
|
const endpointMatches = walls.filter(
|
||||||
|
(wall) =>
|
||||||
|
distanceSquared(point, wall.start) <= epsilonSquared ||
|
||||||
|
distanceSquared(point, wall.end) <= epsilonSquared,
|
||||||
|
)
|
||||||
|
if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id)
|
||||||
|
return closestWallIds(point, walls, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'midpoint') {
|
||||||
|
const midpointMatches = walls.filter(
|
||||||
|
(wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared,
|
||||||
|
)
|
||||||
|
if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id)
|
||||||
|
return closestWallIds(point, walls, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'intersection') {
|
||||||
|
const crossingMatches = walls.filter(
|
||||||
|
(wall) => distanceToWallSquared(point, wall) <= epsilonSquared,
|
||||||
|
)
|
||||||
|
if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2)
|
||||||
|
return closestWallIds(point, walls, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestWallIds(point, walls, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCeilingSnapFeedback() {
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
useWallSnapIndicator.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ export type WallSnapPoint = {
|
|||||||
x: number
|
x: number
|
||||||
z: number
|
z: number
|
||||||
kind: WallSnapKind
|
kind: WallSnapKind
|
||||||
|
/** Optional wall ids whose geometry produced this snap. */
|
||||||
|
wallIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type WallSnapIndicatorState = {
|
type WallSnapIndicatorState = {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||||
import { PolygonEditor, triggerSFX } from '@pascal-app/editor'
|
import {
|
||||||
|
clearCeilingSnapFeedback,
|
||||||
|
PolygonEditor,
|
||||||
|
type PolygonEditorPlanPointSnapContext,
|
||||||
|
resolveCeilingPlanPointSnap,
|
||||||
|
triggerSFX,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
@@ -36,9 +42,13 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
|||||||
() => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
|
() => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
|
||||||
[ceiling, liveOverride],
|
[ceiling, liveOverride],
|
||||||
)
|
)
|
||||||
|
const ceilingLevelId = effectiveCeiling
|
||||||
|
? resolveLevelId(effectiveCeiling, useScene.getState().nodes)
|
||||||
|
: null
|
||||||
|
|
||||||
const handlePolygonChange = useCallback(
|
const handlePolygonChange = useCallback(
|
||||||
(newPolygon: Array<[number, number]>) => {
|
(newPolygon: Array<[number, number]>) => {
|
||||||
|
clearCeilingSnapFeedback()
|
||||||
updateNode(ceilingId, { polygon: newPolygon })
|
updateNode(ceilingId, { polygon: newPolygon })
|
||||||
setSelection({ selectedIds: [ceilingId] })
|
setSelection({ selectedIds: [ceilingId] })
|
||||||
},
|
},
|
||||||
@@ -87,12 +97,18 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
|||||||
(isDragging: boolean) => {
|
(isDragging: boolean) => {
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
ownsPolygonPreviewRef.current = false
|
ownsPolygonPreviewRef.current = false
|
||||||
|
clearCeilingSnapFeedback()
|
||||||
}
|
}
|
||||||
setCeilingHandleHover(isDragging)
|
setCeilingHandleHover(isDragging)
|
||||||
},
|
},
|
||||||
[setCeilingHandleHover],
|
[setCeilingHandleHover],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handlePolygonEditorDragCommit = useCallback(() => {
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
clearCeilingSnapFeedback()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handlePolygonEditorDragStart = useCallback(() => {
|
const handlePolygonEditorDragStart = useCallback(() => {
|
||||||
ownsPolygonPreviewRef.current = true
|
ownsPolygonPreviewRef.current = true
|
||||||
triggerSFX('sfx:item-pick')
|
triggerSFX('sfx:item-pick')
|
||||||
@@ -102,8 +118,21 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
|||||||
ownsPolygonPreviewRef.current = true
|
ownsPolygonPreviewRef.current = true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const resolvePolygonEditorPlanPoint = useCallback(
|
||||||
|
(context: PolygonEditorPlanPointSnapContext) =>
|
||||||
|
resolveCeilingPlanPointSnap({
|
||||||
|
rawPoint: context.rawPoint,
|
||||||
|
fallbackPoint: context.gridPoint,
|
||||||
|
levelId: ceilingLevelId,
|
||||||
|
excludeId: ceilingId,
|
||||||
|
altKey: context.nativeEvent?.altKey === true,
|
||||||
|
}).point,
|
||||||
|
[ceilingId, ceilingLevelId],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
clearCeilingSnapFeedback()
|
||||||
useLiveNodeOverrides.getState().clear(ceilingId)
|
useLiveNodeOverrides.getState().clear(ceilingId)
|
||||||
useScene.getState().markDirty(ceilingId)
|
useScene.getState().markDirty(ceilingId)
|
||||||
ownsPolygonPreviewRef.current = false
|
ownsPolygonPreviewRef.current = false
|
||||||
@@ -121,18 +150,19 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
|
|||||||
allowEdgeMove
|
allowEdgeMove
|
||||||
color="#d4d4d4"
|
color="#d4d4d4"
|
||||||
highlightConnectedHandles
|
highlightConnectedHandles
|
||||||
levelId={resolveLevelId(effectiveCeiling, useScene.getState().nodes)}
|
levelId={ceilingLevelId ?? undefined}
|
||||||
minVertices={3}
|
minVertices={3}
|
||||||
onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag}
|
onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag}
|
||||||
onDragStateChange={handleDragStateChange}
|
onDragCommit={handlePolygonEditorDragCommit}
|
||||||
onDragCommit={() => triggerSFX('sfx:item-place')}
|
|
||||||
onDragStart={handlePolygonEditorDragStart}
|
onDragStart={handlePolygonEditorDragStart}
|
||||||
|
onDragStateChange={handleDragStateChange}
|
||||||
onEdgeHoverChange={handleHandleHoverChange}
|
onEdgeHoverChange={handleHandleHoverChange}
|
||||||
onMidpointHoverChange={handleHandleHoverChange}
|
onMidpointHoverChange={handleHandleHoverChange}
|
||||||
onPolygonChange={handlePolygonChange}
|
onPolygonChange={handlePolygonChange}
|
||||||
onPolygonPreview={handlePolygonPreview}
|
onPolygonPreview={handlePolygonPreview}
|
||||||
onVertexHoverChange={handleHandleHoverChange}
|
onVertexHoverChange={handleHandleHoverChange}
|
||||||
polygon={effectiveCeiling.polygon}
|
polygon={effectiveCeiling.polygon}
|
||||||
|
resolvePlanPoint={resolvePolygonEditorPlanPoint}
|
||||||
surfaceHeight={effectiveCeiling.height ?? 2.5}
|
surfaceHeight={effectiveCeiling.height ?? 2.5}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
clearCeilingSnapFeedback,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
markToolCancelConsumed,
|
markToolCancelConsumed,
|
||||||
|
resolveCeilingPlanPointSnap,
|
||||||
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'
|
||||||
@@ -33,8 +27,6 @@ import { CeilingNode } from './schema'
|
|||||||
|
|
||||||
const CEILING_HEIGHT = 2.52
|
const CEILING_HEIGHT = 2.52
|
||||||
const GRID_OFFSET = 0.02
|
const GRID_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],
|
||||||
@@ -93,10 +85,7 @@ export const CeilingTool: React.FC = () => {
|
|||||||
// draw isn't built with a stale preset's parameters. Unmount-only.
|
// draw isn't built with a stale preset's parameters. Unmount-only.
|
||||||
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
|
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
|
||||||
|
|
||||||
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
|
useEffect(() => () => clearCeilingSnapFeedback(), [])
|
||||||
// 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(), [])
|
|
||||||
|
|
||||||
const verticalGeo = useMemo(
|
const verticalGeo = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -115,44 +104,6 @@ export const CeilingTool: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
// Alignment candidates — anchors of every OTHER alignable object. The
|
|
||||||
// ceiling'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: '__ceiling-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 && gridCursorRef.current)) return
|
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||||
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||||
@@ -168,7 +119,12 @@ export const CeilingTool: 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 = resolveCeilingPlanPointSnap({
|
||||||
|
rawPoint,
|
||||||
|
fallbackPoint: orthoPoint,
|
||||||
|
levelId: currentLevelId,
|
||||||
|
altKey: event.nativeEvent?.altKey === true,
|
||||||
|
}).point
|
||||||
setSnappedCursorPosition(displayPoint)
|
setSnappedCursorPosition(displayPoint)
|
||||||
if (
|
if (
|
||||||
points.length > 0 &&
|
points.length > 0 &&
|
||||||
@@ -199,7 +155,7 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||||
setSelection({ selectedIds: [ceilingId] })
|
setSelection({ selectedIds: [ceilingId] })
|
||||||
setPoints([])
|
setPoints([])
|
||||||
useAlignmentGuides.getState().clear()
|
clearCeilingSnapFeedback()
|
||||||
} 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.
|
||||||
@@ -214,14 +170,14 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||||
setSelection({ selectedIds: [ceilingId] })
|
setSelection({ selectedIds: [ceilingId] })
|
||||||
setPoints([])
|
setPoints([])
|
||||||
useAlignmentGuides.getState().clear()
|
clearCeilingSnapFeedback()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
if (points.length > 0) markToolCancelConsumed()
|
if (points.length > 0) markToolCancelConsumed()
|
||||||
setPoints([])
|
setPoints([])
|
||||||
useAlignmentGuides.getState().clear()
|
clearCeilingSnapFeedback()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user