@pascal-app/editor 1.0.4 → 1.0.6
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/README.md +106 -0
- package/package.json +40 -38
- package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
- package/src/components/editor/bake-thumbnail.tsx +6 -15
- package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
- package/src/components/editor/construction-reveal-audio.tsx +40 -0
- package/src/components/editor/custom-camera-controls.tsx +81 -8
- package/src/components/editor/editor-layout-mobile.tsx +1 -1
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/export-manager.tsx +14 -19
- package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
- package/src/components/editor/first-person/build-collider-world.ts +22 -5
- package/src/components/editor/first-person/ground-holes.test.ts +128 -0
- package/src/components/editor/first-person/ground-holes.ts +130 -0
- package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
- package/src/components/editor/first-person/mechanism-targets.ts +52 -0
- package/src/components/editor/first-person-controls.tsx +19 -11
- package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +72 -23
- package/src/components/editor/follow-pascal.test.tsx +912 -0
- package/src/components/editor/follow-pascal.tsx +354 -0
- package/src/components/editor/group-actions.ts +2 -0
- package/src/components/editor/group-move-3d.ts +2 -0
- package/src/components/editor/group-rotate-handle.tsx +2 -0
- package/src/components/editor/group-transform-shared.test.ts +460 -0
- package/src/components/editor/index.tsx +166 -79
- package/src/components/editor/riser-diagram-panel.tsx +14 -7
- package/src/components/editor/room-highlight.tsx +13 -5
- package/src/components/editor/room-labels.test.ts +621 -128
- package/src/components/editor/room-labels.tsx +1090 -190
- package/src/components/editor/selection-manager.tsx +38 -117
- package/src/components/editor/slab-hole-highlights.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
- package/src/components/editor/snapshot-capture.test.ts +69 -58
- package/src/components/editor/snapshot-capture.ts +7 -1
- package/src/components/editor/thumbnail-generator.test.ts +350 -4
- package/src/components/editor/thumbnail-generator.tsx +128 -54
- package/src/components/editor/use-floorplan-background-placement.ts +3 -1
- package/src/components/editor/views/plugins-view.tsx +1 -8
- package/src/components/editor/views/view-bar.test.tsx +54 -0
- package/src/components/editor/views/view-bar.tsx +140 -17
- package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
- package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
- package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
- package/src/components/point-ask/bubble.tsx +538 -0
- package/src/components/point-ask/hover-chip.tsx +91 -0
- package/src/components/point-ask/mode-pill.tsx +92 -0
- package/src/components/point-ask/optional-motion.ts +22 -0
- package/src/components/point-ask/outline-corners.tsx +93 -0
- package/src/components/point-ask/pins.tsx +711 -0
- package/src/components/point-ask/point-ask-layer.tsx +83 -0
- package/src/components/point-ask/point-ask-styles.ts +105 -0
- package/src/components/point-ask/region-marquee-styles.ts +15 -0
- package/src/components/point-ask/region-marquee.tsx +64 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
- package/src/components/systems/roof/roof-edit-system.tsx +1 -0
- package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
- package/src/components/tools/distribution/connection-compatibility.ts +43 -0
- package/src/components/tools/distribution/distribution-public.ts +9 -0
- package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
- package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
- package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
- package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
- package/src/components/tools/distribution/draw-alignment.ts +35 -0
- package/src/components/tools/distribution/ports.test.ts +56 -0
- package/src/components/tools/distribution/ports.ts +378 -0
- package/src/components/tools/distribution/run-cursor.test.ts +135 -0
- package/src/components/tools/distribution/run-cursor.ts +36 -0
- package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
- package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
- package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
- package/src/components/tools/distribution/run-port-snap.ts +25 -0
- package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
- package/src/components/tools/distribution/run-screen-direction.ts +30 -0
- package/src/components/tools/elevator/elevator-tool.tsx +3 -6
- package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
- package/src/components/tools/item/placement-history.test.tsx +38 -0
- package/src/components/tools/item/placement-strategies.test.ts +46 -1
- package/src/components/tools/item/placement-strategies.ts +3 -6
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
- package/src/components/tools/item/use-draft-node.test.tsx +94 -94
- package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
- package/src/components/tools/registry/item-surface-move.test.ts +62 -63
- package/src/components/tools/registry/item-surface-move.ts +2 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
- package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
- package/src/components/tools/tool-manager.tsx +14 -5
- package/src/components/tools/wall/wall-drafting.test.ts +170 -57
- package/src/components/ui/action-menu/action-button.tsx +6 -1
- package/src/components/ui/action-menu/camera-actions.tsx +7 -3
- package/src/components/ui/action-menu/control-modes.tsx +13 -7
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
- package/src/components/ui/action-menu/index.tsx +56 -33
- package/src/components/ui/action-menu/measurement-control.tsx +11 -9
- package/src/components/ui/action-menu/placement.ts +20 -0
- package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
- package/src/components/ui/action-menu/view-toggles.tsx +47 -42
- package/src/components/ui/build-panel/build-panel.tsx +10 -3
- package/src/components/ui/command-palette/editor-commands.tsx +3 -12
- package/src/components/ui/command-palette/index.tsx +45 -15
- package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
- package/src/components/ui/controls/action-button.tsx +5 -2
- package/src/components/ui/controls/metric-control.tsx +1 -1
- package/src/components/ui/controls/segmented-control.tsx +21 -8
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/slider-control.tsx +1 -1
- package/src/components/ui/controls/toggle-control.tsx +3 -3
- package/src/components/ui/controls/tool-options-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +150 -45
- package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
- package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
- package/src/components/ui/helpers/helper-manager.tsx +7 -1
- package/src/components/ui/helpers/item-helper.tsx +9 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
- package/src/components/ui/panels/panel-wrapper.tsx +53 -27
- package/src/components/ui/panels/parametric-field-control.tsx +2 -2
- package/src/components/ui/panels/parametric-inspector.tsx +3 -3
- package/src/components/ui/panels/room-panel.tsx +65 -0
- package/src/components/ui/primitives/badge.tsx +8 -0
- package/src/components/ui/primitives/sidebar.tsx +1 -1
- package/src/components/ui/right-stack.tsx +21 -2
- package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +3 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
- package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
- package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/tab-bar.tsx +4 -4
- package/src/components/viewer/viewer-controls-bar.tsx +18 -6
- package/src/components/viewer/viewer-rooms.tsx +16 -0
- package/src/hooks/use-auto-save.integration.test.tsx +96 -38
- package/src/hooks/use-auto-save.ts +26 -1
- package/src/hooks/use-ceiling-events.test.ts +123 -118
- package/src/hooks/use-grid-events.test.ts +105 -105
- package/src/hooks/use-keyboard.test.ts +65 -16
- package/src/hooks/use-keyboard.ts +29 -12
- package/src/hooks/use-placement-notice.test.ts +24 -1
- package/src/hooks/use-placement-notice.ts +37 -6
- package/src/index.tsx +71 -24
- package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
- package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
- package/src/lib/add-storey.test.ts +177 -0
- package/src/lib/add-storey.ts +59 -0
- package/src/lib/below-grade.test.tsx +171 -0
- package/src/lib/below-grade.ts +133 -0
- package/src/lib/camera-facing.test.ts +24 -0
- package/src/lib/camera-facing.ts +24 -0
- package/src/lib/direct-manipulation.ts +2 -0
- package/src/lib/download-project-file.ts +17 -0
- package/src/lib/editor-level-display.ts +9 -2
- package/src/lib/editor-views.ts +35 -1
- package/src/lib/export-texture-utils.ts +2 -4
- package/src/lib/floor-foundation-support.test.ts +1 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
- package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
- package/src/lib/floorplan/sheet-export.test.ts +24 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
- package/src/lib/floorplan/site-plan/coverage.ts +2 -4
- package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
- package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
- package/src/lib/floorplan/site-plan/geometry.ts +1 -416
- package/src/lib/floorplan/site-plan/index.ts +0 -16
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
- package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
- package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
- package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
- package/src/lib/follow-pascal-exterior.test.ts +171 -0
- package/src/lib/follow-pascal-exterior.ts +39 -0
- package/src/lib/follow-pascal-presentation.test.ts +203 -0
- package/src/lib/follow-pascal-presentation.ts +129 -0
- package/src/lib/follow-pascal-sight.ts +84 -0
- package/src/lib/follow-pascal-targets.test.ts +406 -0
- package/src/lib/follow-pascal-targets.ts +198 -0
- package/src/lib/follow-pascal.test.ts +350 -0
- package/src/lib/follow-pascal.ts +284 -0
- package/src/lib/fresh-planar-placement.test.ts +112 -0
- package/src/lib/fresh-planar-placement.ts +57 -3
- package/src/lib/geometry-script/author.ts +6 -1
- package/src/lib/geometry-script/client.ts +8 -1
- package/src/lib/geometry-script/finish-scripts.ts +127 -0
- package/src/lib/geometry-script/protocol.ts +1 -0
- package/src/lib/glb-export.test.ts +187 -162
- package/src/lib/glb-export.ts +4 -1
- package/src/lib/history.test.ts +12 -7
- package/src/lib/interaction/scope.ts +4 -0
- package/src/lib/level-selection.test.ts +57 -0
- package/src/lib/level-selection.ts +16 -1
- package/src/lib/lot/clean-ring.test.ts +2 -1
- package/src/lib/lot/drop-in.ts +9 -24
- package/src/lib/lot/lot-patch.test.ts +1 -1
- package/src/lib/lot/lot-patch.ts +2 -2
- package/src/lib/measurement-kind.test.ts +6 -8
- package/src/lib/measurements.test.ts +23 -0
- package/src/lib/orbit-floor-clearance.test.ts +36 -0
- package/src/lib/orbit-floor-clearance.ts +48 -0
- package/src/lib/paint-pass-through.test.ts +11 -0
- package/src/lib/paint-pass-through.ts +4 -2
- package/src/lib/paint-scope.ts +1 -0
- package/src/lib/parametric-node-update.ts +16 -7
- package/src/lib/plugin-chat.ts +15 -0
- package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
- package/src/lib/point-ask/__fixtures__/house.ts +81 -0
- package/src/lib/point-ask/ask-created.test.ts +195 -0
- package/src/lib/point-ask/ask-flow.test.ts +380 -0
- package/src/lib/point-ask/build-context.test.ts +173 -0
- package/src/lib/point-ask/build-context.ts +94 -0
- package/src/lib/point-ask/capture-crop.ts +129 -0
- package/src/lib/point-ask/choreography.ts +69 -0
- package/src/lib/point-ask/compare.test.ts +145 -0
- package/src/lib/point-ask/compare.ts +73 -0
- package/src/lib/point-ask/describe-target.test.ts +165 -0
- package/src/lib/point-ask/describe-target.ts +234 -0
- package/src/lib/point-ask/dictation.ts +79 -0
- package/src/lib/point-ask/edge-marker.test.ts +61 -0
- package/src/lib/point-ask/edge-marker.ts +27 -0
- package/src/lib/point-ask/frame-loop.ts +25 -0
- package/src/lib/point-ask/history-marks.test.ts +94 -0
- package/src/lib/point-ask/history-marks.ts +39 -0
- package/src/lib/point-ask/host-api.ts +32 -0
- package/src/lib/point-ask/outline-box.test.ts +30 -0
- package/src/lib/point-ask/outline-box.ts +11 -0
- package/src/lib/point-ask/overlay-metrics.ts +6 -0
- package/src/lib/point-ask/pick.test.ts +220 -0
- package/src/lib/point-ask/pick.ts +121 -0
- package/src/lib/point-ask/pin-machine.test.ts +494 -0
- package/src/lib/point-ask/pin-machine.ts +217 -0
- package/src/lib/point-ask/placement.test.ts +203 -0
- package/src/lib/point-ask/placement.ts +141 -0
- package/src/lib/point-ask/pointer-tracker.ts +34 -0
- package/src/lib/point-ask/projector.ts +83 -0
- package/src/lib/point-ask/region.test.ts +157 -0
- package/src/lib/point-ask/region.ts +136 -0
- package/src/lib/point-ask/retry.test.ts +217 -0
- package/src/lib/point-ask/retry.ts +37 -0
- package/src/lib/point-ask/scene-commit.test.ts +312 -0
- package/src/lib/point-ask/scene-commit.ts +32 -0
- package/src/lib/point-ask/session.ts +1391 -0
- package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
- package/src/lib/point-ask/spring-keyframes.ts +38 -0
- package/src/lib/point-ask/spring.test.ts +63 -0
- package/src/lib/point-ask/spring.ts +107 -0
- package/src/lib/point-ask/suggestions.test.ts +80 -0
- package/src/lib/point-ask/suggestions.ts +125 -0
- package/src/lib/point-ask/target-scope.test.ts +622 -0
- package/src/lib/point-ask/target-scope.ts +338 -0
- package/src/lib/point-ask/types.ts +48 -0
- package/src/lib/portable-export.test.ts +34 -1
- package/src/lib/portable-export.ts +119 -53
- package/src/lib/print-feature-thickness.ts +10 -1
- package/src/lib/print-roof-solids.test.ts +39 -1
- package/src/lib/print-roof-solids.ts +43 -10
- package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
- package/src/lib/room-assembly-overlay.ts +2 -3
- package/src/lib/room-selection.test.ts +45 -1
- package/src/lib/scene-clipboard.test.ts +246 -1
- package/src/lib/scene-clipboard.ts +44 -14
- package/src/lib/selection-routing.ts +1 -1
- package/src/lib/sfx-bus.ts +13 -0
- package/src/lib/sfx-player.test.ts +70 -9
- package/src/lib/sfx-player.ts +6 -0
- package/src/lib/stair-levels.test.ts +25 -0
- package/src/lib/stair-levels.ts +22 -3
- package/src/lib/terrace-draft.ts +2 -1
- package/src/lib/viewer-selection.ts +22 -11
- package/src/lib/wall-placement-pointer.test.ts +42 -0
- package/src/lib/wall-placement-pointer.ts +57 -0
- package/src/lib/window-changes.ts +35 -0
- package/src/store/use-audio.test.ts +146 -0
- package/src/store/use-audio.tsx +127 -3
- package/src/store/use-construction-reveal.test.ts +173 -0
- package/src/store/use-construction-reveal.ts +145 -0
- package/src/store/use-corner-snap-hint.test.ts +24 -0
- package/src/store/use-corner-snap-hint.ts +47 -0
- package/src/store/use-follow-pascal.test.ts +62 -0
- package/src/store/use-follow-pascal.ts +53 -0
- package/src/store/use-point-ask.ts +247 -0
- package/src/store/use-stair-build-preview.ts +25 -5
- package/src/store/view-layout.test.ts +27 -1
- package/src/store/view-layout.ts +4 -1
- package/src/test-utils/render-current-markup.tsx +16 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
- package/src/components/tools/stair/stair-click-guard.ts +0 -72
- package/src/components/tools/stair/stair-defaults.ts +0 -15
- package/src/components/tools/stair/stair-tool.tsx +0 -682
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { type CameraPose, criticallyDamped } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Follow Pascal: the camera follows what the agent is building (the owner, 2026-10-08). It eases to
|
|
5
|
+
* what the current step works on, at the distance its scale needs; stays about 1.5 s on each target at
|
|
6
|
+
* least; frames what lands together as one; keeps the horizon level (it moves as an orbit about the
|
|
7
|
+
* thing it looks at, never as a free flight); and moves like a critically damped spring.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is arithmetic on boxes and angles, so it is tested without a canvas; the component
|
|
10
|
+
* that feeds it the reveal's events and writes its poses to the camera is `FollowPascal`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type Vec3 = [number, number, number]
|
|
14
|
+
export type Box = { min: Vec3; max: Vec3 }
|
|
15
|
+
|
|
16
|
+
/** What kind of thing a step works on decides how far and from how high it is looked at. */
|
|
17
|
+
export type FollowKind = 'floor' | 'detail' | 'roof' | 'furnish' | 'group'
|
|
18
|
+
|
|
19
|
+
export type FollowWant = {
|
|
20
|
+
box: Box
|
|
21
|
+
kind: FollowKind
|
|
22
|
+
levelId?: string
|
|
23
|
+
nodeIds?: readonly string[]
|
|
24
|
+
/** The outward direction of the wall it is on, in the plan (x, z), for what is looked at square on. */
|
|
25
|
+
normal?: [number, number]
|
|
26
|
+
/** The eye must stand above this height (metres): a roof lifted out of the way is looked down through, not from within. */
|
|
27
|
+
eyeAbove?: number
|
|
28
|
+
/**
|
|
29
|
+
* What it works on is seen from outside (an exterior wall bent, a window moved on one, a roof, the
|
|
30
|
+
* ground): the view is from outdoors with no cutaway, whatever hides the target.
|
|
31
|
+
*/
|
|
32
|
+
exterior?: boolean
|
|
33
|
+
/** When it was asked for, in seconds on the follower's clock. */
|
|
34
|
+
at: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The camera stays on a target at least this long. */
|
|
38
|
+
export const FOLLOW_DWELL_S = 1.5
|
|
39
|
+
/** What lands within this of the first is framed with it. */
|
|
40
|
+
export const FOLLOW_GATHER_S = 0.35
|
|
41
|
+
/** How long a flight takes: a spring's response, not a duration. */
|
|
42
|
+
export const FOLLOW_RESPONSE_S = 1.1
|
|
43
|
+
|
|
44
|
+
/** The eye's elevation above the horizon for each kind, in radians. */
|
|
45
|
+
export const FOLLOW_ELEVATION: Record<FollowKind, number> = {
|
|
46
|
+
floor: 0.5,
|
|
47
|
+
detail: 0.27,
|
|
48
|
+
roof: 0.61,
|
|
49
|
+
furnish: 0.92,
|
|
50
|
+
group: 0.45,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const centreOf = ({ min, max }: Box): Vec3 => [
|
|
54
|
+
(min[0] + max[0]) / 2,
|
|
55
|
+
(min[1] + max[1]) / 2,
|
|
56
|
+
(min[2] + max[2]) / 2,
|
|
57
|
+
]
|
|
58
|
+
const sizeOf = ({ min, max }: Box): Vec3 => [max[0] - min[0], max[1] - min[1], max[2] - min[2]]
|
|
59
|
+
|
|
60
|
+
/** What lands together, as one target: the union of their boxes, a common direction if they share one. */
|
|
61
|
+
export function mergeWants(wants: readonly FollowWant[]): FollowWant {
|
|
62
|
+
const first = wants[0]!
|
|
63
|
+
const min: Vec3 = [...first.box.min]
|
|
64
|
+
const max: Vec3 = [...first.box.max]
|
|
65
|
+
for (const { box } of wants) {
|
|
66
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
67
|
+
min[axis] = Math.min(min[axis]!, box.min[axis]!)
|
|
68
|
+
max[axis] = Math.max(max[axis]!, box.max[axis]!)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const kind = wants.every((want) => want.kind === first.kind) ? first.kind : 'group'
|
|
72
|
+
let normal: [number, number] | undefined
|
|
73
|
+
if (wants.every((want) => want.normal)) {
|
|
74
|
+
const sum: [number, number] = [0, 0]
|
|
75
|
+
for (const want of wants) {
|
|
76
|
+
sum[0] += want.normal![0]
|
|
77
|
+
sum[1] += want.normal![1]
|
|
78
|
+
}
|
|
79
|
+
const length = Math.hypot(sum[0], sum[1])
|
|
80
|
+
// They share a direction only when they mostly agree: the length of the sum over their count.
|
|
81
|
+
if (length / wants.length > 0.6) normal = [sum[0] / length, sum[1] / length]
|
|
82
|
+
}
|
|
83
|
+
const eyes = wants.flatMap((want) => (want.eyeAbove === undefined ? [] : [want.eyeAbove]))
|
|
84
|
+
const nodeIds = [...new Set(wants.flatMap((want) => want.nodeIds ?? []))]
|
|
85
|
+
return {
|
|
86
|
+
box: { min, max },
|
|
87
|
+
kind,
|
|
88
|
+
...(first.levelId ? { levelId: first.levelId } : {}),
|
|
89
|
+
...(nodeIds.length ? { nodeIds } : {}),
|
|
90
|
+
...(normal ? { normal } : {}),
|
|
91
|
+
...(eyes.length ? { eyeAbove: Math.max(...eyes) } : {}),
|
|
92
|
+
// Seen from outside when any of what lands together is: a mixed step takes the outside view.
|
|
93
|
+
...(wants.some((want) => want.exterior) ? { exterior: true } : {}),
|
|
94
|
+
at: Math.max(...wants.map((want) => want.at)),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decides when the camera changes target. Wants are gathered for a moment so things landing together
|
|
100
|
+
* are framed as one, and the camera never leaves a target before its dwell is over.
|
|
101
|
+
*/
|
|
102
|
+
export class Follower {
|
|
103
|
+
private pending: FollowWant[] = []
|
|
104
|
+
private last = Number.NEGATIVE_INFINITY
|
|
105
|
+
|
|
106
|
+
want(want: FollowWant): void {
|
|
107
|
+
this.pending.push(want)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The target to fly to now, if one is due. `blocked` holds it (the person is pointing at something). */
|
|
111
|
+
due(now: number, blocked = false): FollowWant | null {
|
|
112
|
+
const oldest = this.pending[0]
|
|
113
|
+
if (!oldest || blocked) return null
|
|
114
|
+
if (now - this.last < FOLLOW_DWELL_S || now - oldest.at < FOLLOW_GATHER_S) return null
|
|
115
|
+
const nextFloor = this.pending.findIndex((want) => want.levelId !== oldest.levelId)
|
|
116
|
+
const count = nextFloor < 0 ? this.pending.length : nextFloor
|
|
117
|
+
const merged = mergeWants(this.pending.splice(0, count))
|
|
118
|
+
this.last = now
|
|
119
|
+
return merged
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Forgets what waits and starts its dwell over (a new build; the person took the camera back). */
|
|
123
|
+
reset(): void {
|
|
124
|
+
this.pending = []
|
|
125
|
+
this.last = Number.NEGATIVE_INFINITY
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type OrbitValue = { x: number; y: number; z: number; r: number; az: number; el: number }
|
|
130
|
+
|
|
131
|
+
export type FollowView = {
|
|
132
|
+
/** The camera's vertical field of view, in degrees. */
|
|
133
|
+
fov: number
|
|
134
|
+
/** The canvas' width over its height. */
|
|
135
|
+
aspect: number
|
|
136
|
+
/** Where the camera looks from now, so a flight takes the nearest way round. */
|
|
137
|
+
azimuth: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
|
|
141
|
+
const TAU = Math.PI * 2
|
|
142
|
+
|
|
143
|
+
/** The angle `to` written as the nearest equivalent of `from`, so it never goes the long way round. */
|
|
144
|
+
export function nearestTurn(to: number, from: number): number {
|
|
145
|
+
let angle = to
|
|
146
|
+
while (angle - from > Math.PI) angle -= TAU
|
|
147
|
+
while (angle - from < -Math.PI) angle += TAU
|
|
148
|
+
return angle
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Where the camera stands to see `want`: the box's centre, its scale decides how far (a window up
|
|
153
|
+
* close, a floor whole, a group between) at the elevation of its kind, from the side nearest the way
|
|
154
|
+
* it already looks, or square to the wall it is on.
|
|
155
|
+
*/
|
|
156
|
+
export function frameOf(want: FollowWant, view: FollowView): OrbitValue {
|
|
157
|
+
const [cx, cy, cz] = centreOf(want.box)
|
|
158
|
+
const [sx, sy, sz] = sizeOf(want.box)
|
|
159
|
+
const detail = want.kind === 'detail'
|
|
160
|
+
const extent = Math.max(sx, sz, sy * 1.4, detail ? 1.2 : 3)
|
|
161
|
+
const halfVertical = (view.fov * Math.PI) / 360
|
|
162
|
+
const halfHorizontal = Math.atan(Math.tan(halfVertical) * Math.max(0.5, view.aspect))
|
|
163
|
+
const fits = (extent * 0.5) / Math.tan(Math.min(halfVertical, halfHorizontal))
|
|
164
|
+
let r = clamp(fits * 1.25 + sy * 0.3, detail ? 2.8 : 6, 90)
|
|
165
|
+
const el = FOLLOW_ELEVATION[want.kind]
|
|
166
|
+
const eyeY = Math.max(cy, 0.9) + r * Math.sin(el)
|
|
167
|
+
if (want.eyeAbove !== undefined && eyeY < want.eyeAbove) {
|
|
168
|
+
// Further back along the same line of sight, until the eye is above what it must clear.
|
|
169
|
+
r = (want.eyeAbove - Math.max(cy, 0.9)) / Math.sin(el)
|
|
170
|
+
}
|
|
171
|
+
let az = view.azimuth
|
|
172
|
+
if (want.normal) {
|
|
173
|
+
const facing = Math.atan2(want.normal[0], want.normal[1])
|
|
174
|
+
// A little off square, to the side it is already seen from.
|
|
175
|
+
const [left, right] = [facing + 0.5, facing - 0.5]
|
|
176
|
+
az =
|
|
177
|
+
Math.abs(nearestTurn(left, view.azimuth) - view.azimuth) <=
|
|
178
|
+
Math.abs(nearestTurn(right, view.azimuth) - view.azimuth)
|
|
179
|
+
? left
|
|
180
|
+
: right
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
x: cx,
|
|
184
|
+
y: want.kind === 'roof' ? cy - 0.6 : Math.max(cy, 0.9),
|
|
185
|
+
z: cz,
|
|
186
|
+
r,
|
|
187
|
+
az: nearestTurn(az, view.azimuth),
|
|
188
|
+
el,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const KEYS = ['x', 'y', 'z', 'r', 'az', 'el'] as const
|
|
193
|
+
|
|
194
|
+
function orbitPose({ x, y, z, r, az, el }: OrbitValue): CameraPose {
|
|
195
|
+
const flat = Math.cos(el)
|
|
196
|
+
return {
|
|
197
|
+
position: [x + r * flat * Math.sin(az), y + r * Math.sin(el), z + r * flat * Math.cos(az)],
|
|
198
|
+
target: [x, y, z],
|
|
199
|
+
projection: 'perspective',
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Try a higher eye before turning away; never reorient an already clear view. */
|
|
204
|
+
export function clearFrame(
|
|
205
|
+
goal: OrbitValue,
|
|
206
|
+
sees: (pose: CameraPose) => boolean,
|
|
207
|
+
): OrbitValue | null {
|
|
208
|
+
for (const offset of [
|
|
209
|
+
0,
|
|
210
|
+
Math.PI / 6,
|
|
211
|
+
-Math.PI / 6,
|
|
212
|
+
Math.PI / 3,
|
|
213
|
+
-Math.PI / 3,
|
|
214
|
+
Math.PI / 2,
|
|
215
|
+
-Math.PI / 2,
|
|
216
|
+
]) {
|
|
217
|
+
for (const elevation of [goal.el, 0.9, 1.2, (75 * Math.PI) / 180]) {
|
|
218
|
+
const candidate = { ...goal, az: goal.az + offset, el: Math.max(goal.el, elevation) }
|
|
219
|
+
if (sees(orbitPose(candidate))) return candidate
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The camera as six springs on an orbit: the point it looks at, how far, and which way round. */
|
|
226
|
+
export class OrbitSpring {
|
|
227
|
+
private states = Object.fromEntries(KEYS.map((key) => [key, { x: 0, v: 0 }])) as Record<
|
|
228
|
+
(typeof KEYS)[number],
|
|
229
|
+
{ x: number; v: number }
|
|
230
|
+
>
|
|
231
|
+
private goal: OrbitValue
|
|
232
|
+
|
|
233
|
+
constructor(
|
|
234
|
+
start: OrbitValue,
|
|
235
|
+
private response = FOLLOW_RESPONSE_S,
|
|
236
|
+
) {
|
|
237
|
+
this.goal = { ...start }
|
|
238
|
+
this.snap(start)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
snap(value: OrbitValue): void {
|
|
242
|
+
this.goal = { ...value }
|
|
243
|
+
for (const key of KEYS) this.states[key] = { x: value[key], v: 0 }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Flies to `value`, from where it is, carrying its speed. */
|
|
247
|
+
to(value: OrbitValue): void {
|
|
248
|
+
this.goal = { ...value, az: nearestTurn(value.az, this.states.az.x) }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
step(dt: number): void {
|
|
252
|
+
for (const key of KEYS) criticallyDamped(this.states[key], this.goal[key], this.response, dt)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
value(): OrbitValue {
|
|
256
|
+
return Object.fromEntries(KEYS.map((key) => [key, this.states[key].x])) as OrbitValue
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The orbit read off a camera that is already somewhere. */
|
|
260
|
+
fromPose(pose: CameraPose): void {
|
|
261
|
+
const dx = pose.position[0] - pose.target[0]
|
|
262
|
+
const dy = pose.position[1] - pose.target[1]
|
|
263
|
+
const dz = pose.position[2] - pose.target[2]
|
|
264
|
+
const r = Math.hypot(dx, dy, dz) || 1
|
|
265
|
+
this.snap({
|
|
266
|
+
x: pose.target[0],
|
|
267
|
+
y: pose.target[1],
|
|
268
|
+
z: pose.target[2],
|
|
269
|
+
r,
|
|
270
|
+
az: Math.atan2(dx, dz),
|
|
271
|
+
el: Math.asin(clamp(dy / r, -1, 1)),
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Where the camera is now: at the radius and angles about its centre, always looking at it, level. */
|
|
276
|
+
pose(): CameraPose {
|
|
277
|
+
return orbitPose(this.value())
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The goal it is flying to. */
|
|
281
|
+
destination(): OrbitValue {
|
|
282
|
+
return { ...this.goal }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -3,18 +3,24 @@ import {
|
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeDefinition,
|
|
5
5
|
type AnyNodeId,
|
|
6
|
+
BuildingNode,
|
|
6
7
|
beginSceneHistoryPauseSession,
|
|
7
8
|
CabinetModuleNode,
|
|
8
9
|
CabinetNode,
|
|
9
10
|
findLevelAncestorId,
|
|
10
11
|
getSceneHistoryPauseDepth,
|
|
12
|
+
type LevelNode,
|
|
11
13
|
nodeRegistry,
|
|
12
14
|
pauseSceneHistory,
|
|
15
|
+
RoofNode,
|
|
16
|
+
RoofSegmentNode,
|
|
13
17
|
registerNode,
|
|
14
18
|
resumeSceneHistory,
|
|
15
19
|
type SceneCommit,
|
|
16
20
|
subscribeSceneCommits,
|
|
17
21
|
useScene,
|
|
22
|
+
WallNode,
|
|
23
|
+
WindowNode,
|
|
18
24
|
} from '@pascal-app/core'
|
|
19
25
|
import { z } from 'zod'
|
|
20
26
|
import {
|
|
@@ -223,6 +229,102 @@ describe('commitFreshPlacementSubtree', () => {
|
|
|
223
229
|
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
|
|
224
230
|
})
|
|
225
231
|
|
|
232
|
+
// A roof lives on the level above the walls that carry it: one dropped onto them (a duplicate
|
|
233
|
+
// in hand) goes there in the placement's one step.
|
|
234
|
+
test('a roof dropped onto the walls that carry it lands on a new Roof level, in the one step', () => {
|
|
235
|
+
const corners: [number, number][] = [
|
|
236
|
+
[0, 0],
|
|
237
|
+
[8, 0],
|
|
238
|
+
[8, 6],
|
|
239
|
+
[0, 6],
|
|
240
|
+
]
|
|
241
|
+
const walls = corners.map((start, i) =>
|
|
242
|
+
WallNode.parse({ parentId: LEVEL_ID, start, end: corners[(i + 1) % 4], height: 2.5 }),
|
|
243
|
+
)
|
|
244
|
+
const segment = RoofSegmentNode.parse({ width: 8, depth: 6, wallHeight: 0 })
|
|
245
|
+
const draft = RoofNode.parse({
|
|
246
|
+
parentId: LEVEL_ID,
|
|
247
|
+
visible: false,
|
|
248
|
+
metadata: { isNew: true },
|
|
249
|
+
children: [segment.id],
|
|
250
|
+
})
|
|
251
|
+
const building = BuildingNode.parse({ children: [LEVEL_ID] })
|
|
252
|
+
useScene.setState({
|
|
253
|
+
nodes: {
|
|
254
|
+
[building.id]: building,
|
|
255
|
+
[LEVEL_ID]: {
|
|
256
|
+
...level([...walls.map((wall) => wall.id), draft.id]),
|
|
257
|
+
parentId: building.id,
|
|
258
|
+
height: 2.5,
|
|
259
|
+
},
|
|
260
|
+
...Object.fromEntries(walls.map((wall) => [wall.id, wall])),
|
|
261
|
+
[draft.id]: draft,
|
|
262
|
+
[segment.id]: { ...segment, parentId: draft.id },
|
|
263
|
+
},
|
|
264
|
+
rootNodeIds: [building.id],
|
|
265
|
+
collections: {},
|
|
266
|
+
dirtyNodes: new Set(),
|
|
267
|
+
} as never)
|
|
268
|
+
useScene.temporal.getState().clear()
|
|
269
|
+
|
|
270
|
+
const committedId = commitFreshPlacementSubtree(draft.id, {
|
|
271
|
+
position: [4, 2.5, 3],
|
|
272
|
+
visible: true,
|
|
273
|
+
} as Partial<AnyNode>)!
|
|
274
|
+
const nodes = useScene.getState().nodes
|
|
275
|
+
const roofLevel = nodes[nodes[committedId]!.parentId as AnyNodeId] as LevelNode
|
|
276
|
+
expect(roofLevel).toMatchObject({ type: 'level', metadata: { role: 'roof' } })
|
|
277
|
+
expect(roofLevel.children as string[]).toEqual([committedId])
|
|
278
|
+
expect((nodes[LEVEL_ID] as LevelNode).children as string[]).not.toContain(committedId)
|
|
279
|
+
expect(useScene.getState().rootNodeIds).toEqual([building.id])
|
|
280
|
+
|
|
281
|
+
useScene.temporal.getState().undo()
|
|
282
|
+
expect(useScene.getState().nodes[committedId]).toBeUndefined()
|
|
283
|
+
expect(useScene.getState().nodes[roofLevel.id]).toBeUndefined()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('a window joined at a corner while in hand stays joined: its partner names the committed window', () => {
|
|
287
|
+
const wall = WallNode.parse({ id: 'wall_a', parentId: LEVEL_ID, start: [4, 0], end: [0, 0] })
|
|
288
|
+
const other = WallNode.parse({ id: 'wall_b', parentId: LEVEL_ID, start: [0, 0], end: [0, 3] })
|
|
289
|
+
const draft = WindowNode.parse({
|
|
290
|
+
id: 'window_draft',
|
|
291
|
+
parentId: wall.id,
|
|
292
|
+
wallId: wall.id,
|
|
293
|
+
position: [3.5, 1.5, 0],
|
|
294
|
+
width: 1,
|
|
295
|
+
visible: false,
|
|
296
|
+
metadata: { isNew: true },
|
|
297
|
+
corner: { end: 'end', partnerId: 'window_partner', post: 'none' },
|
|
298
|
+
})
|
|
299
|
+
const partner = WindowNode.parse({
|
|
300
|
+
id: 'window_partner',
|
|
301
|
+
parentId: other.id,
|
|
302
|
+
wallId: other.id,
|
|
303
|
+
position: [0.5, 1.5, 0],
|
|
304
|
+
width: 1,
|
|
305
|
+
corner: { end: 'start', partnerId: draft.id, post: 'none' },
|
|
306
|
+
})
|
|
307
|
+
useScene.setState({
|
|
308
|
+
nodes: {
|
|
309
|
+
[LEVEL_ID]: level([wall.id as AnyNodeId, other.id as AnyNodeId]),
|
|
310
|
+
[wall.id]: { ...wall, children: [draft.id] } as AnyNode,
|
|
311
|
+
[other.id]: { ...other, children: [partner.id] } as AnyNode,
|
|
312
|
+
[draft.id]: draft as AnyNode,
|
|
313
|
+
[partner.id]: partner as AnyNode,
|
|
314
|
+
},
|
|
315
|
+
rootNodeIds: [LEVEL_ID],
|
|
316
|
+
collections: {},
|
|
317
|
+
dirtyNodes: new Set(),
|
|
318
|
+
} as never)
|
|
319
|
+
|
|
320
|
+
const committedId = commitFreshPlacementSubtree(draft.id as AnyNodeId, { visible: true })
|
|
321
|
+
|
|
322
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
323
|
+
expect(committedId).toBeTruthy()
|
|
324
|
+
expect((nodes[committedId!] as WindowNode).corner?.partnerId).toBe(partner.id)
|
|
325
|
+
expect((nodes[partner.id] as WindowNode).corner?.partnerId).toBe(committedId!)
|
|
326
|
+
})
|
|
327
|
+
|
|
226
328
|
test("never records inside another owner's pause", () => {
|
|
227
329
|
pauseSceneHistory(useScene)
|
|
228
330
|
try {
|
|
@@ -280,6 +382,16 @@ describe('commitFreshPlacementSubtree', () => {
|
|
|
280
382
|
expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
|
|
281
383
|
})
|
|
282
384
|
|
|
385
|
+
// A copy of one side of a corner window kept naming the original's partner, which never named it
|
|
386
|
+
// back: a link that made the copy refuse to join another window.
|
|
387
|
+
test('a copy of one side of a corner window is a plain window', () => {
|
|
388
|
+
const side = WindowNode.parse({
|
|
389
|
+
corner: { end: 'end', partnerId: 'window_partner', post: 'none' },
|
|
390
|
+
})
|
|
391
|
+
expect((prepareFreshPlacementRootDuplicate(side) as WindowNode).corner).toBeUndefined()
|
|
392
|
+
expect(side.corner?.partnerId).toBe('window_partner')
|
|
393
|
+
})
|
|
394
|
+
|
|
283
395
|
test('commits a duplicated cabinet draft without deleting the original modules', () => {
|
|
284
396
|
seedCabinetRun()
|
|
285
397
|
useScene.temporal.getState().clear()
|
|
@@ -8,14 +8,17 @@ import {
|
|
|
8
8
|
collectSubtree,
|
|
9
9
|
createSceneApi,
|
|
10
10
|
type DuplicableConfig,
|
|
11
|
+
getEffectiveNode,
|
|
11
12
|
getSceneHistoryPauseDepth,
|
|
12
13
|
getSurfaceProvider,
|
|
13
14
|
nodeRegistry,
|
|
14
15
|
resolveSurfacePlacement,
|
|
15
16
|
runSceneHistoryDraftWrite,
|
|
16
17
|
type SurfaceRejectReason,
|
|
18
|
+
seatRoofs,
|
|
17
19
|
useScene,
|
|
18
20
|
} from '@pascal-app/core'
|
|
21
|
+
import { remapNodeReferences } from '@pascal-app/core/clone-scene-graph'
|
|
19
22
|
import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
20
23
|
import useInteractionScope from '../store/use-interaction-scope'
|
|
21
24
|
import usePlacementPreview from '../store/use-placement-preview'
|
|
@@ -39,6 +42,47 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined {
|
|
|
39
42
|
return parentId ?? undefined
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The nodes the draft root names, outside its subtree and other than its parent, that name it
|
|
47
|
+
* back (a corner window's partner, joined while the draft was in hand). The commit gives the
|
|
48
|
+
* root a new id, so they are rewritten to name the committed node, never the deleted draft.
|
|
49
|
+
*/
|
|
50
|
+
function peerReferenceUpdates(
|
|
51
|
+
nodes: Readonly<Record<string, AnyNode>>,
|
|
52
|
+
root: AnyNode,
|
|
53
|
+
subtreeIds: ReadonlySet<string>,
|
|
54
|
+
committedId: AnyNodeId,
|
|
55
|
+
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
|
|
56
|
+
const named = new Set<string>()
|
|
57
|
+
const visit = (value: unknown) => {
|
|
58
|
+
if (typeof value === 'string') {
|
|
59
|
+
if (nodes[value] && !subtreeIds.has(value) && value !== root.parentId) named.add(value)
|
|
60
|
+
} else if (Array.isArray(value)) value.forEach(visit)
|
|
61
|
+
else if (value && typeof value === 'object') Object.values(value).forEach(visit)
|
|
62
|
+
}
|
|
63
|
+
const {
|
|
64
|
+
id: _id,
|
|
65
|
+
parentId: _parentId,
|
|
66
|
+
children: _children,
|
|
67
|
+
...fields
|
|
68
|
+
} = root as AnyNode & {
|
|
69
|
+
children?: unknown
|
|
70
|
+
}
|
|
71
|
+
visit(fields)
|
|
72
|
+
const ids = new Map([[root.id, committedId as string]])
|
|
73
|
+
return [...named].flatMap((id) => {
|
|
74
|
+
const peer = nodes[id]!
|
|
75
|
+
const remapped = remapNodeReferences(peer, ids) as Record<string, unknown>
|
|
76
|
+
const data = Object.fromEntries(
|
|
77
|
+
Object.entries(remapped).filter(
|
|
78
|
+
([key, value]) =>
|
|
79
|
+
JSON.stringify(value) !== JSON.stringify((peer as Record<string, unknown>)[key]),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
return Object.keys(data).length ? [{ id: id as AnyNodeId, data: data as Partial<AnyNode> }] : []
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
42
86
|
/**
|
|
43
87
|
* Create ops for a clone of scene nodes: each copy joins the collections its
|
|
44
88
|
* source is in, whatever its kind.
|
|
@@ -80,6 +124,8 @@ export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
|
|
|
80
124
|
metadata?: unknown
|
|
81
125
|
}
|
|
82
126
|
delete duplicate.id
|
|
127
|
+
// A copy of one side of a corner window is a plain window: its partner stays with the original.
|
|
128
|
+
delete duplicate.corner
|
|
83
129
|
if (Array.isArray(duplicate.children)) duplicate.children = []
|
|
84
130
|
duplicate.metadata = {
|
|
85
131
|
...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
|
|
@@ -255,7 +301,9 @@ export function commitFreshPlacementSubtree(
|
|
|
255
301
|
onReject?.(rejection)
|
|
256
302
|
return null
|
|
257
303
|
}
|
|
258
|
-
const descendants = subtree.descendants.map((node) =>
|
|
304
|
+
const descendants = subtree.descendants.map((node) =>
|
|
305
|
+
cleanPlacementMetadata(getEffectiveNode(node)),
|
|
306
|
+
)
|
|
259
307
|
const parentId = parentIdOf(root)
|
|
260
308
|
const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId })
|
|
261
309
|
// The drafts' memberships, read before they are deleted.
|
|
@@ -269,6 +317,12 @@ export function commitFreshPlacementSubtree(
|
|
|
269
317
|
if (previous === -1) updates.push(merged)
|
|
270
318
|
else updates[previous] = merged
|
|
271
319
|
}
|
|
320
|
+
const subtreeIds = new Set([rootId, ...descendants.map((node) => node.id)])
|
|
321
|
+
for (const update of peerReferenceUpdates(scene.nodes, root, subtreeIds, cloned.rootId))
|
|
322
|
+
if (!updates.some((entry) => entry.id === update.id)) updates.push(update)
|
|
323
|
+
|
|
324
|
+
// A roof dropped onto the walls that carry it goes to the level above them, in the same step.
|
|
325
|
+
const seated = seatRoofs(scene.nodes, { create, update: updates, delete: [rootId] }).changes
|
|
272
326
|
|
|
273
327
|
const scope = useInteractionScope.getState()
|
|
274
328
|
const factoryCreated =
|
|
@@ -282,13 +336,13 @@ export function commitFreshPlacementSubtree(
|
|
|
282
336
|
useScene.getState().deleteNode(rootId)
|
|
283
337
|
})
|
|
284
338
|
recordPlacementStep(rootId, wasTracking, () =>
|
|
285
|
-
useScene.getState().applyNodeChanges({ create, update:
|
|
339
|
+
useScene.getState().applyNodeChanges({ create: seated.create, update: seated.update }),
|
|
286
340
|
)
|
|
287
341
|
} else {
|
|
288
342
|
recordPlacementStep(rootId, wasTracking, () => {
|
|
289
343
|
try {
|
|
290
344
|
// applyNodeChanges validates the complete proposed graph before publishing any part of it.
|
|
291
|
-
scene.applyNodeChanges(
|
|
345
|
+
scene.applyNodeChanges(seated)
|
|
292
346
|
for (const node of [subtree.root, ...subtree.descendants]) scene.clearDirty(node.id)
|
|
293
347
|
} catch (error) {
|
|
294
348
|
// Zustand publishes before notifying subscribers. A subscriber error must restore
|
|
@@ -54,6 +54,7 @@ export async function compileAndStoreGeometryScript(input: {
|
|
|
54
54
|
nodeId?: string
|
|
55
55
|
params?: Record<string, GeometryScriptParamValue>
|
|
56
56
|
kind?: 'item' | 'door' | 'window' | 'column'
|
|
57
|
+
inputs?: Record<string, unknown>
|
|
57
58
|
name?: string
|
|
58
59
|
description?: string
|
|
59
60
|
category?: string
|
|
@@ -80,7 +81,11 @@ export async function compileAndStoreGeometryScript(input: {
|
|
|
80
81
|
if (!blenderBuilder) throw new Error('This editor cannot run Blender (bpy) scripts')
|
|
81
82
|
return { ...(await blenderBuilder({ code, params, nodeId, metadata })), nodeId }
|
|
82
83
|
}
|
|
83
|
-
const { glb, ...compiled } = await compileGeometryScriptInWorker({
|
|
84
|
+
const { glb, ...compiled } = await compileGeometryScriptInWorker({
|
|
85
|
+
code,
|
|
86
|
+
params,
|
|
87
|
+
inputs: input.inputs,
|
|
88
|
+
})
|
|
84
89
|
const context = { nodeId, metadata: { ...metadata, mount: compiled.mount } }
|
|
85
90
|
const store = getArtifactStore()
|
|
86
91
|
const [sha256] = await Promise.all([
|
|
@@ -138,10 +138,17 @@ function startSandbox(): Promise<Window> {
|
|
|
138
138
|
export async function compileGeometryScriptInWorker(input: {
|
|
139
139
|
code: string
|
|
140
140
|
params?: Record<string, GeometryScriptParamValue>
|
|
141
|
+
/** What the host builds it for (a room's floor), in the host's frame. */
|
|
142
|
+
inputs?: Record<string, unknown>
|
|
141
143
|
}): Promise<GeometryScriptCompileOutput> {
|
|
142
144
|
const target = await startSandbox()
|
|
143
145
|
const id = nextId++
|
|
144
|
-
const request: GeometryScriptWorkerRequest = {
|
|
146
|
+
const request: GeometryScriptWorkerRequest = {
|
|
147
|
+
id,
|
|
148
|
+
code: input.code,
|
|
149
|
+
params: input.params,
|
|
150
|
+
inputs: input.inputs,
|
|
151
|
+
}
|
|
145
152
|
return new Promise((resolve, reject) => {
|
|
146
153
|
const timeout = setTimeout(() => {
|
|
147
154
|
reset(new Error(`The script ran longer than ${COMPILE_TIMEOUT_MS / 1000} s and was stopped`))
|