@pascal-app/editor 1.0.0-beta.5 → 1.0.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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +78 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +63 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +44 -21
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +103 -10
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +5 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +455 -0
- package/src/lib/portable-export.ts +1042 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +38 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
package/src/lib/editor-api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AnyNode, type EditorApi, useScene } from '@pascal-app/core'
|
|
2
2
|
import useEditor from '../store/use-editor'
|
|
3
3
|
import useInteractionScope from '../store/use-interaction-scope'
|
|
4
|
+
import { resolveDirectManipulationNode, resolveMoveActionNode } from './direct-manipulation'
|
|
4
5
|
import {
|
|
5
6
|
controlPointReshapeScope,
|
|
6
7
|
endpointReshapeScope,
|
|
@@ -25,14 +26,16 @@ export function createEditorApi(): EditorApi {
|
|
|
25
26
|
// (every concrete kind enumerated). Descriptors pass any node; the
|
|
26
27
|
// cast lets registry-driven move kinds through without forcing a
|
|
27
28
|
// schema-level type widening.
|
|
28
|
-
|
|
29
|
+
const target = resolveMoveActionNode(node, useScene.getState().nodes)
|
|
30
|
+
editor.setMovingNode(target as Parameters<typeof editor.setMovingNode>[0])
|
|
29
31
|
},
|
|
30
32
|
engageMoveDrag(node: AnyNode) {
|
|
31
33
|
const editor = useEditor.getState()
|
|
32
34
|
// Flag drag mode BEFORE mounting the move tool so the coordinator reads
|
|
33
35
|
// it at setup and wires its commit-on-release listener.
|
|
34
36
|
editor.setPlacementDragMode(true)
|
|
35
|
-
|
|
37
|
+
const target = resolveDirectManipulationNode(node, useScene.getState().nodes)
|
|
38
|
+
editor.setMovingNode(target as Parameters<typeof editor.setMovingNode>[0])
|
|
36
39
|
},
|
|
37
40
|
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
|
|
38
41
|
// Endpoint reshape is kind-agnostic: the scope carries the node id + which
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core'
|
|
3
3
|
import useElevationGuides from '../store/use-elevation-guides'
|
|
4
4
|
import {
|
|
5
5
|
clearStructuralElevationGuide,
|
|
6
6
|
collectElevationSnapTargets,
|
|
7
|
+
publishResolvedElevationGuide,
|
|
7
8
|
publishStructuralElevationGuide,
|
|
8
9
|
resolveElevationSnapMatch,
|
|
9
10
|
resolveStructuralElevationSnap,
|
|
@@ -42,6 +43,10 @@ function structuralScene() {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
describe('elevation guides', () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
useElevationGuides.setState({ guide: null })
|
|
48
|
+
})
|
|
49
|
+
|
|
45
50
|
test('collects the level, slab faces, and ceiling plane on the source level', () => {
|
|
46
51
|
const { level, nodes, slab } = structuralScene()
|
|
47
52
|
const targets = collectElevationSnapTargets(
|
|
@@ -104,4 +109,26 @@ describe('elevation guides', () => {
|
|
|
104
109
|
publishStructuralElevationGuide(source, 0.7, nodes)
|
|
105
110
|
expect(useElevationGuides.getState().guide).toBeNull()
|
|
106
111
|
})
|
|
112
|
+
|
|
113
|
+
test('publishes an explicitly resolved neighboring datum', () => {
|
|
114
|
+
const { level } = structuralScene()
|
|
115
|
+
useElevationGuides.setState({ guide: null })
|
|
116
|
+
|
|
117
|
+
publishResolvedElevationGuide(
|
|
118
|
+
{ nodeId: 'leanto_moving', levelId: level.id, anchor: [2, 1] },
|
|
119
|
+
{
|
|
120
|
+
id: 'leanto_neighbor:high-edge',
|
|
121
|
+
elevation: 3.4,
|
|
122
|
+
anchor: [5, 1],
|
|
123
|
+
label: 'Neighbor shed edge',
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(useElevationGuides.getState().guide).toMatchObject({
|
|
128
|
+
ownerId: 'leanto_moving',
|
|
129
|
+
elevation: 3.4,
|
|
130
|
+
direction: [1, 0],
|
|
131
|
+
label: 'Neighbor shed edge',
|
|
132
|
+
})
|
|
133
|
+
})
|
|
107
134
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
|
+
type FenceNode,
|
|
4
5
|
findLevelAncestorId,
|
|
5
6
|
getWallBaseElevationForNodes,
|
|
6
7
|
getWallEffectiveHeightForNodes,
|
|
@@ -53,8 +54,10 @@ function segmentCenter(
|
|
|
53
54
|
// A stale host resolves to the level base — the sculpted ground under the
|
|
54
55
|
// fence's start point, sampled where the builder samples it, so the guide line
|
|
55
56
|
// lands on the rail it claims to describe.
|
|
56
|
-
function
|
|
57
|
-
|
|
57
|
+
export function getFenceBaseElevationForNodes(
|
|
58
|
+
node: FenceNode,
|
|
59
|
+
nodes: Record<string, AnyNode>,
|
|
60
|
+
): number {
|
|
58
61
|
const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined
|
|
59
62
|
const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null)
|
|
60
63
|
const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes)
|
|
@@ -161,7 +164,7 @@ export function collectElevationSnapTargets(
|
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
if (node.type === 'fence') {
|
|
164
|
-
const base =
|
|
167
|
+
const base = getFenceBaseElevationForNodes(node, nodes)
|
|
165
168
|
const center = segmentCenter(node.start, node.end)
|
|
166
169
|
targets.push({
|
|
167
170
|
id: `${node.id}:base`,
|
|
@@ -245,8 +248,20 @@ export function publishStructuralElevationGuide(
|
|
|
245
248
|
return
|
|
246
249
|
}
|
|
247
250
|
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
publishResolvedElevationGuide(source, match.target)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function publishResolvedElevationGuide(
|
|
255
|
+
source: ElevationGuideSource,
|
|
256
|
+
target: ElevationSnapTarget,
|
|
257
|
+
): void {
|
|
258
|
+
if (!source.levelId) {
|
|
259
|
+
clearStructuralElevationGuide(source.nodeId)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const dx = target.anchor[0] - source.anchor[0]
|
|
264
|
+
const dz = target.anchor[1] - source.anchor[1]
|
|
250
265
|
const length = Math.hypot(dx, dz)
|
|
251
266
|
const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0]
|
|
252
267
|
|
|
@@ -255,8 +270,8 @@ export function publishStructuralElevationGuide(
|
|
|
255
270
|
levelId: source.levelId,
|
|
256
271
|
center: source.anchor,
|
|
257
272
|
direction,
|
|
258
|
-
elevation:
|
|
259
|
-
label:
|
|
273
|
+
elevation: target.elevation,
|
|
274
|
+
label: target.label,
|
|
260
275
|
})
|
|
261
276
|
}
|
|
262
277
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import type { WebGPURenderer } from 'three/webgpu'
|
|
4
|
+
import { createExportTextureUtils } from './export-texture-utils'
|
|
5
|
+
|
|
6
|
+
const globals = globalThis as unknown as { document?: unknown }
|
|
7
|
+
const previousDocument = globals.document
|
|
8
|
+
|
|
9
|
+
type FakeRenderer = {
|
|
10
|
+
renderer: WebGPURenderer
|
|
11
|
+
calls: { render: number; dispose: number; destroy: number; sizes: [number, number][] }
|
|
12
|
+
domElement: object
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fakeRenderer(): FakeRenderer {
|
|
16
|
+
const calls = { render: 0, dispose: 0, destroy: 0, sizes: [] as [number, number][] }
|
|
17
|
+
const domElement = { kind: 'renderer-canvas' }
|
|
18
|
+
const renderer = {
|
|
19
|
+
domElement,
|
|
20
|
+
outputColorSpace: THREE.SRGBColorSpace,
|
|
21
|
+
backend: {
|
|
22
|
+
device: {
|
|
23
|
+
destroy: () => {
|
|
24
|
+
calls.destroy += 1
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
setSize: (width: number, height: number) => {
|
|
29
|
+
calls.sizes.push([width, height])
|
|
30
|
+
},
|
|
31
|
+
render: () => {
|
|
32
|
+
calls.render += 1
|
|
33
|
+
},
|
|
34
|
+
dispose: async () => {
|
|
35
|
+
calls.dispose += 1
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
return { renderer: renderer as unknown as WebGPURenderer, calls, domElement }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function installCanvasDocument(drawn: object[]) {
|
|
42
|
+
globals.document = {
|
|
43
|
+
createElement: (tagName: string) => {
|
|
44
|
+
if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
|
|
45
|
+
return {
|
|
46
|
+
width: 0,
|
|
47
|
+
height: 0,
|
|
48
|
+
getContext: () => ({
|
|
49
|
+
drawImage: (source: object) => {
|
|
50
|
+
drawn.push(source)
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function compressedTexture(name: string, width = 8, height = 4): THREE.CompressedTexture {
|
|
59
|
+
const texture = new THREE.CompressedTexture([], width, height)
|
|
60
|
+
texture.name = name
|
|
61
|
+
texture.colorSpace = THREE.NoColorSpace
|
|
62
|
+
texture.wrapS = THREE.RepeatWrapping
|
|
63
|
+
texture.wrapT = THREE.MirroredRepeatWrapping
|
|
64
|
+
texture.minFilter = THREE.NearestFilter
|
|
65
|
+
texture.magFilter = THREE.NearestFilter
|
|
66
|
+
return texture
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
globals.document = previousDocument
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('createExportTextureUtils', () => {
|
|
74
|
+
test('starts one renderer for every compressed texture in the export and releases it once', async () => {
|
|
75
|
+
const drawn: object[] = []
|
|
76
|
+
installCanvasDocument(drawn)
|
|
77
|
+
const fake = fakeRenderer()
|
|
78
|
+
let starts = 0
|
|
79
|
+
const utils = createExportTextureUtils(async () => {
|
|
80
|
+
starts += 1
|
|
81
|
+
return fake.renderer
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const readable = await Promise.all([
|
|
85
|
+
utils.decompress(compressedTexture('NormalGL_a')),
|
|
86
|
+
utils.decompress(compressedTexture('Color_b', 16, 16)),
|
|
87
|
+
utils.decompress(compressedTexture('Rough_c', 2, 8)),
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
expect(starts).toBe(1)
|
|
91
|
+
expect(fake.calls.render).toBe(3)
|
|
92
|
+
expect(fake.calls.sizes).toEqual([
|
|
93
|
+
[8, 4],
|
|
94
|
+
[16, 16],
|
|
95
|
+
[2, 8],
|
|
96
|
+
])
|
|
97
|
+
expect(drawn).toEqual([fake.domElement, fake.domElement, fake.domElement])
|
|
98
|
+
expect(fake.renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
|
|
99
|
+
|
|
100
|
+
const first = readable[0] as THREE.CanvasTexture
|
|
101
|
+
expect(first.isCanvasTexture).toBe(true)
|
|
102
|
+
expect(first.name).toBe('NormalGL_a')
|
|
103
|
+
expect(first.colorSpace).toBe(THREE.NoColorSpace)
|
|
104
|
+
expect(first.wrapS).toBe(THREE.RepeatWrapping)
|
|
105
|
+
expect(first.wrapT).toBe(THREE.MirroredRepeatWrapping)
|
|
106
|
+
expect(first.minFilter).toBe(THREE.NearestFilter)
|
|
107
|
+
expect(first.magFilter).toBe(THREE.NearestFilter)
|
|
108
|
+
|
|
109
|
+
await utils.dispose()
|
|
110
|
+
await utils.dispose()
|
|
111
|
+
expect(fake.calls.dispose).toBe(1)
|
|
112
|
+
expect(fake.calls.destroy).toBe(1)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('clamps the readback to maxTextureSize', async () => {
|
|
116
|
+
installCanvasDocument([])
|
|
117
|
+
const fake = fakeRenderer()
|
|
118
|
+
const utils = createExportTextureUtils(async () => fake.renderer)
|
|
119
|
+
|
|
120
|
+
const readable = await utils.decompress(compressedTexture('Color', 4096, 1024), 2048)
|
|
121
|
+
|
|
122
|
+
expect(fake.calls.sizes).toEqual([[2048, 1024]])
|
|
123
|
+
expect((readable.image as { width: number; height: number }).width).toBe(2048)
|
|
124
|
+
await utils.dispose()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('rejects when no GPU renderer can start instead of leaving the export pending', async () => {
|
|
128
|
+
installCanvasDocument([])
|
|
129
|
+
const utils = createExportTextureUtils(async () => {
|
|
130
|
+
throw new Error('no adapter')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('no adapter')
|
|
134
|
+
await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('no adapter')
|
|
135
|
+
await expect(utils.dispose()).resolves.toBeUndefined()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('disposing without decompressing never starts a renderer', async () => {
|
|
139
|
+
let starts = 0
|
|
140
|
+
const utils = createExportTextureUtils(async () => {
|
|
141
|
+
starts += 1
|
|
142
|
+
return fakeRenderer().renderer
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await utils.dispose()
|
|
146
|
+
expect(starts).toBe(0)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('a decompress after dispose rejects instead of starting an unowned renderer', async () => {
|
|
150
|
+
installCanvasDocument([])
|
|
151
|
+
const fake = fakeRenderer()
|
|
152
|
+
let starts = 0
|
|
153
|
+
const utils = createExportTextureUtils(async () => {
|
|
154
|
+
starts += 1
|
|
155
|
+
return fake.renderer
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
await utils.decompress(compressedTexture('Color'))
|
|
159
|
+
await utils.dispose()
|
|
160
|
+
await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('abandoned')
|
|
161
|
+
await utils.dispose()
|
|
162
|
+
|
|
163
|
+
expect(starts).toBe(1)
|
|
164
|
+
expect(fake.calls.dispose).toBe(1)
|
|
165
|
+
expect(fake.calls.destroy).toBe(1)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { initializeGpuRenderer } from '@pascal-app/viewer'
|
|
2
|
+
import type * as THREE from 'three'
|
|
3
|
+
import { texture as textureNode, uv } from 'three/tsl'
|
|
4
|
+
import { CanvasTexture, NodeMaterial, QuadMesh, WebGPURenderer } from 'three/webgpu'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GPU decompressor handed to three's GLTF/USDZ exporters for compressed (KTX2)
|
|
8
|
+
* textures. One instance lives for the duration of an export and is disposed
|
|
9
|
+
* with it.
|
|
10
|
+
*/
|
|
11
|
+
export type ExportTextureUtils = {
|
|
12
|
+
decompress(texture: THREE.Texture, maxTextureSize?: number): Promise<THREE.Texture>
|
|
13
|
+
dispose(): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The WebGL probe context initializeGpuRenderer opens on this canvas is never
|
|
17
|
+
// released, so one canvas serves every export instead of one per export.
|
|
18
|
+
let probeCanvas: HTMLCanvasElement | null | undefined
|
|
19
|
+
|
|
20
|
+
async function initializeDecompressRenderer(): Promise<WebGPURenderer> {
|
|
21
|
+
probeCanvas ??= typeof document === 'undefined' ? null : document.createElement('canvas')
|
|
22
|
+
const result = await initializeGpuRenderer({
|
|
23
|
+
probeCanvas,
|
|
24
|
+
createRenderer: (parameters) =>
|
|
25
|
+
new WebGPURenderer(parameters as ConstructorParameters<typeof WebGPURenderer>[0]),
|
|
26
|
+
})
|
|
27
|
+
if (result.status !== 'ready') {
|
|
28
|
+
throw new Error('Could not start a GPU renderer to decompress textures for export.', {
|
|
29
|
+
cause: result.error,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
return result.renderer
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function releaseDevice(renderer: WebGPURenderer) {
|
|
36
|
+
// initializeGpuRenderer hands three a caller-owned device, which
|
|
37
|
+
// WebGPUBackend.dispose() deliberately leaves alive.
|
|
38
|
+
const backend = renderer.backend as { device?: { destroy?: () => void } | null }
|
|
39
|
+
try {
|
|
40
|
+
backend.device?.destroy?.()
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mirrors three's `WebGPUTextureUtils.decompress` but keeps a single renderer
|
|
46
|
+
* for the whole export. The stock helper creates, initialises and disposes a
|
|
47
|
+
* WebGPURenderer — adapter and device request included, with no timeout — for
|
|
48
|
+
* every compressed texture it is handed, so a furnished scene paid that cost
|
|
49
|
+
* dozens of times and hung outright when one device request stalled. Handing
|
|
50
|
+
* it a renderer is not an option: it resizes whatever renderer it receives
|
|
51
|
+
* (the live viewer's canvas) and, in r186, throws on the first call that
|
|
52
|
+
* supplies one.
|
|
53
|
+
*/
|
|
54
|
+
export function createExportTextureUtils(
|
|
55
|
+
initialize: () => Promise<WebGPURenderer> = initializeDecompressRenderer,
|
|
56
|
+
): ExportTextureUtils {
|
|
57
|
+
let pending: Promise<WebGPURenderer> | null = null
|
|
58
|
+
let disposed = false
|
|
59
|
+
const quad = new QuadMesh()
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
async decompress(texture, maxTextureSize = Number.POSITIVE_INFINITY) {
|
|
63
|
+
if (disposed) {
|
|
64
|
+
throw new Error('The export was abandoned before this texture was decompressed.')
|
|
65
|
+
}
|
|
66
|
+
pending ??= initialize()
|
|
67
|
+
const renderer = await pending
|
|
68
|
+
|
|
69
|
+
// Everything below the await is synchronous, which is what lets
|
|
70
|
+
// overlapping calls share `quad` and the renderer's colour space.
|
|
71
|
+
const image = texture.image as { width: number; height: number }
|
|
72
|
+
const width = Math.min(image.width, maxTextureSize)
|
|
73
|
+
const height = Math.min(image.height, maxTextureSize)
|
|
74
|
+
|
|
75
|
+
const material = new NodeMaterial()
|
|
76
|
+
material.fragmentNode = textureNode(texture, uv().flipY())
|
|
77
|
+
const previousColorSpace = renderer.outputColorSpace
|
|
78
|
+
renderer.setSize(width, height)
|
|
79
|
+
renderer.outputColorSpace = texture.colorSpace
|
|
80
|
+
quad.material = material
|
|
81
|
+
try {
|
|
82
|
+
quad.render(renderer)
|
|
83
|
+
} finally {
|
|
84
|
+
renderer.outputColorSpace = previousColorSpace
|
|
85
|
+
material.dispose()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const canvas = document.createElement('canvas')
|
|
89
|
+
canvas.width = width
|
|
90
|
+
canvas.height = height
|
|
91
|
+
const context = canvas.getContext('2d')
|
|
92
|
+
if (!context) {
|
|
93
|
+
throw new Error('Could not create a 2D canvas to read back a decompressed texture.')
|
|
94
|
+
}
|
|
95
|
+
context.drawImage(renderer.domElement, 0, 0, width, height)
|
|
96
|
+
|
|
97
|
+
const readable = new CanvasTexture(canvas)
|
|
98
|
+
readable.minFilter = texture.minFilter
|
|
99
|
+
readable.magFilter = texture.magFilter
|
|
100
|
+
readable.wrapS = texture.wrapS
|
|
101
|
+
readable.wrapT = texture.wrapT
|
|
102
|
+
readable.colorSpace = texture.colorSpace
|
|
103
|
+
readable.name = texture.name
|
|
104
|
+
return readable
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async dispose() {
|
|
108
|
+
disposed = true
|
|
109
|
+
const started = pending
|
|
110
|
+
pending = null
|
|
111
|
+
if (!started) return
|
|
112
|
+
const renderer = await started.catch(() => null)
|
|
113
|
+
if (!renderer) return
|
|
114
|
+
try {
|
|
115
|
+
await renderer.dispose()
|
|
116
|
+
} finally {
|
|
117
|
+
releaseDevice(renderer)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { uniform } from 'three/tsl'
|
|
4
|
+
import { cloneExportUserData } from './export-user-data'
|
|
5
|
+
|
|
6
|
+
describe('cloneExportUserData', () => {
|
|
7
|
+
test('keeps plain data and deep-copies it', () => {
|
|
8
|
+
const source = {
|
|
9
|
+
pascalId: 'wall-1',
|
|
10
|
+
pascalSwingLeaf: { axis: 'y', openRotationY: 1.2 },
|
|
11
|
+
tags: ['a', 'b'],
|
|
12
|
+
offsets: new Float32Array([1, 2, 3]),
|
|
13
|
+
}
|
|
14
|
+
const copy = cloneExportUserData(source)
|
|
15
|
+
expect(copy).toEqual(source)
|
|
16
|
+
expect(copy.pascalSwingLeaf).not.toBe(source.pascalSwingLeaf)
|
|
17
|
+
expect(copy.tags).not.toBe(source.tags)
|
|
18
|
+
expect(copy.offsets).not.toBe(source.offsets)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('drops functions and live three resources instead of throwing', () => {
|
|
22
|
+
const material = new THREE.MeshStandardMaterial()
|
|
23
|
+
material.addEventListener('dispose', () => {})
|
|
24
|
+
const source = {
|
|
25
|
+
pascalId: 'pool-1',
|
|
26
|
+
waterEffect: { material, uniforms: { time: uniform(0) }, strength: 0.5 },
|
|
27
|
+
mesh: new THREE.Mesh(new THREE.BoxGeometry(), material),
|
|
28
|
+
geometry: new THREE.BufferGeometry(),
|
|
29
|
+
texture: new THREE.Texture(),
|
|
30
|
+
onDispose: () => {},
|
|
31
|
+
list: [material, 'kept'],
|
|
32
|
+
}
|
|
33
|
+
expect(() => structuredClone(source)).toThrow()
|
|
34
|
+
expect(cloneExportUserData(source)).toEqual({
|
|
35
|
+
pascalId: 'pool-1',
|
|
36
|
+
waterEffect: { uniforms: {}, strength: 0.5 },
|
|
37
|
+
list: [undefined, 'kept'],
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('tolerates cycles', () => {
|
|
42
|
+
const source: Record<string, unknown> = { pascalId: 'x' }
|
|
43
|
+
source.self = source
|
|
44
|
+
const copy = cloneExportUserData(source)
|
|
45
|
+
expect(copy.pascalId).toBe('x')
|
|
46
|
+
expect(copy.self).toBe(copy)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy `userData` for an export clone. `structuredClone` throws the moment it
|
|
3
|
+
* meets a function, and live scenes keep runtime resources in userData (a
|
|
4
|
+
* plugin's effect material, TSL uniforms, a mesh reference) whose dispose
|
|
5
|
+
* listeners are functions — so a plain `structuredClone(userData)` aborts the
|
|
6
|
+
* whole export. Exports only ever read plain data back out of userData, so
|
|
7
|
+
* runtime resources and functions are dropped instead of cloned.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type RuntimeResource = {
|
|
11
|
+
isObject3D?: boolean
|
|
12
|
+
isMaterial?: boolean
|
|
13
|
+
isTexture?: boolean
|
|
14
|
+
isBufferGeometry?: boolean
|
|
15
|
+
isNode?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isRuntimeResource(value: object): boolean {
|
|
19
|
+
const flags = value as RuntimeResource
|
|
20
|
+
return Boolean(
|
|
21
|
+
flags.isObject3D ||
|
|
22
|
+
flags.isMaterial ||
|
|
23
|
+
flags.isTexture ||
|
|
24
|
+
flags.isBufferGeometry ||
|
|
25
|
+
flags.isNode,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cloneValue(value: unknown, seen: Map<object, unknown>): unknown {
|
|
30
|
+
if (typeof value === 'function') return undefined
|
|
31
|
+
if (value === null || typeof value !== 'object') return value
|
|
32
|
+
if (seen.has(value)) return seen.get(value)
|
|
33
|
+
if (isRuntimeResource(value)) return undefined
|
|
34
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
35
|
+
const copy = structuredClone(value)
|
|
36
|
+
seen.set(value, copy)
|
|
37
|
+
return copy
|
|
38
|
+
}
|
|
39
|
+
if (value instanceof Date || value instanceof RegExp) return structuredClone(value)
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
const copy: unknown[] = []
|
|
42
|
+
seen.set(value, copy)
|
|
43
|
+
for (const entry of value) copy.push(cloneValue(entry, seen))
|
|
44
|
+
return copy
|
|
45
|
+
}
|
|
46
|
+
if (value instanceof Map) {
|
|
47
|
+
const copy = new Map<unknown, unknown>()
|
|
48
|
+
seen.set(value, copy)
|
|
49
|
+
for (const [key, entry] of value) copy.set(cloneValue(key, seen), cloneValue(entry, seen))
|
|
50
|
+
return copy
|
|
51
|
+
}
|
|
52
|
+
if (value instanceof Set) {
|
|
53
|
+
const copy = new Set<unknown>()
|
|
54
|
+
seen.set(value, copy)
|
|
55
|
+
for (const entry of value) copy.add(cloneValue(entry, seen))
|
|
56
|
+
return copy
|
|
57
|
+
}
|
|
58
|
+
const copy: Record<string, unknown> = {}
|
|
59
|
+
seen.set(value, copy)
|
|
60
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
61
|
+
const cloned = cloneValue(entry, seen)
|
|
62
|
+
if (cloned !== undefined) copy[key] = cloned
|
|
63
|
+
}
|
|
64
|
+
return copy
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cloneExportUserData(userData: Record<string, unknown>): Record<string, unknown> {
|
|
68
|
+
return cloneValue(userData, new Map()) as Record<string, unknown>
|
|
69
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { OrthographicCamera, PerspectiveCamera, Vector3 } from 'three'
|
|
3
|
+
import { getFloatingMenuScale } from './floating-menu-scale'
|
|
4
|
+
|
|
5
|
+
describe('getFloatingMenuScale', () => {
|
|
6
|
+
test('uses the standard orthographic zoom scale and clamps its range', () => {
|
|
7
|
+
const camera = new OrthographicCamera()
|
|
8
|
+
const anchor = new Vector3()
|
|
9
|
+
|
|
10
|
+
camera.zoom = 15
|
|
11
|
+
expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
|
|
12
|
+
camera.zoom = 2
|
|
13
|
+
expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
|
|
14
|
+
camera.zoom = 40
|
|
15
|
+
expect(getFloatingMenuScale(camera, anchor)).toBe(1)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('uses inverse perspective-camera distance and clamps its range', () => {
|
|
19
|
+
const camera = new PerspectiveCamera()
|
|
20
|
+
const anchor = new Vector3()
|
|
21
|
+
|
|
22
|
+
camera.position.set(0, 0, 16)
|
|
23
|
+
expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
|
|
24
|
+
camera.position.set(0, 0, 48)
|
|
25
|
+
expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
|
|
26
|
+
camera.position.set(0, 0, 6)
|
|
27
|
+
expect(getFloatingMenuScale(camera, anchor)).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Camera, OrthographicCamera, type Vector3 } from 'three'
|
|
2
|
+
|
|
3
|
+
const MIN_MENU_SCALE = 0.5
|
|
4
|
+
const MAX_MENU_SCALE = 1
|
|
5
|
+
const REF_ORTHO_ZOOM = 20
|
|
6
|
+
const REF_CAMERA_DISTANCE = 12
|
|
7
|
+
|
|
8
|
+
export function getFloatingMenuScale(camera: Camera, anchor: Vector3): number {
|
|
9
|
+
const raw =
|
|
10
|
+
camera instanceof OrthographicCamera
|
|
11
|
+
? camera.zoom / REF_ORTHO_ZOOM
|
|
12
|
+
: REF_CAMERA_DISTANCE / Math.max(camera.position.distanceTo(anchor), 0.001)
|
|
13
|
+
return Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
|
|
14
|
+
}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { LevelNode, useScene, WallNode } from '@pascal-app/core'
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
2
4
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
3
|
-
import { applyFloorplanAlignment } from './apply-alignment'
|
|
5
|
+
import { alignFloorplanDraftPoint, applyFloorplanAlignment } from './apply-alignment'
|
|
4
6
|
|
|
5
7
|
describe('applyFloorplanAlignment', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// These tests assume no active building (alignment runs on world axes).
|
|
10
|
+
// The scene/viewer stores are process-wide singletons, so an earlier test
|
|
11
|
+
// FILE can leak a selected building fixture into them — under bun's
|
|
12
|
+
// platform-dependent file order that turned into an order-dependent
|
|
13
|
+
// failure (getActiveBuildingPose reading a fixture building without a
|
|
14
|
+
// rotation array). Pin the empty-scene context explicitly.
|
|
15
|
+
useScene.setState({ nodes: {} } as never)
|
|
16
|
+
useViewer.setState({
|
|
17
|
+
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
|
18
|
+
} as never)
|
|
19
|
+
})
|
|
20
|
+
|
|
6
21
|
afterEach(() => {
|
|
7
22
|
useAlignmentGuides.getState().clear()
|
|
8
23
|
})
|
|
@@ -22,4 +37,34 @@ describe('applyFloorplanAlignment', () => {
|
|
|
22
37
|
expect(result.guides).toHaveLength(1)
|
|
23
38
|
expect(useAlignmentGuides.getState().guides).toHaveLength(1)
|
|
24
39
|
})
|
|
40
|
+
|
|
41
|
+
test('can restrict candidates to the active level', () => {
|
|
42
|
+
const lowerLevel = LevelNode.parse({ id: 'level_lower', level: 0 })
|
|
43
|
+
const upperLevel = LevelNode.parse({ id: 'level_upper', level: 1 })
|
|
44
|
+
const lowerWall = WallNode.parse({
|
|
45
|
+
id: 'wall_lower',
|
|
46
|
+
parentId: lowerLevel.id,
|
|
47
|
+
start: [10, 10],
|
|
48
|
+
end: [14, 10],
|
|
49
|
+
})
|
|
50
|
+
const upperWall = WallNode.parse({
|
|
51
|
+
id: 'wall_upper',
|
|
52
|
+
parentId: upperLevel.id,
|
|
53
|
+
start: [0, 0],
|
|
54
|
+
end: [4, 0],
|
|
55
|
+
})
|
|
56
|
+
useScene.setState({
|
|
57
|
+
nodes: Object.fromEntries(
|
|
58
|
+
[lowerLevel, upperLevel, lowerWall, upperWall].map((node) => [node.id, node]),
|
|
59
|
+
),
|
|
60
|
+
} as never)
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
alignFloorplanDraftPoint([0.04, 0], {
|
|
64
|
+
applySnap: true,
|
|
65
|
+
levelId: lowerLevel.id,
|
|
66
|
+
}),
|
|
67
|
+
).toEqual([0.04, 0])
|
|
68
|
+
expect(useAlignmentGuides.getState().guides).toHaveLength(0)
|
|
69
|
+
})
|
|
25
70
|
})
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type AlignmentAnchor,
|
|
3
3
|
type AlignmentGuide,
|
|
4
4
|
collectAlignmentAnchors,
|
|
5
|
+
resolveLevelId,
|
|
5
6
|
useScene,
|
|
6
7
|
} from '@pascal-app/core'
|
|
7
8
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
@@ -96,13 +97,21 @@ export function alignFloorplanDraftPoint(
|
|
|
96
97
|
bypass?: boolean
|
|
97
98
|
threshold?: number
|
|
98
99
|
excludeIds?: readonly string[]
|
|
100
|
+
levelId?: string | null
|
|
99
101
|
},
|
|
100
102
|
): [number, number] {
|
|
101
103
|
if (opts?.bypass) {
|
|
102
104
|
useAlignmentGuides.getState().clear()
|
|
103
105
|
return [point[0], point[1]]
|
|
104
106
|
}
|
|
105
|
-
|
|
107
|
+
const nodes = useScene.getState().nodes
|
|
108
|
+
let candidates = collectAlignmentAnchors(nodes, FLOORPLAN_DRAFT_ALIGN_ID)
|
|
109
|
+
if (opts && 'levelId' in opts) {
|
|
110
|
+
candidates = candidates.filter((anchor) => {
|
|
111
|
+
const candidate = nodes[anchor.nodeId as keyof typeof nodes]
|
|
112
|
+
return candidate ? resolveLevelId(candidate, nodes) === opts.levelId : false
|
|
113
|
+
})
|
|
114
|
+
}
|
|
106
115
|
if (opts?.excludeIds?.length) {
|
|
107
116
|
const excluded = new Set(opts.excludeIds)
|
|
108
117
|
candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))
|