@pascal-app/editor 0.9.2 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -9,12 +9,14 @@ import {
|
|
|
9
9
|
movingAlignmentAnchors,
|
|
10
10
|
type NodeEvent,
|
|
11
11
|
resolveAlignment,
|
|
12
|
+
resolveSupportSlabPatch,
|
|
12
13
|
StairNode,
|
|
13
14
|
StairSegmentNode,
|
|
14
15
|
syncAutoStairOpenings,
|
|
15
16
|
useScene,
|
|
16
17
|
} from '@pascal-app/core'
|
|
17
18
|
import { useViewer } from '@pascal-app/viewer'
|
|
19
|
+
import { useThree } from '@react-three/fiber'
|
|
18
20
|
import { useEffect, useMemo, useRef } from 'react'
|
|
19
21
|
import * as THREE from 'three'
|
|
20
22
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
@@ -31,8 +33,11 @@ import useEditor, {
|
|
|
31
33
|
} from '../../../store/use-editor'
|
|
32
34
|
|
|
33
35
|
import useFacingPose from '../../../store/use-facing-pose'
|
|
36
|
+
import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
|
|
34
37
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
35
38
|
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
|
|
39
|
+
import { resolvePointerSupportSurface } from '../shared/pointer-support-cap'
|
|
40
|
+
import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard'
|
|
36
41
|
import {
|
|
37
42
|
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
|
38
43
|
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
|
@@ -57,6 +62,7 @@ const GRID_OFFSET = 0.02
|
|
|
57
62
|
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
|
|
58
63
|
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
59
64
|
type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
|
|
65
|
+
type MoveTriggerEvent = GridEvent | NodeEvent<AnyNode>
|
|
60
66
|
|
|
61
67
|
const CLICK_TRIGGER_KINDS = [
|
|
62
68
|
'shelf',
|
|
@@ -149,7 +155,6 @@ function createDefaultStairNode({
|
|
|
149
155
|
slabOpeningMode: 'destination',
|
|
150
156
|
openingOffset: DEFAULT_STAIR_OPENING_OFFSET,
|
|
151
157
|
width: DEFAULT_STAIR_WIDTH,
|
|
152
|
-
totalRise: DEFAULT_STAIR_HEIGHT,
|
|
153
158
|
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
|
154
159
|
thickness: DEFAULT_STAIR_THICKNESS,
|
|
155
160
|
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
|
@@ -172,6 +177,7 @@ function commitStairPlacement(
|
|
|
172
177
|
levelId: LevelNode['id'],
|
|
173
178
|
position: [number, number, number],
|
|
174
179
|
rotation: number,
|
|
180
|
+
supportElevationCap: number | null,
|
|
175
181
|
): void {
|
|
176
182
|
const { createNodes, nodes } = useScene.getState()
|
|
177
183
|
const placementLevelId = resolveStairPlacementLevelId(
|
|
@@ -192,13 +198,27 @@ function commitStairPlacement(
|
|
|
192
198
|
})
|
|
193
199
|
const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId
|
|
194
200
|
|
|
195
|
-
const stair =
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
const stair = StairNode.parse({
|
|
202
|
+
...createDefaultStairNode({
|
|
203
|
+
name,
|
|
204
|
+
levelId: placementLevelId,
|
|
205
|
+
nextLevelId,
|
|
206
|
+
position,
|
|
207
|
+
rotation,
|
|
208
|
+
segmentId: segment.id,
|
|
209
|
+
}),
|
|
210
|
+
parentId: placementLevelId,
|
|
211
|
+
})
|
|
212
|
+
const prospectiveNodes = {
|
|
213
|
+
...nodes,
|
|
214
|
+
[stair.id]: stair,
|
|
215
|
+
[segment.id]: { ...segment, parentId: stair.id },
|
|
216
|
+
} as Record<string, AnyNode>
|
|
217
|
+
const committedStair = StairNode.parse({
|
|
218
|
+
...stair,
|
|
219
|
+
...resolveSupportSlabPatch(stair, prospectiveNodes, {
|
|
220
|
+
maxElevation: supportElevationCap,
|
|
221
|
+
}),
|
|
202
222
|
})
|
|
203
223
|
|
|
204
224
|
const createdLevel = destinationPlan?.createdLevel
|
|
@@ -209,17 +229,21 @@ function commitStairPlacement(
|
|
|
209
229
|
|
|
210
230
|
createNodes([
|
|
211
231
|
...levelCreateOps,
|
|
212
|
-
{ node:
|
|
213
|
-
{ node: segment, parentId:
|
|
232
|
+
{ node: committedStair, parentId: placementLevelId },
|
|
233
|
+
{ node: segment, parentId: committedStair.id },
|
|
214
234
|
])
|
|
215
235
|
|
|
216
236
|
sfxEmitter.emit('sfx:structure-build')
|
|
217
237
|
}
|
|
218
238
|
|
|
219
239
|
export const StairTool: React.FC = () => {
|
|
240
|
+
const camera = useThree((state) => state.camera)
|
|
241
|
+
const cameraRef = useRef(camera)
|
|
242
|
+
cameraRef.current = camera
|
|
220
243
|
const cursorRef = useRef<THREE.Group>(null)
|
|
221
244
|
const previewRef = useRef<THREE.Group>(null)
|
|
222
245
|
const rotationRef = useRef(0)
|
|
246
|
+
const supportCapRef = useRef<number | null>(null)
|
|
223
247
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
|
224
248
|
const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
|
|
225
249
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
@@ -230,11 +254,16 @@ export const StairTool: React.FC = () => {
|
|
|
230
254
|
if (!currentLevelId) return
|
|
231
255
|
|
|
232
256
|
const openingPreview = createSurfaceOpeningPreviewController()
|
|
257
|
+
// Refuses the duplicate commit triggers a single physical click produces
|
|
258
|
+
// — see `stair-click-guard.ts`. Fresh per armed session.
|
|
259
|
+
const commitGate = createStairCommitGate()
|
|
233
260
|
|
|
234
261
|
// Reset rotation when tool activates
|
|
235
262
|
rotationRef.current = 0
|
|
263
|
+
useStairBuildPreview.getState().reset()
|
|
236
264
|
if (previewRef.current) previewRef.current.rotation.y = 0
|
|
237
265
|
lastCanonicalPositionRef.current = null
|
|
266
|
+
supportCapRef.current = null
|
|
238
267
|
|
|
239
268
|
const buildPreviewScene = (position: [number, number, number], rotation: number) => {
|
|
240
269
|
const nodes = useScene.getState().nodes
|
|
@@ -281,10 +310,15 @@ export const StairTool: React.FC = () => {
|
|
|
281
310
|
// cheap because it has no opening sync).
|
|
282
311
|
let lastPreviewKey: string | null = null
|
|
283
312
|
|
|
284
|
-
const applyDraftPreview = (
|
|
285
|
-
|
|
313
|
+
const applyDraftPreview = (
|
|
314
|
+
position: [number, number, number],
|
|
315
|
+
rotation: number,
|
|
316
|
+
supportElevationCap: number | null,
|
|
317
|
+
) => {
|
|
318
|
+
const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportElevationCap?.toFixed(3) ?? 'none'}`
|
|
286
319
|
if (key === lastPreviewKey) return
|
|
287
320
|
lastPreviewKey = key
|
|
321
|
+
useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation)
|
|
288
322
|
const preview = buildPreviewScene(position, rotation)
|
|
289
323
|
const visualPosition = preview
|
|
290
324
|
? getFloorStackPreviewPosition({
|
|
@@ -293,6 +327,7 @@ export const StairTool: React.FC = () => {
|
|
|
293
327
|
rotation,
|
|
294
328
|
levelId: preview.placementLevelId,
|
|
295
329
|
nodes: preview.previewNodes,
|
|
330
|
+
maxElevation: supportElevationCap,
|
|
296
331
|
})
|
|
297
332
|
: position
|
|
298
333
|
if (cursorRef.current) {
|
|
@@ -388,25 +423,34 @@ export const StairTool: React.FC = () => {
|
|
|
388
423
|
return [x, z]
|
|
389
424
|
}
|
|
390
425
|
|
|
391
|
-
const
|
|
426
|
+
const resolveStairPosition = (event: MoveTriggerEvent): [number, number, number] | null => {
|
|
427
|
+
const pointed = resolvePointerSupportSurface(cameraRef.current, event.position)
|
|
428
|
+
supportCapRef.current = pointed?.elevation ?? null
|
|
429
|
+
const fallbackPosition =
|
|
430
|
+
'node' in event ? lastCanonicalPositionRef.current : event.localPosition
|
|
431
|
+
if (!pointed?.localPoint && !fallbackPosition) return null
|
|
432
|
+
const rawX = pointed?.localPoint?.[0] ?? fallbackPosition![0]
|
|
433
|
+
const rawZ = pointed?.localPoint?.[2] ?? fallbackPosition![2]
|
|
392
434
|
// Grid snap follows the global mode (live step so the HUD chip is
|
|
393
435
|
// honest); Off keeps the raw cursor. Shift cycles the mode centrally.
|
|
394
436
|
const step = useEditor.getState().gridSnapStep
|
|
395
437
|
const [gridX, gridZ] = alignPoint(
|
|
396
|
-
isGridSnapActive()
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
? Math.round(event.localPosition[2] / step) * step
|
|
401
|
-
: event.localPosition[2],
|
|
402
|
-
event.localPosition[0],
|
|
403
|
-
event.localPosition[2],
|
|
438
|
+
isGridSnapActive() ? Math.round(rawX / step) * step : rawX,
|
|
439
|
+
isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ,
|
|
440
|
+
rawX,
|
|
441
|
+
rawZ,
|
|
404
442
|
!isAlignmentGuideActive(),
|
|
405
443
|
isMagneticSnapActive(),
|
|
406
444
|
)
|
|
407
|
-
|
|
445
|
+
return [gridX, 0, gridZ]
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const onPointerMove = (event: MoveTriggerEvent) => {
|
|
449
|
+
const position = resolveStairPosition(event)
|
|
450
|
+
if (!position) return
|
|
451
|
+
const [gridX, , gridZ] = position
|
|
408
452
|
lastCanonicalPositionRef.current = position
|
|
409
|
-
applyDraftPreview(position, rotationRef.current)
|
|
453
|
+
applyDraftPreview(position, rotationRef.current, supportCapRef.current)
|
|
410
454
|
|
|
411
455
|
if (
|
|
412
456
|
(isGridSnapActive() || isMagneticSnapActive()) &&
|
|
@@ -419,37 +463,28 @@ export const StairTool: React.FC = () => {
|
|
|
419
463
|
previousGridPosRef.current = [gridX, gridZ]
|
|
420
464
|
}
|
|
421
465
|
|
|
422
|
-
const getAlignedGridPosition = (event: GridEvent): [number, number, number] => {
|
|
423
|
-
const step = useEditor.getState().gridSnapStep
|
|
424
|
-
const [gridX, gridZ] = alignPoint(
|
|
425
|
-
isGridSnapActive()
|
|
426
|
-
? Math.round(event.localPosition[0] / step) * step
|
|
427
|
-
: event.localPosition[0],
|
|
428
|
-
isGridSnapActive()
|
|
429
|
-
? Math.round(event.localPosition[2] / step) * step
|
|
430
|
-
: event.localPosition[2],
|
|
431
|
-
event.localPosition[0],
|
|
432
|
-
event.localPosition[2],
|
|
433
|
-
!isAlignmentGuideActive(),
|
|
434
|
-
isMagneticSnapActive(),
|
|
435
|
-
)
|
|
436
|
-
return [gridX, 0, gridZ]
|
|
437
|
-
}
|
|
438
|
-
|
|
439
466
|
const commitAtCursor = (event: ClickTriggerEvent) => {
|
|
440
467
|
if (!currentLevelId) return
|
|
468
|
+
// One physical click can reach here twice (node click synthesized on
|
|
469
|
+
// pointerup + the native browser click driving `grid:click`) — see
|
|
470
|
+
// `stair-click-guard.ts`. The gate refuses anything after a single-
|
|
471
|
+
// continuation commit; the swallow below eats the same gesture's
|
|
472
|
+
// follow-up click while the tool stays armed (repeat continuation).
|
|
473
|
+
if (!commitGate.shouldCommit()) return
|
|
441
474
|
const nodeEvent = 'node' in event ? (event as NodeEvent<AnyNode>) : null
|
|
442
475
|
if (nodeEvent) {
|
|
443
476
|
nodeEvent.stopPropagation()
|
|
444
477
|
nodeEvent.nativeEvent.stopPropagation()
|
|
478
|
+
// The canvas-level `grid:click` listener is out of stopPropagation's
|
|
479
|
+
// reach — without this, the browser click that follows this
|
|
480
|
+
// pointerup-synthesized node click commits a second stair.
|
|
481
|
+
swallowFollowUpBrowserClick()
|
|
445
482
|
}
|
|
446
483
|
|
|
447
|
-
const position =
|
|
448
|
-
? lastCanonicalPositionRef.current
|
|
449
|
-
: getAlignedGridPosition(event as GridEvent)
|
|
484
|
+
const position = resolveStairPosition(event)
|
|
450
485
|
if (!position) return
|
|
451
486
|
|
|
452
|
-
commitStairPlacement(currentLevelId, position, rotationRef.current)
|
|
487
|
+
commitStairPlacement(currentLevelId, position, rotationRef.current, supportCapRef.current)
|
|
453
488
|
openingPreview.clear()
|
|
454
489
|
// Commit cleared the opening preview, so force the next hover (even on the
|
|
455
490
|
// same cell) to rebuild rather than dedupe against the just-placed key.
|
|
@@ -462,8 +497,14 @@ export const StairTool: React.FC = () => {
|
|
|
462
497
|
if (useEditor.getState().getContinuation('point') === 'repeat') {
|
|
463
498
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
|
464
499
|
} else {
|
|
500
|
+
commitGate.markExited()
|
|
465
501
|
useFacingPose.getState().clear()
|
|
466
502
|
useEditor.getState().setTool(null)
|
|
503
|
+
// Return to select mode explicitly (matches the spawn tool's exit).
|
|
504
|
+
// The selection managers route node clicks only while
|
|
505
|
+
// `mode === 'select'`; exiting with `mode: 'build'` + a null tool
|
|
506
|
+
// left every click dead until the user pressed Escape.
|
|
507
|
+
useEditor.getState().setMode('select')
|
|
467
508
|
}
|
|
468
509
|
}
|
|
469
510
|
|
|
@@ -482,34 +523,44 @@ export const StairTool: React.FC = () => {
|
|
|
482
523
|
sfxEmitter.emit('sfx:item-rotate')
|
|
483
524
|
rotationRef.current += rotationDelta
|
|
484
525
|
if (lastCanonicalPositionRef.current) {
|
|
485
|
-
applyDraftPreview(
|
|
526
|
+
applyDraftPreview(
|
|
527
|
+
lastCanonicalPositionRef.current,
|
|
528
|
+
rotationRef.current,
|
|
529
|
+
supportCapRef.current,
|
|
530
|
+
)
|
|
486
531
|
} else if (previewRef.current) {
|
|
487
532
|
previewRef.current.rotation.y = rotationRef.current
|
|
488
533
|
}
|
|
489
534
|
}
|
|
490
535
|
}
|
|
491
536
|
|
|
492
|
-
emitter.on('grid:move',
|
|
537
|
+
emitter.on('grid:move', onPointerMove)
|
|
493
538
|
emitter.on('grid:click', commitAtCursor)
|
|
494
539
|
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
|
|
495
540
|
type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
|
|
541
|
+
type MoveKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]>
|
|
496
542
|
for (const kind of CLICK_TRIGGER_KINDS) {
|
|
497
543
|
const key = `${kind}:click` as ClickKey
|
|
498
544
|
emitter.on(key, commitAtCursor as never)
|
|
545
|
+
const moveKey = `${kind}:move` as MoveKey
|
|
546
|
+
emitter.on(moveKey, onPointerMove as never)
|
|
499
547
|
}
|
|
500
548
|
window.addEventListener('keydown', onKeyDown)
|
|
501
549
|
|
|
502
550
|
return () => {
|
|
503
|
-
emitter.off('grid:move',
|
|
551
|
+
emitter.off('grid:move', onPointerMove)
|
|
504
552
|
emitter.off('grid:click', commitAtCursor)
|
|
505
553
|
for (const kind of CLICK_TRIGGER_KINDS) {
|
|
506
554
|
const key = `${kind}:click` as ClickKey
|
|
507
555
|
emitter.off(key, commitAtCursor as never)
|
|
556
|
+
const moveKey = `${kind}:move` as MoveKey
|
|
557
|
+
emitter.off(moveKey, onPointerMove as never)
|
|
508
558
|
}
|
|
509
559
|
window.removeEventListener('keydown', onKeyDown)
|
|
510
560
|
useAlignmentGuides.getState().clear()
|
|
511
561
|
openingPreview.clear()
|
|
512
562
|
useFacingPose.getState().clear()
|
|
563
|
+
useStairBuildPreview.getState().reset()
|
|
513
564
|
}
|
|
514
565
|
}, [currentLevelId])
|
|
515
566
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AnyNodeDefinition,
|
|
2
3
|
type AnyNodeId,
|
|
3
4
|
type BuildingNode,
|
|
4
5
|
type CeilingNode,
|
|
@@ -10,17 +11,21 @@ import {
|
|
|
10
11
|
} from '@pascal-app/core'
|
|
11
12
|
import { useViewer } from '@pascal-app/viewer'
|
|
12
13
|
import { type ComponentType, lazy, Suspense, useMemo } from 'react'
|
|
14
|
+
import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
|
|
13
15
|
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
|
14
16
|
import {
|
|
15
17
|
useControlPointReshape,
|
|
16
18
|
useEditingHole,
|
|
17
19
|
useEndpointReshape,
|
|
18
20
|
useIsCurveReshape,
|
|
21
|
+
useIsFloorplanDrivenReshape,
|
|
22
|
+
useIsToolDrivenReshape,
|
|
19
23
|
useMovingNode,
|
|
20
24
|
useReshapingNode,
|
|
21
25
|
useTangentReshape,
|
|
22
26
|
} from '../../store/use-interaction-scope'
|
|
23
27
|
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
|
|
28
|
+
import { Elevation3DGuideLayer } from '../editor/elevation-3d-guide-layer'
|
|
24
29
|
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
|
|
25
30
|
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
|
|
26
31
|
import { ElevatorTool } from './elevator/elevator-tool'
|
|
@@ -29,6 +34,7 @@ import { RoofTool } from './roof/roof-tool'
|
|
|
29
34
|
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
|
|
30
35
|
import { FacingPoseIndicator } from './shared/facing-pose-indicator'
|
|
31
36
|
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
|
37
|
+
import { TerrainSculptTool } from './site/terrain-sculpt-tool'
|
|
32
38
|
import { StairTool } from './stair/stair-tool'
|
|
33
39
|
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
|
34
40
|
import { ZoneTool } from './zone/zone-tool'
|
|
@@ -36,6 +42,29 @@ import { ZoneTool } from './zone/zone-tool'
|
|
|
36
42
|
// Cache lazy tool components keyed by their loader so React.lazy isn't
|
|
37
43
|
// re-invoked across renders.
|
|
38
44
|
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
|
|
45
|
+
const registryToolPreloadCache = new WeakMap<AnyNodeDefinition, Promise<void>>()
|
|
46
|
+
|
|
47
|
+
export function preloadRegistryToolModules(tool: string | null): Promise<void> {
|
|
48
|
+
if (!tool) return Promise.resolve()
|
|
49
|
+
const def = nodeRegistry.get(tool)
|
|
50
|
+
if (!def) return Promise.resolve()
|
|
51
|
+
const cached = registryToolPreloadCache.get(def)
|
|
52
|
+
if (cached) return cached
|
|
53
|
+
|
|
54
|
+
const loaders: Array<() => Promise<unknown>> = []
|
|
55
|
+
if (def.tool) loaders.push(def.tool)
|
|
56
|
+
if (def.preview) loaders.push(def.preview)
|
|
57
|
+
if (def.renderer?.kind === 'parametric') loaders.push(def.renderer.module)
|
|
58
|
+
if (def.system) loaders.push(def.system.module)
|
|
59
|
+
if (def.parametrics?.customPanel) loaders.push(def.parametrics.customPanel)
|
|
60
|
+
if (def.parametrics?.trailingSection) loaders.push(def.parametrics.trailingSection)
|
|
61
|
+
const moveTool = def.affordanceTools?.move
|
|
62
|
+
if (moveTool) loaders.push(moveTool)
|
|
63
|
+
|
|
64
|
+
const preload = Promise.allSettled(loaders.map((loader) => loader())).then(() => undefined)
|
|
65
|
+
registryToolPreloadCache.set(def, preload)
|
|
66
|
+
return preload
|
|
67
|
+
}
|
|
39
68
|
|
|
40
69
|
function getRegistryTool(tool: Tool | null): ComponentType | null {
|
|
41
70
|
if (!tool) return null
|
|
@@ -43,7 +72,13 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
|
|
|
43
72
|
if (!def?.tool) return null
|
|
44
73
|
const cached = lazyToolCache.get(def.tool)
|
|
45
74
|
if (cached) return cached
|
|
46
|
-
const Comp = lazy(
|
|
75
|
+
const Comp = lazy(async () => {
|
|
76
|
+
// Placement can only begin once the node's preview, committed renderer,
|
|
77
|
+
// inspector, and move contribution are warm. This keeps the click itself
|
|
78
|
+
// synchronous even under Next.js dev-time on-demand compilation.
|
|
79
|
+
await preloadRegistryToolModules(tool)
|
|
80
|
+
return def.tool!() as Promise<{ default: ComponentType }>
|
|
81
|
+
})
|
|
47
82
|
lazyToolCache.set(def.tool, Comp)
|
|
48
83
|
return Comp
|
|
49
84
|
}
|
|
@@ -73,6 +108,8 @@ export const ToolManager: React.FC = () => {
|
|
|
73
108
|
const controlPointReshape = useControlPointReshape()
|
|
74
109
|
const tangentReshape = useTangentReshape()
|
|
75
110
|
const isCurveReshape = useIsCurveReshape()
|
|
111
|
+
const isToolDrivenReshape = useIsToolDrivenReshape()
|
|
112
|
+
const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape()
|
|
76
113
|
const reshapingNode = useReshapingNode()
|
|
77
114
|
// The endpoint affordance tool's `target` is kind-specific
|
|
78
115
|
// (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
|
|
@@ -128,9 +165,14 @@ export const ToolManager: React.FC = () => {
|
|
|
128
165
|
| CeilingNode['id']
|
|
129
166
|
| undefined
|
|
130
167
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
168
|
+
// Site boundary handles normally share one 2D/3D rule. Sculpt is the deliberate
|
|
169
|
+
// 3D exception: the brush only owns this canvas, where PolygonEditor can hand
|
|
170
|
+
// off its pointer before a boundary drag starts.
|
|
171
|
+
const sculpting = mode === 'terrain-sculpt'
|
|
172
|
+
// Sculpt keeps the 3D property controls visible. PolygonEditor marks its
|
|
173
|
+
// pointer before the canvas-level brush listener runs, and activating one
|
|
174
|
+
// exits sculpt mode before starting the boundary drag.
|
|
175
|
+
const showSiteBoundaryEditor = sculpting || siteBoundaryHandlesEnabled({ mode, phase })
|
|
134
176
|
|
|
135
177
|
// A multi-selection is manipulated as one rigid group (drag / R / T), so
|
|
136
178
|
// per-node reshape chrome — the slab / ceiling boundary editors' vertex and
|
|
@@ -143,6 +185,7 @@ export const ToolManager: React.FC = () => {
|
|
|
143
185
|
mode === 'select' &&
|
|
144
186
|
isSoleSelection &&
|
|
145
187
|
selectedSlabId !== undefined &&
|
|
188
|
+
!isFloorplanDrivenReshape &&
|
|
146
189
|
!editingSlabHoleIsManual
|
|
147
190
|
|
|
148
191
|
// Show slab hole editor when editing a hole on the selected slab
|
|
@@ -150,6 +193,7 @@ export const ToolManager: React.FC = () => {
|
|
|
150
193
|
selectedSlabId !== undefined &&
|
|
151
194
|
editingHole !== null &&
|
|
152
195
|
editingHole.nodeId === selectedSlabId &&
|
|
196
|
+
!isFloorplanDrivenReshape &&
|
|
153
197
|
editingSlabHoleIsManual
|
|
154
198
|
|
|
155
199
|
// Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
|
|
@@ -158,13 +202,15 @@ export const ToolManager: React.FC = () => {
|
|
|
158
202
|
mode === 'select' &&
|
|
159
203
|
isSoleSelection &&
|
|
160
204
|
selectedCeilingId !== undefined &&
|
|
205
|
+
!isFloorplanDrivenReshape &&
|
|
161
206
|
(!editingHole || editingHole.nodeId !== selectedCeilingId)
|
|
162
207
|
|
|
163
208
|
// Show ceiling hole editor when editing a hole on the selected ceiling
|
|
164
209
|
const showCeilingHoleEditor =
|
|
165
210
|
selectedCeilingId !== undefined &&
|
|
166
211
|
editingHole !== null &&
|
|
167
|
-
editingHole.nodeId === selectedCeilingId
|
|
212
|
+
editingHole.nodeId === selectedCeilingId &&
|
|
213
|
+
!isFloorplanDrivenReshape
|
|
168
214
|
|
|
169
215
|
// Show zone boundary editor when in structure/select mode with a zone selected
|
|
170
216
|
// Hide when editing a slab or ceiling to avoid overlapping handles
|
|
@@ -172,6 +218,7 @@ export const ToolManager: React.FC = () => {
|
|
|
172
218
|
phase === 'structure' &&
|
|
173
219
|
mode === 'select' &&
|
|
174
220
|
selectedZoneId !== null &&
|
|
221
|
+
!isFloorplanDrivenReshape &&
|
|
175
222
|
!showSlabBoundaryEditor &&
|
|
176
223
|
!showCeilingBoundaryEditor
|
|
177
224
|
|
|
@@ -217,6 +264,10 @@ export const ToolManager: React.FC = () => {
|
|
|
217
264
|
<>
|
|
218
265
|
{/* World-space tools: site boundary and building movement operate in world coordinates */}
|
|
219
266
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
|
267
|
+
{/* Terrain sculpting is a mode rather than a `tools[phase][tool]` entry —
|
|
268
|
+
it places no node — so it gets its own gate here. World-space, because
|
|
269
|
+
the ground is not building-local. */}
|
|
270
|
+
{sculpting && <TerrainSculptTool />}
|
|
220
271
|
{showMover && movingNode?.type === 'building' && (
|
|
221
272
|
<MoveTool onNodeMoved={handlePlacedNodeSelected} onSpawnMoved={handlePlacedNodeSelected} />
|
|
222
273
|
)}
|
|
@@ -271,7 +322,8 @@ export const ToolManager: React.FC = () => {
|
|
|
271
322
|
</Suspense>
|
|
272
323
|
) : null
|
|
273
324
|
})()}
|
|
274
|
-
{
|
|
325
|
+
{isToolDrivenReshape &&
|
|
326
|
+
endpointTarget &&
|
|
275
327
|
reshapingNode &&
|
|
276
328
|
(() => {
|
|
277
329
|
const RegistryAffordance = getRegistryAffordanceTool(
|
|
@@ -284,7 +336,8 @@ export const ToolManager: React.FC = () => {
|
|
|
284
336
|
</Suspense>
|
|
285
337
|
) : null
|
|
286
338
|
})()}
|
|
287
|
-
{
|
|
339
|
+
{isToolDrivenReshape &&
|
|
340
|
+
isCurveReshape &&
|
|
288
341
|
reshapingNode &&
|
|
289
342
|
(() => {
|
|
290
343
|
const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
|
|
@@ -294,7 +347,8 @@ export const ToolManager: React.FC = () => {
|
|
|
294
347
|
</Suspense>
|
|
295
348
|
) : null
|
|
296
349
|
})()}
|
|
297
|
-
{
|
|
350
|
+
{isToolDrivenReshape &&
|
|
351
|
+
controlPointTarget &&
|
|
298
352
|
(() => {
|
|
299
353
|
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point')
|
|
300
354
|
return RegistryAffordance ? (
|
|
@@ -303,7 +357,8 @@ export const ToolManager: React.FC = () => {
|
|
|
303
357
|
</Suspense>
|
|
304
358
|
) : null
|
|
305
359
|
})()}
|
|
306
|
-
{
|
|
360
|
+
{isToolDrivenReshape &&
|
|
361
|
+
tangentTarget &&
|
|
307
362
|
(() => {
|
|
308
363
|
const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent')
|
|
309
364
|
return RegistryAffordance ? (
|
|
@@ -344,6 +399,9 @@ export const ToolManager: React.FC = () => {
|
|
|
344
399
|
{/* Wall-plane proximity / sill / equal-spacing guides for openings,
|
|
345
400
|
published by the door/window move tools in the same world frame. */}
|
|
346
401
|
<OpeningGuides3DLayer />
|
|
402
|
+
{/* Structural Y-datum feedback for slab, ceiling, wall, and fence
|
|
403
|
+
elevation handles. Ephemeral editor chrome; never scene data. */}
|
|
404
|
+
<Elevation3DGuideLayer />
|
|
347
405
|
{/* "Magnetic" beacon at the active wall-draft snap point. */}
|
|
348
406
|
<WallSnapBeaconLayer />
|
|
349
407
|
</group>
|
|
@@ -2,8 +2,14 @@ import { beforeEach, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeId,
|
|
5
|
+
applyHeightPatch,
|
|
6
|
+
createTerrainField,
|
|
5
7
|
DoorNode as DoorSchema,
|
|
8
|
+
encodeTerrainField,
|
|
9
|
+
flattenPatch,
|
|
10
|
+
GROUND_SUPPORT_ID,
|
|
6
11
|
runAsSingleSceneHistoryStep,
|
|
12
|
+
spatialGridManager,
|
|
7
13
|
useScene,
|
|
8
14
|
type WallNode,
|
|
9
15
|
WallNode as WallSchema,
|
|
@@ -14,6 +20,7 @@ import useInteractionScope from '../../../store/use-interaction-scope'
|
|
|
14
20
|
import {
|
|
15
21
|
createWallOnCurrentLevel,
|
|
16
22
|
resolveEndpointWallSplit,
|
|
23
|
+
resolveTerrainWallConstructionOptions,
|
|
17
24
|
snapWallDraftPointDetailed,
|
|
18
25
|
} from './wall-drafting'
|
|
19
26
|
import type { WallPlanPoint } from './wall-snap-geometry'
|
|
@@ -86,7 +93,7 @@ describe('createWallOnCurrentLevel', () => {
|
|
|
86
93
|
useEditor.getState().setSnappingMode('wall', 'lines')
|
|
87
94
|
useInteractionScope
|
|
88
95
|
.getState()
|
|
89
|
-
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' })
|
|
96
|
+
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint', driver: 'tool' })
|
|
90
97
|
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
|
|
91
98
|
useScene.temporal.getState().clear()
|
|
92
99
|
useScene.temporal.getState().resume()
|
|
@@ -102,6 +109,83 @@ describe('createWallOnCurrentLevel', () => {
|
|
|
102
109
|
expect(levelWalls()).toHaveLength(2)
|
|
103
110
|
})
|
|
104
111
|
|
|
112
|
+
test('committed wall preserves the ghost construction elevation on ground', () => {
|
|
113
|
+
const created = createWallOnCurrentLevel([2, 2], [3, 2], {
|
|
114
|
+
supportCap: 1.75,
|
|
115
|
+
preferredSupportSlabId: GROUND_SUPPORT_ID,
|
|
116
|
+
constructionElevation: 1.75,
|
|
117
|
+
constructionHeight: 2.5,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(created?.supportSlabId).toBe(GROUND_SUPPORT_ID)
|
|
121
|
+
expect(created?.supportOffset).toBe(1.75)
|
|
122
|
+
expect(created?.height).toBe(2.5)
|
|
123
|
+
const support = spatialGridManager.getSlabSupportForWall(
|
|
124
|
+
LEVEL_ID,
|
|
125
|
+
created?.start ?? [0, 0],
|
|
126
|
+
created?.end ?? [0, 0],
|
|
127
|
+
created?.curveOffset,
|
|
128
|
+
created?.thickness,
|
|
129
|
+
created?.supportSlabId,
|
|
130
|
+
undefined,
|
|
131
|
+
created?.supportOffset,
|
|
132
|
+
)
|
|
133
|
+
expect(support.elevation).toBe(1.75)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('2D terrain construction options freeze the first-point elevation and wall height', () => {
|
|
137
|
+
const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-2, -2] })
|
|
138
|
+
const patch = flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5)
|
|
139
|
+
if (!patch) throw new Error('Expected terrain patch')
|
|
140
|
+
const terrain = applyHeightPatch(field, patch)
|
|
141
|
+
const site = {
|
|
142
|
+
id: 'site_test',
|
|
143
|
+
type: 'site',
|
|
144
|
+
object: 'node',
|
|
145
|
+
parentId: null,
|
|
146
|
+
visible: true,
|
|
147
|
+
metadata: {},
|
|
148
|
+
children: ['building_test'],
|
|
149
|
+
terrain: encodeTerrainField(terrain),
|
|
150
|
+
} as unknown as AnyNode
|
|
151
|
+
const building = {
|
|
152
|
+
id: 'building_test',
|
|
153
|
+
type: 'building',
|
|
154
|
+
object: 'node',
|
|
155
|
+
parentId: site.id,
|
|
156
|
+
visible: true,
|
|
157
|
+
metadata: {},
|
|
158
|
+
children: [LEVEL_ID],
|
|
159
|
+
position: [0, 0, 0],
|
|
160
|
+
rotation: [0, 0, 0],
|
|
161
|
+
} as AnyNode
|
|
162
|
+
const level = {
|
|
163
|
+
id: LEVEL_ID,
|
|
164
|
+
type: 'level',
|
|
165
|
+
object: 'node',
|
|
166
|
+
parentId: building.id,
|
|
167
|
+
visible: true,
|
|
168
|
+
metadata: {},
|
|
169
|
+
children: [],
|
|
170
|
+
level: 0,
|
|
171
|
+
height: 3,
|
|
172
|
+
} as AnyNode
|
|
173
|
+
const nodes = Object.fromEntries([site, building, level].map((node) => [node.id, node]))
|
|
174
|
+
|
|
175
|
+
expect(resolveTerrainWallConstructionOptions(nodes, LEVEL_ID, [0, 0])).toEqual({
|
|
176
|
+
constructionElevation: 1.5,
|
|
177
|
+
constructionHeight: 3,
|
|
178
|
+
supportCap: 1.5,
|
|
179
|
+
})
|
|
180
|
+
expect(
|
|
181
|
+
resolveTerrainWallConstructionOptions(nodes, LEVEL_ID, [0, 0], { height: 2.25 }),
|
|
182
|
+
).toEqual({
|
|
183
|
+
constructionElevation: 1.5,
|
|
184
|
+
constructionHeight: 2.25,
|
|
185
|
+
supportCap: 1.5,
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
105
189
|
test('endpoint near the host start corner snaps there without splitting', () => {
|
|
106
190
|
const created = createWallOnCurrentLevel([2, 2], [0.015, 0])
|
|
107
191
|
|
|
@@ -326,5 +410,38 @@ describe('snapWallDraftPointDetailed', () => {
|
|
|
326
410
|
|
|
327
411
|
expect(result.point).toEqual([3.99, 0.03])
|
|
328
412
|
expect(result.snap).toBeNull()
|
|
413
|
+
expect(result.targetWallIds).toEqual([])
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// Endpoint-move regression: walls attached to the moving corner keep their
|
|
417
|
+
// pre-drag coordinates in the scene during the drag, so their stale corner
|
|
418
|
+
// recreates the old junction inside the connect radius. The move tools must
|
|
419
|
+
// pass those walls in `ignoreWallIds` (attached mode) or a sub-5cm corner
|
|
420
|
+
// correction — e.g. squaring a scan-imported 91° junction — can never land.
|
|
421
|
+
test('a stale linked-wall corner swallows a sub-connect-radius correction unless ignored', () => {
|
|
422
|
+
// `wall_d` shares the dragged corner of `wall_c` at [2, 0.03]; the user
|
|
423
|
+
// drops 3cm away at [2, 0] to square the junction.
|
|
424
|
+
const linked = makeWall([2, 0.03], [2, 2], 'wall_d')
|
|
425
|
+
|
|
426
|
+
const captured = snapWallDraftPointDetailed({
|
|
427
|
+
point: [2, 0],
|
|
428
|
+
walls: [linked],
|
|
429
|
+
ignoreWallIds: ['wall_c'],
|
|
430
|
+
magnetic: false,
|
|
431
|
+
step: 0,
|
|
432
|
+
})
|
|
433
|
+
expect(captured.point).toEqual([2, 0.03])
|
|
434
|
+
expect(captured.snap).toBe('endpoint')
|
|
435
|
+
expect(captured.targetWallIds).toEqual(['wall_d'])
|
|
436
|
+
|
|
437
|
+
const freed = snapWallDraftPointDetailed({
|
|
438
|
+
point: [2, 0],
|
|
439
|
+
walls: [linked],
|
|
440
|
+
ignoreWallIds: ['wall_c', 'wall_d'],
|
|
441
|
+
magnetic: false,
|
|
442
|
+
step: 0,
|
|
443
|
+
})
|
|
444
|
+
expect(freed.point).toEqual([2, 0])
|
|
445
|
+
expect(freed.snap).toBeNull()
|
|
329
446
|
})
|
|
330
447
|
})
|