@pascal-app/editor 1.0.4 → 1.0.5
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 +39 -37
- 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
|
@@ -6,14 +6,18 @@ import {
|
|
|
6
6
|
generateSceneMaterialId,
|
|
7
7
|
getArtifactStore,
|
|
8
8
|
isDerivedNode,
|
|
9
|
+
isStoreyContent,
|
|
9
10
|
type LevelNode,
|
|
10
11
|
nodeRegistry,
|
|
11
12
|
type ParsedBuildJson,
|
|
12
13
|
remapMeasurementReferences,
|
|
14
|
+
runAsSingleSceneHistoryStep,
|
|
13
15
|
SceneMaterial,
|
|
14
16
|
type SceneMaterialId,
|
|
15
17
|
type StairNode,
|
|
16
18
|
scriptedSize,
|
|
19
|
+
seatRoofs,
|
|
20
|
+
storeyOf,
|
|
17
21
|
useScene,
|
|
18
22
|
} from '@pascal-app/core'
|
|
19
23
|
import { clampDoorToWall, clampWindowToWall } from '@pascal-app/core/building'
|
|
@@ -258,6 +262,13 @@ function remapNodeReferences(
|
|
|
258
262
|
}
|
|
259
263
|
}
|
|
260
264
|
|
|
265
|
+
// A corner window pasted with its partner joins the partner's copy; pasted alone, it is plain.
|
|
266
|
+
if (clone.type === 'window' && clone.corner) {
|
|
267
|
+
const partnerId = idMap.get(clone.corner.partnerId as AnyNodeId)
|
|
268
|
+
if (partnerId) clone.corner = { ...clone.corner, partnerId }
|
|
269
|
+
else delete clone.corner
|
|
270
|
+
}
|
|
271
|
+
|
|
261
272
|
if (clone.type === 'stair') {
|
|
262
273
|
const nextLevelId = getNextLevelId(targetLevel, nodes)
|
|
263
274
|
;(clone as StairNode).fromLevelId = targetLevel.id
|
|
@@ -477,11 +488,17 @@ export async function pasteSystemEditorClipboardToLevel(
|
|
|
477
488
|
return applyClipboardPayloadToLevel(payload, targetLevel.id, refused)
|
|
478
489
|
}
|
|
479
490
|
|
|
491
|
+
/** The script and GLB a scripted node, a room's floor script or a wall's face scripts reference. */
|
|
480
492
|
function artifactHashes(node: AnyNode): string[] {
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
493
|
+
const sources =
|
|
494
|
+
node.type === 'zone'
|
|
495
|
+
? [node.floor?.script]
|
|
496
|
+
: node.type === 'wall'
|
|
497
|
+
? [node.finishScripts?.a, node.finishScripts?.b]
|
|
498
|
+
: ['source' in node ? node.source : undefined]
|
|
499
|
+
return sources.flatMap((source) =>
|
|
500
|
+
typeof source === 'object' && source?.kind === 'script' ? [source.script, source.artifact] : [],
|
|
501
|
+
)
|
|
485
502
|
}
|
|
486
503
|
|
|
487
504
|
/**
|
|
@@ -582,11 +599,21 @@ function applyClipboardPayloadToLevel(
|
|
|
582
599
|
targetLevelId?: AnyNodeId,
|
|
583
600
|
{ ids: refused, reason: refusal }: Refused = NONE_REFUSED,
|
|
584
601
|
): PasteResult | null {
|
|
585
|
-
const
|
|
586
|
-
if (!
|
|
602
|
+
const aimedLevel = getPasteTargetLevel(targetLevelId)
|
|
603
|
+
if (!aimedLevel) return null
|
|
587
604
|
const payload = refused.size > 0 ? withoutRefusedSubtrees(copied, refused) : copied
|
|
588
605
|
|
|
589
606
|
const scene = useScene.getState()
|
|
607
|
+
// A paste lands on one level. On a roof level, one carrying anything a storey holds (walls,
|
|
608
|
+
// rooms, items, cabinets, …) goes to the storey it covers, whole: the row in view was the roof's,
|
|
609
|
+
// the plan is the storey's, and a roof in it that bears there is seated back up.
|
|
610
|
+
const rootIdSet = new Set(payload.rootIds)
|
|
611
|
+
const targetLevel = payload.nodes.some(
|
|
612
|
+
(node) => rootIdSet.has(node.id as AnyNodeId) && isStoreyContent(node.type, node.metadata),
|
|
613
|
+
)
|
|
614
|
+
? ((scene.nodes[storeyOf(scene.nodes, aimedLevel.id) as AnyNodeId] as LevelNode | undefined) ??
|
|
615
|
+
aimedLevel)
|
|
616
|
+
: aimedLevel
|
|
590
617
|
const idMap = new Map<AnyNodeId, AnyNodeId>()
|
|
591
618
|
const materialIdMap = new Map<SceneMaterialId, SceneMaterialId>()
|
|
592
619
|
const materialsToCreate: SceneMaterial[] = []
|
|
@@ -607,7 +634,6 @@ function applyClipboardPayloadToLevel(
|
|
|
607
634
|
materialsToCreate.push({ ...material, id: nextId })
|
|
608
635
|
}
|
|
609
636
|
|
|
610
|
-
const rootIdSet = new Set(payload.rootIds)
|
|
611
637
|
const pastedNodes: AnyNode[] = []
|
|
612
638
|
const skippedIds: AnyNodeId[] = []
|
|
613
639
|
|
|
@@ -646,12 +672,10 @@ function applyClipboardPayloadToLevel(
|
|
|
646
672
|
}
|
|
647
673
|
}
|
|
648
674
|
|
|
649
|
-
for (const material of materialsToCreate) {
|
|
650
|
-
scene.addSceneMaterial(material)
|
|
651
|
-
}
|
|
652
675
|
const sourceIds = new Map([...idMap].map(([source, copy]) => [copy, source]))
|
|
653
|
-
|
|
654
|
-
|
|
676
|
+
// A roof pasted onto the walls that carry it goes to the level above them, in the paste itself.
|
|
677
|
+
const { changes } = seatRoofs(scene.nodes, {
|
|
678
|
+
create: pastedNodes.map((node) => ({
|
|
655
679
|
node,
|
|
656
680
|
parentId: (node.parentId as AnyNodeId | null) ?? undefined,
|
|
657
681
|
// A copy is in its source's collections; an item brings them in its own `collectionIds`.
|
|
@@ -659,15 +683,21 @@ function applyClipboardPayloadToLevel(
|
|
|
659
683
|
collectionIds: collectionIdsOf(scene.collections, sourceIds.get(node.id as AnyNodeId)!),
|
|
660
684
|
}),
|
|
661
685
|
})),
|
|
662
|
-
)
|
|
686
|
+
})
|
|
687
|
+
runAsSingleSceneHistoryStep(useScene, () => {
|
|
688
|
+
for (const material of materialsToCreate) scene.addSceneMaterial(material)
|
|
689
|
+
scene.applyNodeChanges(changes)
|
|
690
|
+
})
|
|
663
691
|
|
|
692
|
+
const nodes = useScene.getState().nodes
|
|
664
693
|
const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
|
|
665
694
|
const pastedRootIds = payload.rootIds
|
|
666
695
|
.map((rootId) => idMap.get(rootId))
|
|
667
696
|
.filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
|
|
697
|
+
const rootLevel = nodes[(nodes[pastedRootIds[0]!]?.parentId ?? '') as AnyNodeId]
|
|
668
698
|
|
|
669
699
|
useViewer.getState().setSelection({
|
|
670
|
-
levelId: targetLevel.id,
|
|
700
|
+
levelId: rootLevel?.type === 'level' ? rootLevel.id : targetLevel.id,
|
|
671
701
|
selectedIds: pastedRootIds,
|
|
672
702
|
})
|
|
673
703
|
|
|
@@ -98,7 +98,7 @@ export function resolveCanvasSelectionNode({
|
|
|
98
98
|
const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node
|
|
99
99
|
let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget
|
|
100
100
|
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
|
|
101
|
-
if (parentFrame) {
|
|
101
|
+
if (parentFrame && !parentFrame.independent) {
|
|
102
102
|
const parent = parentFrame.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
|
|
103
103
|
if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) {
|
|
104
104
|
target = parent
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -28,6 +28,9 @@ type SFXEvents = {
|
|
|
28
28
|
'sfx:menu-click': undefined
|
|
29
29
|
'sfx:paint-apply': undefined
|
|
30
30
|
'sfx:success': undefined
|
|
31
|
+
'sfx:turn-done': undefined
|
|
32
|
+
'sfx:turn-failed': undefined
|
|
33
|
+
'sfx:turn-needs-input': undefined
|
|
31
34
|
'sfx:terrain-sculpt-start': TerrainVerb
|
|
32
35
|
'sfx:terrain-sculpt-stop': undefined
|
|
33
36
|
}
|
|
@@ -58,6 +61,9 @@ const handleMenuHover = () => playSFX('menuHover')
|
|
|
58
61
|
const handleMenuClick = () => playSFX('menuClick')
|
|
59
62
|
const handlePaintApply = () => playSFX('paintApply')
|
|
60
63
|
const handleSuccess = () => playSFX('success')
|
|
64
|
+
const handleTurnDone = () => playSFX('turnDone')
|
|
65
|
+
const handleTurnFailed = () => playSFX('turnFailed')
|
|
66
|
+
const handleTurnNeedsInput = () => playSFX('turnNeedsInput')
|
|
61
67
|
const TERRAIN_LOOP_BY_VERB = {
|
|
62
68
|
raise: 'terrainRaise',
|
|
63
69
|
lower: 'terrainLower',
|
|
@@ -89,9 +95,13 @@ export function initSFXBus() {
|
|
|
89
95
|
sfxEmitter.on('sfx:menu-click', handleMenuClick)
|
|
90
96
|
sfxEmitter.on('sfx:paint-apply', handlePaintApply)
|
|
91
97
|
sfxEmitter.on('sfx:success', handleSuccess)
|
|
98
|
+
sfxEmitter.on('sfx:turn-done', handleTurnDone)
|
|
99
|
+
sfxEmitter.on('sfx:turn-failed', handleTurnFailed)
|
|
100
|
+
sfxEmitter.on('sfx:turn-needs-input', handleTurnNeedsInput)
|
|
92
101
|
sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
93
102
|
sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
94
103
|
unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
|
|
104
|
+
updateSFXVolumes()
|
|
95
105
|
}
|
|
96
106
|
|
|
97
107
|
export function disposeSFXBus() {
|
|
@@ -110,6 +120,9 @@ export function disposeSFXBus() {
|
|
|
110
120
|
sfxEmitter.off('sfx:menu-click', handleMenuClick)
|
|
111
121
|
sfxEmitter.off('sfx:paint-apply', handlePaintApply)
|
|
112
122
|
sfxEmitter.off('sfx:success', handleSuccess)
|
|
123
|
+
sfxEmitter.off('sfx:turn-done', handleTurnDone)
|
|
124
|
+
sfxEmitter.off('sfx:turn-failed', handleTurnFailed)
|
|
125
|
+
sfxEmitter.off('sfx:turn-needs-input', handleTurnNeedsInput)
|
|
113
126
|
sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
114
127
|
sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
115
128
|
unsubscribeAudio?.()
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import useAudio from '../store/use-audio'
|
|
2
3
|
|
|
3
4
|
type FakeContext = { id: string }
|
|
4
5
|
|
|
5
6
|
let activeContext: FakeContext = { id: 'first' }
|
|
6
7
|
let initialState: 'loaded' | 'loading' = 'loaded'
|
|
7
|
-
let muted = false
|
|
8
8
|
let throwOnPlay = false
|
|
9
9
|
const instances: FakeHowl[] = []
|
|
10
10
|
|
|
@@ -92,12 +92,6 @@ const fakeHowler = {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
|
|
95
|
-
mock.module('../store/use-audio', () => ({
|
|
96
|
-
default: {
|
|
97
|
-
getState: () => ({ masterVolume: 100, muted, sfxVolume: 100 }),
|
|
98
|
-
subscribe: () => () => {},
|
|
99
|
-
},
|
|
100
|
-
}))
|
|
101
95
|
|
|
102
96
|
const { disposeSFX, playSFX, preloadSFX, startLoopSFX, stopLoopSFX, updateSFXVolumes } =
|
|
103
97
|
await import('./sfx-player')
|
|
@@ -107,7 +101,7 @@ beforeEach(() => {
|
|
|
107
101
|
disposeSFXBus()
|
|
108
102
|
activeContext = { id: 'first' }
|
|
109
103
|
initialState = 'loaded'
|
|
110
|
-
muted
|
|
104
|
+
useAudio.setState({ masterVolume: 100, muted: false, sfxVolume: 100 })
|
|
111
105
|
throwOnPlay = false
|
|
112
106
|
instances.length = 0
|
|
113
107
|
})
|
|
@@ -118,6 +112,51 @@ afterAll(() => {
|
|
|
118
112
|
})
|
|
119
113
|
|
|
120
114
|
describe('SFX audio context lifecycle', () => {
|
|
115
|
+
test('editor startup warms assets without playing and repeated init does not duplicate delivery', () => {
|
|
116
|
+
initSFXBus()
|
|
117
|
+
const warmed = [...instances]
|
|
118
|
+
expect(warmed.length).toBeGreaterThan(0)
|
|
119
|
+
expect(warmed.every((sound) => sound.playCount === 0)).toBe(true)
|
|
120
|
+
initSFXBus()
|
|
121
|
+
expect(instances).toHaveLength(warmed.length)
|
|
122
|
+
triggerSFX('sfx:item-place')
|
|
123
|
+
expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a one-shot requested before loading is dropped, not replayed on load', () => {
|
|
127
|
+
initialState = 'loading'
|
|
128
|
+
initSFXBus()
|
|
129
|
+
triggerSFX('sfx:item-place')
|
|
130
|
+
for (const sound of instances) sound.finishLoading()
|
|
131
|
+
expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
|
|
132
|
+
// A different fresh cue avoids relying on the per-name repeat clock.
|
|
133
|
+
triggerSFX('sfx:structure-build')
|
|
134
|
+
expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('muted startup neither starts audio nor consumes a later fresh cue', () => {
|
|
138
|
+
useAudio.setState({ muted: true })
|
|
139
|
+
initSFXBus()
|
|
140
|
+
triggerSFX('sfx:item-place')
|
|
141
|
+
expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
|
|
142
|
+
useAudio.setState({ muted: false })
|
|
143
|
+
updateSFXVolumes()
|
|
144
|
+
triggerSFX('sfx:item-place')
|
|
145
|
+
expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('cleanup detaches delivery and unloads resources once, then remount plays once', () => {
|
|
149
|
+
initSFXBus()
|
|
150
|
+
const warmed = [...instances]
|
|
151
|
+
disposeSFXBus()
|
|
152
|
+
disposeSFXBus()
|
|
153
|
+
triggerSFX('sfx:item-place')
|
|
154
|
+
expect(warmed.every((sound) => sound.playCount === 0 && sound.unloadCount === 1)).toBe(true)
|
|
155
|
+
initSFXBus()
|
|
156
|
+
triggerSFX('sfx:item-place')
|
|
157
|
+
expect(instances.reduce((sum, sound) => sum + sound.playCount, 0)).toBe(1)
|
|
158
|
+
})
|
|
159
|
+
|
|
121
160
|
test('reuses the preloaded cache while the Howler context is unchanged', () => {
|
|
122
161
|
preloadSFX()
|
|
123
162
|
const initialCount = instances.length
|
|
@@ -234,7 +273,7 @@ describe('SFX audio context lifecycle', () => {
|
|
|
234
273
|
startLoopSFX('terrainLower')
|
|
235
274
|
const lower = instances.find((sound) => sound.loop && sound.playCount === 1)
|
|
236
275
|
|
|
237
|
-
muted
|
|
276
|
+
useAudio.setState({ muted: true })
|
|
238
277
|
updateSFXVolumes()
|
|
239
278
|
|
|
240
279
|
expect(lower?.fadeCalls.at(-1)).toEqual([0.2, 0, 90, 1])
|
|
@@ -263,4 +302,26 @@ describe('SFX audio context lifecycle', () => {
|
|
|
263
302
|
|
|
264
303
|
expect(lower?.playCount).toBe(0)
|
|
265
304
|
})
|
|
305
|
+
|
|
306
|
+
test('turn cues play at the master and effects volume, and not in a muted tab', () => {
|
|
307
|
+
const cues = ['sfx:turn-done', 'sfx:turn-failed', 'sfx:turn-needs-input'] as const
|
|
308
|
+
useAudio.setState({ masterVolume: 50, muted: true, sfxVolume: 40 })
|
|
309
|
+
initSFXBus()
|
|
310
|
+
|
|
311
|
+
for (const cue of cues) triggerSFX(cue)
|
|
312
|
+
expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
|
|
313
|
+
|
|
314
|
+
useAudio.setState({ muted: false })
|
|
315
|
+
for (const cue of cues) triggerSFX(cue)
|
|
316
|
+
|
|
317
|
+
const played = instances.filter((sound) => sound.playCount === 1)
|
|
318
|
+
expect(played.map((sound) => sound.src)).toEqual([
|
|
319
|
+
'/audios/sfx/turn_done.mp3',
|
|
320
|
+
'/audios/sfx/turn_failed.mp3',
|
|
321
|
+
'/audios/sfx/turn_needs_input.mp3',
|
|
322
|
+
])
|
|
323
|
+
for (const sound of played) {
|
|
324
|
+
expect(sound.volumeCalls.find(([, id]) => id === 1)?.[0]).toBeCloseTo(0.2)
|
|
325
|
+
}
|
|
326
|
+
})
|
|
266
327
|
})
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -120,6 +120,12 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
120
120
|
src: '/audios/sfx/success.mp3',
|
|
121
121
|
minIntervalMs: 3_000,
|
|
122
122
|
},
|
|
123
|
+
// An agent's turn, heard from another tab or another app: it ended with its work done, it failed,
|
|
124
|
+
// or it waits on the person. Two-note chimes (`scripts/synth-turn-cues.ts`), so no jitter, as for
|
|
125
|
+
// `success`. The host plays each once per turn; no gap here could tell two turns from one.
|
|
126
|
+
turnDone: { src: '/audios/sfx/turn_done.mp3' },
|
|
127
|
+
turnFailed: { src: '/audios/sfx/turn_failed.mp3' },
|
|
128
|
+
turnNeedsInput: { src: '/audios/sfx/turn_needs_input.mp3' },
|
|
123
129
|
} as const
|
|
124
130
|
|
|
125
131
|
export type SFXName = keyof typeof SFX
|
|
@@ -37,6 +37,31 @@ describe('stair level helpers', () => {
|
|
|
37
37
|
expect(plan?.createdLevel?.parentId).toBe(building.id)
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
+
// Every generated one-storey house has a Roof level: the stair tool sent its flight up into it.
|
|
41
|
+
test('climbs to a new storey under the roof level, which moves up over it', () => {
|
|
42
|
+
const ground = LevelNode.parse({ level: 0, children: [] })
|
|
43
|
+
const roofLevel = LevelNode.parse({ level: 1, children: [], metadata: { role: 'roof' } })
|
|
44
|
+
const building = BuildingNode.parse({ children: [ground.id, roofLevel.id] })
|
|
45
|
+
const nodes = {
|
|
46
|
+
[building.id]: building,
|
|
47
|
+
[ground.id]: { ...ground, parentId: building.id },
|
|
48
|
+
[roofLevel.id]: { ...roofLevel, parentId: building.id },
|
|
49
|
+
} as Record<AnyNodeId, AnyNode>
|
|
50
|
+
|
|
51
|
+
const plan = resolveStairDestinationLevel({
|
|
52
|
+
createMissing: true,
|
|
53
|
+
fromLevelId: ground.id,
|
|
54
|
+
nodes,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(plan?.toLevel.id).toBe(plan?.createdLevel?.id)
|
|
58
|
+
expect(plan?.createdLevel?.level).toBe(1)
|
|
59
|
+
expect(plan?.liftedLevels).toEqual([{ id: roofLevel.id, data: { level: 2 } }])
|
|
60
|
+
expect(resolveStairDestinationLevel({ fromLevelId: ground.id, nodes })?.toLevel.id).toBe(
|
|
61
|
+
ground.id,
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
40
65
|
test('uses the nearest higher sibling level instead of creating one', () => {
|
|
41
66
|
const building = BuildingNode.parse({})
|
|
42
67
|
const ground = LevelNode.parse({ level: 0, parentId: building.id })
|
package/src/lib/stair-levels.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
type LevelNode as LevelNodeType,
|
|
7
7
|
resolveBuildingForLevel,
|
|
8
8
|
type StairNode,
|
|
9
|
+
servesAsRoofLevel,
|
|
10
|
+
storeyInsertIndex,
|
|
9
11
|
} from '@pascal-app/core'
|
|
10
12
|
|
|
11
13
|
function sortLevelsByHeight(levels: LevelNodeType[]) {
|
|
@@ -141,30 +143,46 @@ export function resolveStairDestinationLevel({
|
|
|
141
143
|
nodes as Record<AnyNodeId, AnyNode>,
|
|
142
144
|
)
|
|
143
145
|
const levels = getBuildingLevelsForLevel(nodes, fromLevel.id)
|
|
144
|
-
|
|
146
|
+
// A stair rises to a storey: the roof level over the top one is not a floor to climb to.
|
|
147
|
+
const nextExistingLevel =
|
|
148
|
+
levels.find((level) => level.level > fromLevel.level && !servesAsRoofLevel(nodes, level)) ??
|
|
149
|
+
null
|
|
145
150
|
if (nextExistingLevel) {
|
|
146
151
|
return {
|
|
147
152
|
buildingId,
|
|
148
153
|
createdLevel: null,
|
|
149
154
|
fromLevel,
|
|
150
155
|
levels,
|
|
156
|
+
liftedLevels: [],
|
|
151
157
|
toLevel: nextExistingLevel,
|
|
152
158
|
}
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
if (createMissing && buildingId) {
|
|
162
|
+
// The new storey goes under the roof level, which moves up over it.
|
|
163
|
+
const { floorIndex, shift } = storeyInsertIndex(nodes, buildingId)
|
|
156
164
|
const createdLevel = LevelNode.parse({
|
|
157
165
|
children: [],
|
|
158
166
|
height: DEFAULT_LEVEL_HEIGHT,
|
|
159
|
-
level:
|
|
167
|
+
level: floorIndex,
|
|
160
168
|
parentId: buildingId,
|
|
161
169
|
})
|
|
170
|
+
const lifted = new Map(shift.map(({ id, data }) => [id, data.level]))
|
|
162
171
|
|
|
163
172
|
return {
|
|
164
173
|
buildingId,
|
|
165
174
|
createdLevel,
|
|
166
175
|
fromLevel,
|
|
167
|
-
levels: sortLevelsByHeight([
|
|
176
|
+
levels: sortLevelsByHeight([
|
|
177
|
+
...levels.map((level) =>
|
|
178
|
+
lifted.has(level.id) ? { ...level, level: lifted.get(level.id)! } : level,
|
|
179
|
+
),
|
|
180
|
+
createdLevel,
|
|
181
|
+
]),
|
|
182
|
+
liftedLevels: shift.map(({ id, data }) => ({
|
|
183
|
+
id: id as AnyNodeId,
|
|
184
|
+
data: data as Partial<AnyNode>,
|
|
185
|
+
})),
|
|
168
186
|
toLevel: createdLevel,
|
|
169
187
|
}
|
|
170
188
|
}
|
|
@@ -174,6 +192,7 @@ export function resolveStairDestinationLevel({
|
|
|
174
192
|
createdLevel: null,
|
|
175
193
|
fromLevel,
|
|
176
194
|
levels,
|
|
195
|
+
liftedLevels: [],
|
|
177
196
|
toLevel: fromLevel,
|
|
178
197
|
}
|
|
179
198
|
}
|
package/src/lib/terrace-draft.ts
CHANGED
|
@@ -53,7 +53,8 @@ const OPEN_GROUND = 500
|
|
|
53
53
|
* Why a terrace outline cannot be built, or its plan: an outdoor room of the
|
|
54
54
|
* outline, closed with separators where no wall runs, without a ceiling.
|
|
55
55
|
* Core refuses an outline over a room (the same rule MCP's outdoor
|
|
56
|
-
* `create_room` follows) and names the room
|
|
56
|
+
* `create_room` follows) and names the room, except the numbered room its
|
|
57
|
+
* walls enclose exactly: a walled courtyard, which becomes the terrace.
|
|
57
58
|
*/
|
|
58
59
|
export function planTerrace(
|
|
59
60
|
nodes: Record<string, AnyNode>,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
Sphere,
|
|
28
28
|
Vector3,
|
|
29
29
|
} from 'three'
|
|
30
|
+
import { useBelowGrade } from './below-grade'
|
|
30
31
|
import { nodeDisplayLabel } from './node-display-label'
|
|
31
32
|
import { roomFloorElevation } from './room-handle-drag'
|
|
32
33
|
import { RoomSelectionIndex, type RoomSelectionRecord, resolveRoomHit } from './room-selection'
|
|
@@ -222,13 +223,15 @@ export function resolveViewerHover(
|
|
|
222
223
|
|
|
223
224
|
/**
|
|
224
225
|
* The current floor reads as the editor shows it: the levels above it step
|
|
225
|
-
* aside (in the stacked display) so its rooms are in view and in reach
|
|
226
|
-
* it
|
|
227
|
-
* hands the flag to the other in
|
|
226
|
+
* aside (in the stacked display) so its rooms are in view and in reach, and the
|
|
227
|
+
* ground is veiled over it when it lies below grade. Mount it in the surface's DOM tree,
|
|
228
|
+
* beside the editor's own level display, so one hands the flag to the other in
|
|
229
|
+
* order.
|
|
228
230
|
*/
|
|
229
231
|
export function useViewerFloorDisplay() {
|
|
230
232
|
// A walkthrough tours the whole building.
|
|
231
233
|
const walking = useViewer((s) => s.walkthroughMode)
|
|
234
|
+
useBelowGrade(!walking)
|
|
232
235
|
useEffect(() => {
|
|
233
236
|
if (walking) return
|
|
234
237
|
useViewer.setState({ hideLevelsAboveSelection: true })
|
|
@@ -344,14 +347,13 @@ function expandByZone(target: Box3, zone: ZoneNode, source: ViewerSource) {
|
|
|
344
347
|
}
|
|
345
348
|
}
|
|
346
349
|
|
|
347
|
-
/** The frame request for a node: its saved view
|
|
350
|
+
/** The frame request for a node: the box it spans, and its saved view when it has one. */
|
|
348
351
|
function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameEvent | null {
|
|
349
352
|
const node = source.nodes[id]
|
|
350
353
|
if (!node) return null
|
|
351
354
|
const saved = node.camera
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
+
const pose =
|
|
356
|
+
saved?.position && saved.target ? { position: saved.position, target: saved.target } : null
|
|
355
357
|
box.makeEmpty()
|
|
356
358
|
if (node.type === 'zone') expandByZone(box, node, source)
|
|
357
359
|
else if (node.type === 'unit') {
|
|
@@ -363,8 +365,16 @@ function viewerFrameEvent(id: string, source: ViewerSource): CameraControlFrameE
|
|
|
363
365
|
const object = source.objectFor(id)
|
|
364
366
|
if (object) box.setFromObject(object)
|
|
365
367
|
}
|
|
366
|
-
|
|
367
|
-
return { bounds
|
|
368
|
+
const bounds = box.isEmpty() ? null : { min: box.min.toArray(), max: box.max.toArray() }
|
|
369
|
+
if (pose) return { bounds, pose }
|
|
370
|
+
return bounds ? { bounds } : null
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Framing glides to its pose, or jumps there when the person asks for reduced motion. */
|
|
374
|
+
export function framingAnimates(): boolean {
|
|
375
|
+
return !(
|
|
376
|
+
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
377
|
+
)
|
|
368
378
|
}
|
|
369
379
|
|
|
370
380
|
export function frameViewerNode(id: string, source: ViewerSource) {
|
|
@@ -426,13 +436,14 @@ export function frameViewerCamera(
|
|
|
426
436
|
: undefined,
|
|
427
437
|
)
|
|
428
438
|
if (!pose) return
|
|
429
|
-
|
|
439
|
+
const animate = framingAnimates()
|
|
440
|
+
control.setLookAt(...pose.position, ...pose.target, animate)
|
|
430
441
|
if ((camera as OrthographicCamera).isOrthographicCamera && event.bounds && !event.pose) {
|
|
431
442
|
box.min.set(...event.bounds.min)
|
|
432
443
|
box.max.set(...event.bounds.max)
|
|
433
444
|
box.getBoundingSphere(frameSphere)
|
|
434
445
|
frameSphere.radius = Math.max(frameSphere.radius * 1.3, 1)
|
|
435
|
-
control.fitToSphere(frameSphere,
|
|
446
|
+
control.fitToSphere(frameSphere, animate)
|
|
436
447
|
}
|
|
437
448
|
}
|
|
438
449
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { Group, Vector3 } from 'three'
|
|
3
|
+
import {
|
|
4
|
+
createWallPointerTracker,
|
|
5
|
+
wallPointerInParent,
|
|
6
|
+
wallSideAtPointer,
|
|
7
|
+
} from './wall-placement-pointer'
|
|
8
|
+
|
|
9
|
+
test('keeps the grab offset across a pointer gap on the original face', () => {
|
|
10
|
+
const pointer = createWallPointerTracker({ wallId: 'wall_a', side: 'front', position: [4, 2] })
|
|
11
|
+
expect(pointer.resolve('wall_a', 'front', [3, 1])).toEqual([4, 2])
|
|
12
|
+
pointer.leave()
|
|
13
|
+
expect(pointer.resolve('wall_a', 'front', [5, 3])).toEqual([6, 4])
|
|
14
|
+
})
|
|
15
|
+
test('a different wall or face permanently releases the old grab offset', () => {
|
|
16
|
+
for (const [wall, side] of [
|
|
17
|
+
['wall_b', 'front'],
|
|
18
|
+
['wall_a', 'back'],
|
|
19
|
+
] as const) {
|
|
20
|
+
const pointer = createWallPointerTracker({ wallId: 'wall_a', side: 'front', position: [4, 2] })
|
|
21
|
+
pointer.resolve('wall_a', 'front', [3, 1])
|
|
22
|
+
expect(pointer.resolve(wall, side, [5, 3])).toEqual([5, 3])
|
|
23
|
+
expect(pointer.resolve('wall_a', 'front', [6, 4])).toEqual([6, 4])
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
test('a committed floor placement releases the original wall grab', () => {
|
|
27
|
+
const pointer = createWallPointerTracker({ wallId: 'wall_a', position: [4, 2] })
|
|
28
|
+
pointer.resolve('wall_a', 'front', [3, 1])
|
|
29
|
+
pointer.forget()
|
|
30
|
+
expect(pointer.resolve('wall_a', 'front', [5, 3])).toEqual([5, 3])
|
|
31
|
+
})
|
|
32
|
+
test('reads the pointer in the rotated wall parent and identifies its face', () => {
|
|
33
|
+
const parent = new Group()
|
|
34
|
+
parent.position.set(3, 1, 5)
|
|
35
|
+
parent.rotation.y = Math.PI / 2
|
|
36
|
+
parent.updateMatrixWorld(true)
|
|
37
|
+
const world = parent.localToWorld(new Vector3(2, 3, -0.1))
|
|
38
|
+
const local = wallPointerInParent(world.toArray(), parent)
|
|
39
|
+
expect(local[0]).toBeCloseTo(2)
|
|
40
|
+
expect(local[1]).toBeCloseTo(3)
|
|
41
|
+
expect(wallSideAtPointer(local)).toBe('back')
|
|
42
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Object3D, Vector3 } from 'three'
|
|
2
|
+
|
|
3
|
+
type WallSide = 'front' | 'back'
|
|
4
|
+
type Point = readonly [number, number]
|
|
5
|
+
|
|
6
|
+
/** Keep the grabbed point only on its original face; new hosts follow the cursor. */
|
|
7
|
+
export function createWallPointerTracker(source?: {
|
|
8
|
+
wallId: string | null
|
|
9
|
+
side?: WallSide
|
|
10
|
+
position: readonly number[]
|
|
11
|
+
}) {
|
|
12
|
+
let forgotten = !source
|
|
13
|
+
let grabOffset: { x: number; y: number } | null = null
|
|
14
|
+
let anchor: { wallId: string; side: WallSide; x: number; y: number } | null = null
|
|
15
|
+
return {
|
|
16
|
+
resolve(wallId: string, side: WallSide, point: Point): [number, number] {
|
|
17
|
+
if (!anchor || anchor.wallId !== wallId || anchor.side !== side) {
|
|
18
|
+
const preserve =
|
|
19
|
+
!forgotten && source?.wallId === wallId && (!source.side || source.side === side)
|
|
20
|
+
if (!preserve) forgotten = true
|
|
21
|
+
if (preserve && !grabOffset) {
|
|
22
|
+
grabOffset = { x: source!.position[0]! - point[0], y: source!.position[1]! - point[1] }
|
|
23
|
+
}
|
|
24
|
+
anchor = {
|
|
25
|
+
wallId,
|
|
26
|
+
side,
|
|
27
|
+
x: preserve ? grabOffset!.x : 0,
|
|
28
|
+
y: preserve ? grabOffset!.y : 0,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [point[0] + anchor.x, point[1] + anchor.y]
|
|
32
|
+
},
|
|
33
|
+
leave() {
|
|
34
|
+
anchor = null
|
|
35
|
+
},
|
|
36
|
+
forget() {
|
|
37
|
+
anchor = null
|
|
38
|
+
grabOffset = null
|
|
39
|
+
forgotten = true
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Measure the hit in the same parent frame that renders the placed child. */
|
|
45
|
+
export function wallPointerInParent(
|
|
46
|
+
worldPoint: readonly number[],
|
|
47
|
+
parent: Object3D,
|
|
48
|
+
): [number, number, number] {
|
|
49
|
+
parent.updateWorldMatrix(true, false)
|
|
50
|
+
const point = parent.worldToLocal(new Vector3(worldPoint[0], worldPoint[1], worldPoint[2]))
|
|
51
|
+
return [point.x, point.y, point.z]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Collision mesh winding can differ from the wall frame; the hit identifies the touched side. */
|
|
55
|
+
export function wallSideAtPointer(point: readonly number[]): WallSide {
|
|
56
|
+
return point[2]! >= 0 ? 'front' : 'back'
|
|
57
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type AnyNode, type AnyNodeId, useScene, type WindowNode } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write what Wrap the corner, Merge windows or a placement at a corner planned: one batch, so one
|
|
5
|
+
* undo step, with the windows and the walls that carry them marked for rebuild.
|
|
6
|
+
*/
|
|
7
|
+
export function writeWindowChanges(changes: {
|
|
8
|
+
create: WindowNode | null
|
|
9
|
+
updates: Record<string, Partial<WindowNode>>
|
|
10
|
+
remove: readonly string[]
|
|
11
|
+
}) {
|
|
12
|
+
const scene = useScene.getState()
|
|
13
|
+
const parents = new Set<string>()
|
|
14
|
+
for (const id of [...Object.keys(changes.updates), ...changes.remove]) {
|
|
15
|
+
const parentId = scene.nodes[id as AnyNodeId]?.parentId
|
|
16
|
+
if (parentId) parents.add(parentId)
|
|
17
|
+
}
|
|
18
|
+
scene.applyNodeChanges({
|
|
19
|
+
create: changes.create
|
|
20
|
+
? [{ node: changes.create, parentId: changes.create.parentId as AnyNodeId }]
|
|
21
|
+
: [],
|
|
22
|
+
update: Object.entries(changes.updates).map(([id, data]) => ({
|
|
23
|
+
id: id as AnyNodeId,
|
|
24
|
+
data: data as Partial<AnyNode>,
|
|
25
|
+
})),
|
|
26
|
+
delete: changes.remove as AnyNodeId[],
|
|
27
|
+
})
|
|
28
|
+
const after = useScene.getState()
|
|
29
|
+
for (const id of Object.keys(changes.updates)) after.dirtyNodes.add(id as AnyNodeId)
|
|
30
|
+
if (changes.create) {
|
|
31
|
+
after.dirtyNodes.add(changes.create.id as AnyNodeId)
|
|
32
|
+
if (changes.create.parentId) parents.add(changes.create.parentId)
|
|
33
|
+
}
|
|
34
|
+
for (const id of parents) after.dirtyNodes.add(id as AnyNodeId)
|
|
35
|
+
}
|