@pascal-app/editor 0.9.2 → 1.0.0-beta.2
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 +265 -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
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
type CeilingNode,
|
|
7
7
|
ColumnNode,
|
|
8
8
|
createSceneApi,
|
|
9
|
-
DEFAULT_WALL_HEIGHT,
|
|
10
9
|
DoorNode,
|
|
11
10
|
ElevatorNode,
|
|
12
11
|
emitter,
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
getActiveRoofHeight,
|
|
16
15
|
getEffectiveNode,
|
|
17
16
|
getWallCurveLength,
|
|
17
|
+
getWallEffectiveHeightForNodes,
|
|
18
18
|
getWallThickness,
|
|
19
19
|
ItemNode,
|
|
20
20
|
isCurvedWall,
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
runAsSingleSceneHistoryStep,
|
|
28
28
|
type SlabNode,
|
|
29
29
|
SpawnNode,
|
|
30
|
-
StairNode,
|
|
31
30
|
StairSegmentNode,
|
|
32
31
|
sceneRegistry,
|
|
33
32
|
summarizeSystemFor,
|
|
@@ -42,15 +41,20 @@ import { useFrame } from '@react-three/fiber'
|
|
|
42
41
|
import { useCallback, useMemo, useRef } from 'react'
|
|
43
42
|
import * as THREE from 'three'
|
|
44
43
|
import { useShallow } from 'zustand/react/shallow'
|
|
44
|
+
import { useReducedMotion } from '../../hooks/use-reduced-motion'
|
|
45
|
+
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
|
|
45
46
|
import {
|
|
46
47
|
createFreshPlacementSubtree,
|
|
47
48
|
duplicatesAsFreshSubtree,
|
|
49
|
+
prepareFreshPlacementRootDuplicate,
|
|
48
50
|
} from '../../lib/fresh-planar-placement'
|
|
49
51
|
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
|
50
52
|
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
|
53
|
+
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
|
54
|
+
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
|
51
55
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
|
52
56
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
|
53
|
-
import {
|
|
57
|
+
import { cn } from '../../lib/utils'
|
|
54
58
|
import useEditor from '../../store/use-editor'
|
|
55
59
|
import useInteractionScope, {
|
|
56
60
|
useActiveHandleDrag,
|
|
@@ -207,26 +211,9 @@ function collectQuickActionNodes(
|
|
|
207
211
|
): Record<AnyNodeId, AnyNode> | null {
|
|
208
212
|
if (!selectedId) return null
|
|
209
213
|
const selected = nodes[selectedId as AnyNodeId]
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const add = (id: string | null | undefined) => {
|
|
214
|
-
if (!id) return
|
|
215
|
-
const node = nodes[id as AnyNodeId]
|
|
216
|
-
if (node) collected[node.id as AnyNodeId] = node
|
|
217
|
-
}
|
|
218
|
-
const addChildren = (node: AnyNode | undefined) => {
|
|
219
|
-
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
|
|
220
|
-
add(childId)
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
add(selected.parentId ?? null)
|
|
225
|
-
addChildren(selected)
|
|
226
|
-
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
|
|
227
|
-
addChildren(parent)
|
|
228
|
-
|
|
229
|
-
return collected
|
|
214
|
+
const def = selected ? nodeRegistry.get(selected.type) : undefined
|
|
215
|
+
if (!def?.quickActions) return null
|
|
216
|
+
return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
|
|
230
217
|
}
|
|
231
218
|
|
|
232
219
|
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
|
|
@@ -283,7 +270,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
|
|
|
283
270
|
} {
|
|
284
271
|
if (node.type === 'wall') {
|
|
285
272
|
return {
|
|
286
|
-
height: node.
|
|
273
|
+
height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes),
|
|
287
274
|
length: getWallCurveLength(node),
|
|
288
275
|
thickness: getWallThickness(node),
|
|
289
276
|
}
|
|
@@ -296,6 +283,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
|
|
|
296
283
|
}
|
|
297
284
|
|
|
298
285
|
export function FloatingActionMenu() {
|
|
286
|
+
const reducedMotion = useReducedMotion()
|
|
299
287
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
300
288
|
const updateNode = useScene((s) => s.updateNode)
|
|
301
289
|
const mode = useEditor((s) => s.mode)
|
|
@@ -424,7 +412,10 @@ export function FloatingActionMenu() {
|
|
|
424
412
|
const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
|
|
425
413
|
| { height?: number }
|
|
426
414
|
| undefined
|
|
427
|
-
const fallbackHeight =
|
|
415
|
+
const fallbackHeight =
|
|
416
|
+
node.type === 'wall'
|
|
417
|
+
? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
|
|
418
|
+
: FENCE_DEFAULT_HEIGHT
|
|
428
419
|
const liveHeight = override?.height ?? node.height ?? fallbackHeight
|
|
429
420
|
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
|
|
430
421
|
}
|
|
@@ -514,7 +505,8 @@ export function FloatingActionMenu() {
|
|
|
514
505
|
e.stopPropagation()
|
|
515
506
|
if (!node) return
|
|
516
507
|
sfxEmitter.emit('sfx:item-pick')
|
|
517
|
-
|
|
508
|
+
const sceneNodes = useScene.getState().nodes
|
|
509
|
+
setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
|
|
518
510
|
setSelection({ selectedIds: [] })
|
|
519
511
|
},
|
|
520
512
|
[node, setMovingNode, setSelection],
|
|
@@ -537,20 +529,26 @@ export function FloatingActionMenu() {
|
|
|
537
529
|
useScene.temporal.getState().pause()
|
|
538
530
|
|
|
539
531
|
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
532
|
+
let draftId: AnyNodeId | null = null
|
|
533
|
+
try {
|
|
534
|
+
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
|
535
|
+
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
|
536
|
+
if (draft) {
|
|
537
|
+
setMovingNode(draft as any)
|
|
538
|
+
setSelection({ selectedIds: [] })
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (draftId && useScene.getState().nodes[draftId]) {
|
|
543
|
+
useScene.getState().deleteNode(draftId)
|
|
544
|
+
}
|
|
545
|
+
console.error('Failed to duplicate node subtree', error)
|
|
546
546
|
}
|
|
547
547
|
useScene.temporal.getState().resume()
|
|
548
548
|
return
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
-
|
|
552
|
-
delete duplicateInfo.id
|
|
553
|
-
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
551
|
+
const duplicateInfo = prepareFreshPlacementRootDuplicate(node as AnyNode) as any
|
|
554
552
|
|
|
555
553
|
let duplicate: AnyNode | null = null
|
|
556
554
|
try {
|
|
@@ -573,11 +571,6 @@ export function FloatingActionMenu() {
|
|
|
573
571
|
} else if (node.type === 'roof-segment') {
|
|
574
572
|
duplicateInfo.id = generateId('rseg')
|
|
575
573
|
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
|
576
|
-
} else if (node.type === 'stair') {
|
|
577
|
-
duplicateInfo.children = []
|
|
578
|
-
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
|
579
|
-
delete duplicateInfo.metadata?.isNew
|
|
580
|
-
duplicate = StairNode.parse(duplicateInfo)
|
|
581
574
|
} else if (node.type === 'stair-segment') {
|
|
582
575
|
duplicate = StairSegmentNode.parse(duplicateInfo)
|
|
583
576
|
} else if (node.type === 'spawn') {
|
|
@@ -615,11 +608,7 @@ export function FloatingActionMenu() {
|
|
|
615
608
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
616
609
|
} else if (duplicate.type === 'fence') {
|
|
617
610
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
618
|
-
} else if (
|
|
619
|
-
duplicate.type === 'roof-segment' ||
|
|
620
|
-
duplicate.type === 'stair' ||
|
|
621
|
-
duplicate.type === 'stair-segment'
|
|
622
|
-
) {
|
|
611
|
+
} else if (duplicate.type === 'roof-segment' || duplicate.type === 'stair-segment') {
|
|
623
612
|
// Add small offset to make it visible
|
|
624
613
|
if ('position' in duplicate) {
|
|
625
614
|
duplicate.position = [
|
|
@@ -628,13 +617,7 @@ export function FloatingActionMenu() {
|
|
|
628
617
|
duplicate.position[2] + 1,
|
|
629
618
|
]
|
|
630
619
|
}
|
|
631
|
-
|
|
632
|
-
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
|
633
|
-
} else {
|
|
634
|
-
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Duplicate children for stair nodes
|
|
620
|
+
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
638
621
|
} else if (
|
|
639
622
|
duplicate.type === 'item' ||
|
|
640
623
|
duplicate.type === 'chimney' ||
|
|
@@ -703,12 +686,8 @@ export function FloatingActionMenu() {
|
|
|
703
686
|
nodeRegistry.has(duplicate.type)
|
|
704
687
|
) {
|
|
705
688
|
setMovingNode(duplicate as any)
|
|
706
|
-
} else if (duplicate.type === 'stair') {
|
|
707
|
-
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
|
708
|
-
}
|
|
709
|
-
if (duplicate.type !== 'stair') {
|
|
710
|
-
setSelection({ selectedIds: [] })
|
|
711
689
|
}
|
|
690
|
+
setSelection({ selectedIds: [] })
|
|
712
691
|
}
|
|
713
692
|
},
|
|
714
693
|
[node, setMovingNode, setSelection],
|
|
@@ -776,9 +755,15 @@ export function FloatingActionMenu() {
|
|
|
776
755
|
)
|
|
777
756
|
|
|
778
757
|
const handleQuickAction = useCallback(
|
|
779
|
-
(action: NodeQuickAction) => (e: React.MouseEvent) => {
|
|
758
|
+
(action: NodeQuickAction) => (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
780
759
|
e.stopPropagation()
|
|
781
|
-
if (!node
|
|
760
|
+
if (!node) return
|
|
761
|
+
if (action.disabled) {
|
|
762
|
+
if (action.blockedFeedback) {
|
|
763
|
+
playBlockedQuickActionFeedback(e.currentTarget, reducedMotion)
|
|
764
|
+
}
|
|
765
|
+
return
|
|
766
|
+
}
|
|
782
767
|
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
|
|
783
768
|
const result =
|
|
784
769
|
action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
|
|
@@ -788,7 +773,7 @@ export function FloatingActionMenu() {
|
|
|
788
773
|
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
|
|
789
774
|
}
|
|
790
775
|
},
|
|
791
|
-
[node, setSelection],
|
|
776
|
+
[node, reducedMotion, setSelection],
|
|
792
777
|
)
|
|
793
778
|
|
|
794
779
|
if (
|
|
@@ -851,18 +836,27 @@ export function FloatingActionMenu() {
|
|
|
851
836
|
>
|
|
852
837
|
{quickActions.map((action) => (
|
|
853
838
|
<button
|
|
839
|
+
aria-disabled={action.disabled || undefined}
|
|
854
840
|
aria-label={action.title ?? action.label}
|
|
855
|
-
className=
|
|
856
|
-
|
|
841
|
+
className={cn(
|
|
842
|
+
'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
|
|
843
|
+
action.disabled &&
|
|
844
|
+
'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
|
845
|
+
)}
|
|
846
|
+
disabled={action.disabled && !action.blockedFeedback}
|
|
857
847
|
key={action.id}
|
|
858
848
|
onClick={handleQuickAction(action)}
|
|
859
849
|
title={action.title ?? action.label}
|
|
860
850
|
type="button"
|
|
861
851
|
>
|
|
862
|
-
<span className="flex
|
|
863
|
-
<
|
|
852
|
+
<span className="flex items-center gap-1.5" data-quick-action-feedback>
|
|
853
|
+
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
|
|
854
|
+
<QuickActionIcon action={action} />
|
|
855
|
+
</span>
|
|
856
|
+
<span className="whitespace-nowrap leading-none" data-quick-action-label>
|
|
857
|
+
{action.label}
|
|
858
|
+
</span>
|
|
864
859
|
</span>
|
|
865
|
-
<span className="whitespace-nowrap leading-none">{action.label}</span>
|
|
866
860
|
</button>
|
|
867
861
|
))}
|
|
868
862
|
</div>
|
|
@@ -10,8 +10,6 @@ const baseArgs = {
|
|
|
10
10
|
modifierKeys: { meta: false, ctrl: false, shift: false },
|
|
11
11
|
planPoint: [0, 0] as [number, number],
|
|
12
12
|
structureLayer: 'elements',
|
|
13
|
-
toPoint2D: ([x, y]: [number, number]) => ({ x, y }),
|
|
14
|
-
visibleZonePolygons: [],
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
describe('resolveFloorplanBackgroundSelection', () => {
|
|
@@ -55,4 +53,20 @@ describe('resolveFloorplanBackgroundSelection', () => {
|
|
|
55
53
|
preserveSelection: true,
|
|
56
54
|
})
|
|
57
55
|
})
|
|
56
|
+
|
|
57
|
+
test('uses the registry hit result for zone selection', () => {
|
|
58
|
+
const result = resolveFloorplanBackgroundSelection({
|
|
59
|
+
...baseArgs,
|
|
60
|
+
canSelectElementFloorplanGeometry: false,
|
|
61
|
+
canSelectFloorplanZones: true,
|
|
62
|
+
getFloorplanHitIdAtPoint: () => 'zone_1',
|
|
63
|
+
structureLayer: 'zones',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
handled: true,
|
|
68
|
+
kind: 'select-zone',
|
|
69
|
+
zoneId: 'zone_1',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
58
72
|
})
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import { isPointInsidePolygon } from '../../lib/floorplan'
|
|
3
|
+
import type { ZoneNode as ZoneNodeType } from '@pascal-app/core'
|
|
5
4
|
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
|
6
5
|
|
|
7
6
|
type ModifierKeys = {
|
|
@@ -10,13 +9,6 @@ type ModifierKeys = {
|
|
|
10
9
|
shift: boolean
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
type ZoneHitEntry = {
|
|
14
|
-
zone: {
|
|
15
|
-
id: ZoneNodeType['id']
|
|
16
|
-
}
|
|
17
|
-
polygon: Point2D[]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
12
|
type ResolveFloorplanBackgroundSelectionArgs = {
|
|
21
13
|
canSelectElementFloorplanGeometry: boolean
|
|
22
14
|
canSelectFloorplanZones: boolean
|
|
@@ -26,8 +18,6 @@ type ResolveFloorplanBackgroundSelectionArgs = {
|
|
|
26
18
|
modifierKeys: ModifierKeys
|
|
27
19
|
planPoint: WallPlanPoint
|
|
28
20
|
structureLayer: string
|
|
29
|
-
toPoint2D: (point: WallPlanPoint) => Point2D
|
|
30
|
-
visibleZonePolygons: ZoneHitEntry[]
|
|
31
21
|
}
|
|
32
22
|
|
|
33
23
|
export type FloorplanBackgroundSelectionResult =
|
|
@@ -63,18 +53,14 @@ export function resolveFloorplanBackgroundSelection({
|
|
|
63
53
|
modifierKeys,
|
|
64
54
|
planPoint,
|
|
65
55
|
structureLayer,
|
|
66
|
-
toPoint2D,
|
|
67
|
-
visibleZonePolygons,
|
|
68
56
|
}: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
|
|
69
57
|
if (canSelectFloorplanZones) {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
if (zoneHit) {
|
|
58
|
+
const zoneId = getFloorplanHitIdAtPoint(planPoint)
|
|
59
|
+
if (zoneId) {
|
|
74
60
|
return {
|
|
75
61
|
handled: true,
|
|
76
62
|
kind: 'select-zone',
|
|
77
|
-
zoneId:
|
|
63
|
+
zoneId: zoneId as ZoneNodeType['id'],
|
|
78
64
|
}
|
|
79
65
|
}
|
|
80
66
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { CameraPose } from '@pascal-app/core'
|
|
3
|
+
import { publishInitialCameraPose } from '../../lib/camera-pose'
|
|
4
|
+
import type { NavigationSyncPose } from '../../store/use-editor'
|
|
5
|
+
import {
|
|
6
|
+
cameraPoseToFloorplanNavigationPose,
|
|
7
|
+
createFloorplanCameraNavigationChannel,
|
|
8
|
+
createFloorplanCameraSyncBridge,
|
|
9
|
+
floorplanNavigationPoseToCameraPose,
|
|
10
|
+
} from './floorplan-camera-sync'
|
|
11
|
+
|
|
12
|
+
const cameraPose: CameraPose = {
|
|
13
|
+
fov: 50,
|
|
14
|
+
position: [3, 4, 4],
|
|
15
|
+
projection: 'perspective',
|
|
16
|
+
target: [0, 1, 0],
|
|
17
|
+
viewWidth: 12,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cameraPoseAtAzimuth(
|
|
21
|
+
azimuth: number,
|
|
22
|
+
target: [number, number, number] = [0, 1, 0],
|
|
23
|
+
): CameraPose {
|
|
24
|
+
const horizontalDistance = 5
|
|
25
|
+
return {
|
|
26
|
+
...cameraPose,
|
|
27
|
+
position: [
|
|
28
|
+
target[0] + Math.sin(azimuth) * horizontalDistance,
|
|
29
|
+
4,
|
|
30
|
+
target[2] + Math.cos(azimuth) * horizontalDistance,
|
|
31
|
+
],
|
|
32
|
+
target,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('floorplan camera sync', () => {
|
|
37
|
+
test('derives the floor-plan navigation pose from the generic camera pose', () => {
|
|
38
|
+
expect(
|
|
39
|
+
cameraPoseToFloorplanNavigationPose({
|
|
40
|
+
position: [10, 5, 0],
|
|
41
|
+
projection: 'perspective',
|
|
42
|
+
target: [0, 0, 0],
|
|
43
|
+
viewWidth: 20,
|
|
44
|
+
}),
|
|
45
|
+
).toEqual({
|
|
46
|
+
source: '3d',
|
|
47
|
+
target: [0, 0, 0],
|
|
48
|
+
azimuth: Math.PI / 2,
|
|
49
|
+
viewWidth: 20,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('applies a floor-plan pose while preserving the generic camera elevation and projection', () => {
|
|
54
|
+
expect(
|
|
55
|
+
floorplanNavigationPoseToCameraPose(
|
|
56
|
+
{
|
|
57
|
+
source: '2d',
|
|
58
|
+
revision: 4,
|
|
59
|
+
target: [10, 2, 20],
|
|
60
|
+
azimuth: Math.PI / 2,
|
|
61
|
+
viewWidth: 8,
|
|
62
|
+
},
|
|
63
|
+
cameraPose,
|
|
64
|
+
),
|
|
65
|
+
).toEqual({
|
|
66
|
+
fov: 50,
|
|
67
|
+
position: [15, 5, 20],
|
|
68
|
+
projection: 'perspective',
|
|
69
|
+
target: [10, 2, 20],
|
|
70
|
+
viewWidth: 8,
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('owns two-way synchronization without echoing tiny camera changes', () => {
|
|
75
|
+
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
|
76
|
+
const applied: CameraPose[] = []
|
|
77
|
+
const bridge = createFloorplanCameraSyncBridge({
|
|
78
|
+
applyCameraPose: (pose) => applied.push(pose),
|
|
79
|
+
publishNavigationPose: (pose) => published.push(pose),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
bridge.receiveCameraPose(cameraPose)
|
|
83
|
+
bridge.receiveCameraPose({
|
|
84
|
+
...cameraPose,
|
|
85
|
+
position: [3.0001, 4, 4],
|
|
86
|
+
})
|
|
87
|
+
bridge.receiveNavigationPose({
|
|
88
|
+
source: '2d',
|
|
89
|
+
revision: 1,
|
|
90
|
+
target: [10, 2, 20],
|
|
91
|
+
azimuth: Math.PI / 2,
|
|
92
|
+
viewWidth: 8,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(published).toHaveLength(1)
|
|
96
|
+
expect(published[0]?.source).toBe('3d')
|
|
97
|
+
expect(applied).toHaveLength(1)
|
|
98
|
+
expect(applied[0]).toMatchObject({
|
|
99
|
+
fov: 50,
|
|
100
|
+
projection: 'perspective',
|
|
101
|
+
target: [10, 2, 20],
|
|
102
|
+
viewWidth: 8,
|
|
103
|
+
})
|
|
104
|
+
expect(applied[0]?.position[0]).toBeCloseTo(15)
|
|
105
|
+
expect(applied[0]?.position[1]).toBeCloseTo(5)
|
|
106
|
+
expect(applied[0]?.position[2]).toBeCloseTo(20)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('publishes sub-degree camera rotation for a real-time compass', () => {
|
|
110
|
+
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
|
111
|
+
const bridge = createFloorplanCameraSyncBridge({
|
|
112
|
+
applyCameraPose: () => {},
|
|
113
|
+
publishNavigationPose: (pose) => published.push(pose),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
|
117
|
+
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
|
|
118
|
+
expect(published).toHaveLength(2)
|
|
119
|
+
expect(published[1]?.azimuth).toBeCloseTo((0.9 * Math.PI) / 180)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('keeps a sub-degree camera rotation when it arrives with a pan', () => {
|
|
123
|
+
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
|
124
|
+
const bridge = createFloorplanCameraSyncBridge({
|
|
125
|
+
applyCameraPose: () => {},
|
|
126
|
+
publishNavigationPose: (pose) => published.push(pose),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
|
130
|
+
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.5 * Math.PI) / 180, [1, 1, 0]))
|
|
131
|
+
|
|
132
|
+
expect(published).toHaveLength(2)
|
|
133
|
+
expect(published[1]).toMatchObject({
|
|
134
|
+
target: [1, 1, 0],
|
|
135
|
+
})
|
|
136
|
+
expect(published[1]?.azimuth).toBeCloseTo((0.5 * Math.PI) / 180)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('keeps streaming live camera headings in 3D-only mode', () => {
|
|
140
|
+
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
|
|
141
|
+
const applied: CameraPose[] = []
|
|
142
|
+
const bridge = createFloorplanCameraSyncBridge({
|
|
143
|
+
active: false,
|
|
144
|
+
applyCameraPose: (pose) => applied.push(pose),
|
|
145
|
+
publishNavigationPose: (pose) => published.push(pose),
|
|
146
|
+
})
|
|
147
|
+
const latestPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
|
|
148
|
+
|
|
149
|
+
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
|
|
150
|
+
bridge.receiveCameraPose(latestPose)
|
|
151
|
+
bridge.receiveNavigationPose({
|
|
152
|
+
source: '2d',
|
|
153
|
+
revision: 1,
|
|
154
|
+
target: [10, 2, 20],
|
|
155
|
+
azimuth: Math.PI / 2,
|
|
156
|
+
viewWidth: 8,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(published).toEqual([
|
|
160
|
+
{
|
|
161
|
+
source: '3d',
|
|
162
|
+
target: cameraPose.target,
|
|
163
|
+
azimuth: 0,
|
|
164
|
+
viewWidth: 12,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
source: '3d',
|
|
168
|
+
target: latestPose.target,
|
|
169
|
+
azimuth: Math.PI / 2,
|
|
170
|
+
viewWidth: 12,
|
|
171
|
+
},
|
|
172
|
+
])
|
|
173
|
+
expect(applied).toEqual([])
|
|
174
|
+
|
|
175
|
+
bridge.setActive(true)
|
|
176
|
+
expect(published).toHaveLength(2)
|
|
177
|
+
expect(applied).toEqual([])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('applies the north-alignment pose used by the compass in 3D view', () => {
|
|
181
|
+
const applied: CameraPose[] = []
|
|
182
|
+
const bridge = createFloorplanCameraSyncBridge({
|
|
183
|
+
applyCameraPose: (pose) => applied.push(pose),
|
|
184
|
+
publishNavigationPose: () => {},
|
|
185
|
+
})
|
|
186
|
+
const currentPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
|
|
187
|
+
|
|
188
|
+
bridge.receiveCameraPose(currentPose)
|
|
189
|
+
bridge.receiveNavigationPose({
|
|
190
|
+
source: '2d',
|
|
191
|
+
revision: 1,
|
|
192
|
+
target: [...currentPose.target],
|
|
193
|
+
azimuth: 0,
|
|
194
|
+
viewWidth: currentPose.viewWidth!,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
expect(applied).toHaveLength(1)
|
|
198
|
+
expect(applied[0]).toMatchObject({
|
|
199
|
+
target: currentPose.target,
|
|
200
|
+
projection: currentPose.projection,
|
|
201
|
+
viewWidth: currentPose.viewWidth,
|
|
202
|
+
})
|
|
203
|
+
expect(applied[0]?.position[0]).toBeCloseTo(currentPose.target[0])
|
|
204
|
+
expect(applied[0]?.position[1]).toBeCloseTo(currentPose.position[1])
|
|
205
|
+
expect(applied[0]?.position[2]).toBeCloseTo(currentPose.target[2] + 5)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('applies 2D-first navigation on initial load without a 3D interaction', () => {
|
|
209
|
+
const applied: CameraPose[] = []
|
|
210
|
+
const bridge = createFloorplanCameraSyncBridge({
|
|
211
|
+
applyCameraPose: (pose) => applied.push(pose),
|
|
212
|
+
publishNavigationPose: () => {},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
bridge.receiveNavigationPose({
|
|
216
|
+
source: '2d',
|
|
217
|
+
revision: 1,
|
|
218
|
+
target: [10, 2, 20],
|
|
219
|
+
azimuth: Math.PI / 2,
|
|
220
|
+
viewWidth: 8,
|
|
221
|
+
})
|
|
222
|
+
expect(applied).toEqual([])
|
|
223
|
+
|
|
224
|
+
publishInitialCameraPose(() => bridge.receiveCameraPose(cameraPose))
|
|
225
|
+
expect(applied).toHaveLength(1)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('delivers the live camera stream through transient subscribers', () => {
|
|
229
|
+
const channel = createFloorplanCameraNavigationChannel()
|
|
230
|
+
const received: NavigationSyncPose[] = []
|
|
231
|
+
const unsubscribe = channel.subscribe((pose) => received.push(pose))
|
|
232
|
+
const input = {
|
|
233
|
+
source: '3d' as const,
|
|
234
|
+
target: [0, 1, 2] as [number, number, number],
|
|
235
|
+
azimuth: 0.5,
|
|
236
|
+
viewWidth: 12,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
channel.publish(input)
|
|
240
|
+
channel.publish({ ...input, azimuth: 0.75 })
|
|
241
|
+
unsubscribe()
|
|
242
|
+
channel.publish({ ...input, azimuth: 1 })
|
|
243
|
+
|
|
244
|
+
expect(received).toEqual([
|
|
245
|
+
{ ...input, revision: 1 },
|
|
246
|
+
{ ...input, azimuth: 0.75, revision: 2 },
|
|
247
|
+
])
|
|
248
|
+
})
|
|
249
|
+
})
|