@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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
isRegionBigEnough,
|
|
4
|
+
rankInRegion,
|
|
5
|
+
rectFromDrag,
|
|
6
|
+
regionFractions,
|
|
7
|
+
regionSizeMetres,
|
|
8
|
+
} from './region'
|
|
9
|
+
|
|
10
|
+
// A region is a view, not a selection (spec 2.3): a rectangle dragged on the canvas, its size in
|
|
11
|
+
// metres shown live, what lies inside it ranked for the agent. Written first: a rectangle dragged
|
|
12
|
+
// right to left coming out inverted; a rectangle past the canvas edge; a speck of a drag taken as
|
|
13
|
+
// a region; a size measured through a ray that points at the sky; the ranking leaning on elements
|
|
14
|
+
// that merely touch the rectangle.
|
|
15
|
+
|
|
16
|
+
const canvas = { x0: 0, y0: 0, x1: 1000, y1: 600 }
|
|
17
|
+
|
|
18
|
+
describe('rectFromDrag', () => {
|
|
19
|
+
test('is the same rectangle whichever corner the drag starts from', () => {
|
|
20
|
+
const rect = { x0: 100, y0: 80, x1: 300, y1: 200 }
|
|
21
|
+
|
|
22
|
+
expect(rectFromDrag({ x: 100, y: 80 }, { x: 300, y: 200 }, canvas)).toEqual(rect)
|
|
23
|
+
expect(rectFromDrag({ x: 300, y: 200 }, { x: 100, y: 80 }, canvas)).toEqual(rect)
|
|
24
|
+
expect(rectFromDrag({ x: 300, y: 80 }, { x: 100, y: 200 }, canvas)).toEqual(rect)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('is kept inside the canvas', () => {
|
|
28
|
+
expect(rectFromDrag({ x: -40, y: 20 }, { x: 1200, y: 700 }, canvas)).toEqual({
|
|
29
|
+
x0: 0,
|
|
30
|
+
y0: 20,
|
|
31
|
+
x1: 1000,
|
|
32
|
+
y1: 600,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('isRegionBigEnough', () => {
|
|
38
|
+
test('wants 24 px on both sides, so a click that wobbled is not a region', () => {
|
|
39
|
+
expect(isRegionBigEnough({ x0: 0, y0: 0, x1: 24, y1: 24 })).toBe(true)
|
|
40
|
+
expect(isRegionBigEnough({ x0: 0, y0: 0, x1: 23, y1: 200 })).toBe(false)
|
|
41
|
+
expect(isRegionBigEnough({ x0: 0, y0: 0, x1: 200, y1: 23.9 })).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('regionFractions', () => {
|
|
46
|
+
test('is [left, top, right, bottom] as 0-1 of the full frame', () => {
|
|
47
|
+
const box = { left: 100, top: 50, width: 800, height: 400 }
|
|
48
|
+
|
|
49
|
+
expect(regionFractions({ x0: 300, y0: 150, x1: 700, y1: 350 }, box)).toEqual([
|
|
50
|
+
0.25, 0.25, 0.75, 0.75,
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('is clamped to the frame', () => {
|
|
55
|
+
const box = { left: 100, top: 50, width: 800, height: 400 }
|
|
56
|
+
|
|
57
|
+
expect(regionFractions({ x0: 0, y0: 0, x1: 2000, y1: 2000 }, box)).toEqual([0, 0, 1, 1])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('regionSizeMetres', () => {
|
|
62
|
+
// A camera straight above: the screen point (x, y) looks down at the world point (x / 10, 0, y / 10).
|
|
63
|
+
const down = (x: number, y: number) => ({
|
|
64
|
+
origin: [x / 10, 10, y / 10] as [number, number, number],
|
|
65
|
+
direction: [0, -1, 0] as [number, number, number],
|
|
66
|
+
})
|
|
67
|
+
const rect = { x0: 100, y0: 100, x1: 300, y1: 200 }
|
|
68
|
+
|
|
69
|
+
test('is the distance between the left and right edge hits, and top and bottom, on the plane', () => {
|
|
70
|
+
expect(regionSizeMetres(rect, down, 0)).toEqual({ width: 20, depth: 10 })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('measures on the plane it is given', () => {
|
|
74
|
+
// At y = 5 the same rays hit the same x and z (they are straight down): the size does not change.
|
|
75
|
+
expect(regionSizeMetres(rect, down, 5)).toEqual({ width: 20, depth: 10 })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('is null when a ray misses the plane: it points up, along it, or does not exist', () => {
|
|
79
|
+
expect(
|
|
80
|
+
regionSizeMetres(rect, () => ({ origin: [0, 1, 0], direction: [0, 1, 0] }), 0),
|
|
81
|
+
).toBeNull()
|
|
82
|
+
expect(
|
|
83
|
+
regionSizeMetres(rect, () => ({ origin: [0, 1, 0], direction: [1, 0, 0] }), 0),
|
|
84
|
+
).toBeNull()
|
|
85
|
+
expect(regionSizeMetres(rect, () => null, 0)).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('is null when only one of the four rays misses (the sky in a corner of the frame)', () => {
|
|
89
|
+
const skyAtTop = (x: number, y: number) =>
|
|
90
|
+
y <= 100
|
|
91
|
+
? {
|
|
92
|
+
origin: [0, 1, 0] as [number, number, number],
|
|
93
|
+
direction: [0, 1, 0] as [number, number, number],
|
|
94
|
+
}
|
|
95
|
+
: down(x, y)
|
|
96
|
+
|
|
97
|
+
expect(regionSizeMetres(rect, skyAtTop, 0)).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('rankInRegion', () => {
|
|
102
|
+
const region = { x0: 100, y0: 100, x1: 300, y1: 300 }
|
|
103
|
+
|
|
104
|
+
test('ranks by the area of overlap with the region, biggest first', () => {
|
|
105
|
+
const ranked = rankInRegion(
|
|
106
|
+
[
|
|
107
|
+
{ id: 'small', rect: { x0: 250, y0: 250, x1: 350, y1: 350 } }, // 50 x 50
|
|
108
|
+
{ id: 'big', rect: { x0: 0, y0: 0, x1: 400, y1: 400 } }, // the whole region
|
|
109
|
+
{ id: 'mid', rect: { x0: 100, y0: 100, x1: 200, y1: 300 } }, // 100 x 200
|
|
110
|
+
],
|
|
111
|
+
region,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(ranked).toEqual({ ids: ['big', 'mid', 'small'], more: 0 })
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('leaves out what is outside, what only touches an edge, and what has no screen box', () => {
|
|
118
|
+
const ranked = rankInRegion(
|
|
119
|
+
[
|
|
120
|
+
{ id: 'outside', rect: { x0: 500, y0: 500, x1: 600, y1: 600 } },
|
|
121
|
+
{ id: 'touching', rect: { x0: 300, y0: 100, x1: 400, y1: 300 } },
|
|
122
|
+
{ id: 'hidden', rect: null },
|
|
123
|
+
{ id: 'inside', rect: { x0: 150, y0: 150, x1: 160, y1: 160 } },
|
|
124
|
+
],
|
|
125
|
+
region,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(ranked).toEqual({ ids: ['inside'], more: 0 })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('ties go by id, so the same view always ranks the same way', () => {
|
|
132
|
+
const same = { x0: 120, y0: 120, x1: 140, y1: 140 }
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
rankInRegion(
|
|
136
|
+
[
|
|
137
|
+
{ id: 'b', rect: same },
|
|
138
|
+
{ id: 'a', rect: same },
|
|
139
|
+
],
|
|
140
|
+
region,
|
|
141
|
+
).ids,
|
|
142
|
+
).toEqual(['a', 'b'])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('keeps the top few and counts the rest', () => {
|
|
146
|
+
const many = Array.from({ length: 15 }, (_, i) => ({
|
|
147
|
+
id: `e${String(i).padStart(2, '0')}`,
|
|
148
|
+
rect: { x0: 110, y0: 110, x1: 110 + 10 + i, y1: 120 },
|
|
149
|
+
}))
|
|
150
|
+
const ranked = rankInRegion(many, region)
|
|
151
|
+
|
|
152
|
+
expect(ranked.ids).toHaveLength(12)
|
|
153
|
+
expect(ranked.more).toBe(3)
|
|
154
|
+
expect(ranked.ids[0]).toBe('e14')
|
|
155
|
+
expect(rankInRegion(many, region, 5)).toMatchObject({ more: 10 })
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { formatLinearMeasurement } from '../measurements'
|
|
2
|
+
import type { Rect, ScreenPoint } from './types'
|
|
3
|
+
|
|
4
|
+
// A region is a view, not a selection (spec 2.3, "A region is a view, not a selection"): a
|
|
5
|
+
// rectangle dragged on the canvas, its size shown live in metres, the crop it frames, and what lies
|
|
6
|
+
// inside it ranked for the agent. Pure: the camera is handed in as a ray function.
|
|
7
|
+
|
|
8
|
+
const MIN_SIDE_PX = 24
|
|
9
|
+
const RAY_EPSILON = 1e-6
|
|
10
|
+
|
|
11
|
+
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
|
12
|
+
|
|
13
|
+
/** The rectangle between two drag points, whichever way it was dragged, kept inside `bounds`. */
|
|
14
|
+
export function rectFromDrag(a: ScreenPoint, b: ScreenPoint, bounds: Rect): Rect {
|
|
15
|
+
return {
|
|
16
|
+
x0: clamp(Math.min(a.x, b.x), bounds.x0, bounds.x1),
|
|
17
|
+
y0: clamp(Math.min(a.y, b.y), bounds.y0, bounds.y1),
|
|
18
|
+
x1: clamp(Math.max(a.x, b.x), bounds.x0, bounds.x1),
|
|
19
|
+
y1: clamp(Math.max(a.y, b.y), bounds.y0, bounds.y1),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Under 24 px on a side it is a click that wobbled, not a region. */
|
|
24
|
+
export function isRegionBigEnough(rect: Rect): boolean {
|
|
25
|
+
return rect.x1 - rect.x0 >= MIN_SIDE_PX && rect.y1 - rect.y0 >= MIN_SIDE_PX
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The rectangle as 0-1 fractions of the canvas's full frame: [left, top, right, bottom]. */
|
|
29
|
+
export function regionFractions(
|
|
30
|
+
rect: Rect,
|
|
31
|
+
canvas: { left: number; top: number; width: number; height: number },
|
|
32
|
+
): [number, number, number, number] {
|
|
33
|
+
const fraction = (value: number, origin: number, extent: number) =>
|
|
34
|
+
clamp((value - origin) / extent, 0, 1)
|
|
35
|
+
return [
|
|
36
|
+
fraction(rect.x0, canvas.left, canvas.width),
|
|
37
|
+
fraction(rect.y0, canvas.top, canvas.height),
|
|
38
|
+
fraction(rect.x1, canvas.left, canvas.width),
|
|
39
|
+
fraction(rect.y1, canvas.top, canvas.height),
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Ray = { origin: [number, number, number]; direction: [number, number, number] }
|
|
44
|
+
|
|
45
|
+
/** Where a ray meets the horizontal plane `y = planeY` in front of it; null when it points up or along it. */
|
|
46
|
+
function hitPlane(ray: Ray | null, planeY: number): [number, number, number] | null {
|
|
47
|
+
if (!ray || ray.direction[1] >= -RAY_EPSILON) return null
|
|
48
|
+
const t = (planeY - ray.origin[1]) / ray.direction[1]
|
|
49
|
+
if (!Number.isFinite(t) || t < 0) return null
|
|
50
|
+
return [ray.origin[0] + ray.direction[0] * t, planeY, ray.origin[2] + ray.direction[2] * t]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const distance = (a: readonly number[], b: readonly number[]) =>
|
|
54
|
+
Math.hypot((a[0] ?? 0) - (b[0] ?? 0), (a[1] ?? 0) - (b[1] ?? 0), (a[2] ?? 0) - (b[2] ?? 0))
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the rectangle spans on the ground, in metres: the rays through the middle of its four sides
|
|
58
|
+
* meet the plane at `planeY` (the level's floor), and the width and depth are the distances between
|
|
59
|
+
* opposite hits. Null when any of the four misses the plane (the sky in a corner of the frame).
|
|
60
|
+
*/
|
|
61
|
+
export function regionSizeMetres(
|
|
62
|
+
rect: Rect,
|
|
63
|
+
cameraRay: (x: number, y: number) => Ray | null,
|
|
64
|
+
planeY: number,
|
|
65
|
+
): { width: number; depth: number } | null {
|
|
66
|
+
const midX = (rect.x0 + rect.x1) / 2
|
|
67
|
+
const midY = (rect.y0 + rect.y1) / 2
|
|
68
|
+
const left = hitPlane(cameraRay(rect.x0, midY), planeY)
|
|
69
|
+
const right = hitPlane(cameraRay(rect.x1, midY), planeY)
|
|
70
|
+
const top = hitPlane(cameraRay(midX, rect.y0), planeY)
|
|
71
|
+
const bottom = hitPlane(cameraRay(midX, rect.y1), planeY)
|
|
72
|
+
if (!(left && right && top && bottom)) return null
|
|
73
|
+
return { width: distance(left, right), depth: distance(top, bottom) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** "4.2 × 3.1 m", or feet and inches as the editor writes them. */
|
|
77
|
+
export function formatRegionSize(
|
|
78
|
+
size: { width: number; depth: number },
|
|
79
|
+
unit: 'metric' | 'imperial',
|
|
80
|
+
): string {
|
|
81
|
+
if (unit === 'imperial')
|
|
82
|
+
return `${formatLinearMeasurement(size.width, 'imperial')} × ${formatLinearMeasurement(size.depth, 'imperial')}`
|
|
83
|
+
return `${size.width.toFixed(1)} × ${size.depth.toFixed(1)} m`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const overlapArea = (a: Rect, b: Rect) =>
|
|
87
|
+
Math.max(0, Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)) *
|
|
88
|
+
Math.max(0, Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0))
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What the region shows, by how much of it each element covers, the biggest first, ties by id so
|
|
92
|
+
* the same view ranks the same way; elements that only touch an edge, or have no box on screen,
|
|
93
|
+
* are not in it. The top `limit` are named, the rest only counted.
|
|
94
|
+
*/
|
|
95
|
+
export function rankInRegion(
|
|
96
|
+
candidates: readonly { id: string; rect: Rect | null }[],
|
|
97
|
+
region: Rect,
|
|
98
|
+
limit = 12,
|
|
99
|
+
): { ids: string[]; more: number } {
|
|
100
|
+
const ranked = candidates
|
|
101
|
+
.flatMap(({ id, rect }) => {
|
|
102
|
+
const area = rect ? overlapArea(rect, region) : 0
|
|
103
|
+
return area > 0 ? [{ id, area }] : []
|
|
104
|
+
})
|
|
105
|
+
.sort((a, b) => b.area - a.area || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
106
|
+
return {
|
|
107
|
+
ids: ranked.slice(0, limit).map((entry) => entry.id),
|
|
108
|
+
more: Math.max(0, ranked.length - limit),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The region chip's words: "Area · 6 elements". */
|
|
113
|
+
export function regionLabel(count: number): string {
|
|
114
|
+
if (count <= 0) return 'Area'
|
|
115
|
+
return `Area · ${count} element${count === 1 ? '' : 's'}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Where the bubble's tail points on the rectangle: the release point put on its nearest edge. A
|
|
120
|
+
* point outside is clamped onto the rectangle; one inside is pushed out to the closest side.
|
|
121
|
+
*/
|
|
122
|
+
export function nearestEdgePoint(rect: Rect, to: ScreenPoint): ScreenPoint {
|
|
123
|
+
const x = clamp(to.x, rect.x0, rect.x1)
|
|
124
|
+
const y = clamp(to.y, rect.y0, rect.y1)
|
|
125
|
+
const inside = to.x > rect.x0 && to.x < rect.x1 && to.y > rect.y0 && to.y < rect.y1
|
|
126
|
+
if (!inside) return { x, y }
|
|
127
|
+
const toLeft = x - rect.x0
|
|
128
|
+
const toRight = rect.x1 - x
|
|
129
|
+
const toTop = y - rect.y0
|
|
130
|
+
const toBottom = rect.y1 - y
|
|
131
|
+
const nearest = Math.min(toLeft, toRight, toTop, toBottom)
|
|
132
|
+
if (nearest === toRight) return { x: rect.x1, y }
|
|
133
|
+
if (nearest === toLeft) return { x: rect.x0, y }
|
|
134
|
+
if (nearest === toTop) return { x, y: rect.y0 }
|
|
135
|
+
return { x, y: rect.y1 }
|
|
136
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
BuildingNode,
|
|
4
|
+
clearSceneHistory,
|
|
5
|
+
emitter,
|
|
6
|
+
LevelNode,
|
|
7
|
+
type PointAskSubmit,
|
|
8
|
+
registerPointAskHandler,
|
|
9
|
+
useScene,
|
|
10
|
+
WallNode,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { type BubbleTarget, type PinRecord, usePointAsk } from '../../store/use-point-ask'
|
|
13
|
+
import { createPin } from './pin-machine'
|
|
14
|
+
import { planRetry } from './retry'
|
|
15
|
+
import { retryAsk, sendBubble, startPointAskSession } from './session'
|
|
16
|
+
|
|
17
|
+
// Retrying an ask from the chat: the same words, targets, camera and crop as a new ask with its own
|
|
18
|
+
// pin. Architect's Retry goes through here; a finished ask keeps its pin (its compare and Undo
|
|
19
|
+
// stay), an errored one is replaced, as "Try again" does.
|
|
20
|
+
|
|
21
|
+
const submit: PointAskSubmit = {
|
|
22
|
+
askId: 'ask_old',
|
|
23
|
+
context: {
|
|
24
|
+
askId: 'ask_old',
|
|
25
|
+
camera: {
|
|
26
|
+
aspect: 1.6,
|
|
27
|
+
fov: 50,
|
|
28
|
+
position: [0, 1, 5],
|
|
29
|
+
projection: 'perspective',
|
|
30
|
+
target: [0, 1, 0],
|
|
31
|
+
},
|
|
32
|
+
capturedAt: '2026-10-12T10:00:00.000Z',
|
|
33
|
+
gesture: 'click',
|
|
34
|
+
image: { dataUrl: 'data:image/jpeg;base64,AAAA', height: 320, marked: true, width: 512 },
|
|
35
|
+
kind: 'scene-point',
|
|
36
|
+
level: { id: 'level_0', name: 'Ground floor' },
|
|
37
|
+
source: 'editor',
|
|
38
|
+
targets: [],
|
|
39
|
+
version: 1,
|
|
40
|
+
},
|
|
41
|
+
from: { height: 24, width: 120, x: 600, y: 300 },
|
|
42
|
+
text: 'make this window wider',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pinIn(state: 'error' | 'done'): PinRecord {
|
|
46
|
+
const model = createPin({ askId: 'ask_old', targetIds: ['window_kitchen_n1'], text: submit.text })
|
|
47
|
+
return {
|
|
48
|
+
anchor: [0, 1, 0],
|
|
49
|
+
createdAt: 1,
|
|
50
|
+
label: 'Window · Kitchen',
|
|
51
|
+
levelId: 'level_0',
|
|
52
|
+
local: null,
|
|
53
|
+
model: { ...model, state } as PinRecord['model'],
|
|
54
|
+
retireAt: 123,
|
|
55
|
+
submit,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('planRetry', () => {
|
|
60
|
+
test('the new ask has its own id, the same words, targets, camera and crop', () => {
|
|
61
|
+
const plan = planRetry(pinIn('done'), 'ask_new', 5)
|
|
62
|
+
expect(plan?.submit.askId).toBe('ask_new')
|
|
63
|
+
expect(plan?.submit.context.askId).toBe('ask_new')
|
|
64
|
+
expect(plan?.submit.text).toBe('make this window wider')
|
|
65
|
+
expect(plan?.submit.context.camera).toEqual(submit.context.camera)
|
|
66
|
+
expect(plan?.submit.context.image).toEqual(submit.context.image)
|
|
67
|
+
expect(plan?.pin.model.askId).toBe('ask_new')
|
|
68
|
+
expect(plan?.pin.model.targetIds).toEqual(['window_kitchen_n1'])
|
|
69
|
+
expect(plan?.pin.retireAt).toBeNull()
|
|
70
|
+
expect(plan?.pin.createdAt).toBe(5)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('an errored pin is replaced (Try again); any other state keeps its pin, with its compare and Undo', () => {
|
|
74
|
+
expect(planRetry(pinIn('error'), 'ask_new', 5)?.keepOld).toBe(false)
|
|
75
|
+
expect(planRetry(pinIn('done'), 'ask_new', 5)?.keepOld).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a pin that kept no submit cannot be retried', () => {
|
|
79
|
+
const { submit: _gone, ...noSubmit } = pinIn('error')
|
|
80
|
+
expect(planRetry(noSubmit as PinRecord, 'ask_new', 5)).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const building = BuildingNode.parse({ id: 'building_retry' })
|
|
85
|
+
const level = LevelNode.parse({ id: 'level_retry', parentId: building.id })
|
|
86
|
+
const wall = WallNode.parse({
|
|
87
|
+
id: 'wall_retry',
|
|
88
|
+
parentId: level.id,
|
|
89
|
+
start: [0, 0],
|
|
90
|
+
end: [4, 0],
|
|
91
|
+
height: 2.6,
|
|
92
|
+
})
|
|
93
|
+
building.children = [level.id]
|
|
94
|
+
level.children = [wall.id]
|
|
95
|
+
const target = {
|
|
96
|
+
id: wall.id,
|
|
97
|
+
type: 'wall',
|
|
98
|
+
name: 'Wall',
|
|
99
|
+
levelId: level.id,
|
|
100
|
+
size: { length: 4, height: 2.6, thickness: 0.2 },
|
|
101
|
+
box: { min: [0, 0, -0.1], max: [4, 2.6, 0.1] },
|
|
102
|
+
} as unknown as BubbleTarget
|
|
103
|
+
|
|
104
|
+
const original = useScene.getState()
|
|
105
|
+
const originalWindow = globalThis.window
|
|
106
|
+
const originalDocument = globalThis.document
|
|
107
|
+
const originalRaf = globalThis.requestAnimationFrame
|
|
108
|
+
const originalCancelRaf = globalThis.cancelAnimationFrame
|
|
109
|
+
let stopSession = () => {}
|
|
110
|
+
let unregister = () => {}
|
|
111
|
+
let handed: PointAskSubmit[] = []
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
const stub = { addEventListener() {}, removeEventListener() {} }
|
|
115
|
+
globalThis.window = { ...stub, innerWidth: 1280, innerHeight: 800 } as never
|
|
116
|
+
globalThis.document = { ...stub, hidden: false } as never
|
|
117
|
+
globalThis.requestAnimationFrame = (callback) => {
|
|
118
|
+
callback(0)
|
|
119
|
+
return 0
|
|
120
|
+
}
|
|
121
|
+
globalThis.cancelAnimationFrame = () => {}
|
|
122
|
+
useScene.setState({
|
|
123
|
+
nodes: { [building.id]: building, [level.id]: level, [wall.id]: wall },
|
|
124
|
+
rootNodeIds: [building.id],
|
|
125
|
+
dirtyNodes: new Set(),
|
|
126
|
+
readOnly: false,
|
|
127
|
+
} as never)
|
|
128
|
+
clearSceneHistory()
|
|
129
|
+
usePointAsk.setState({ pins: {}, pinOrder: [], bubble: null, comparing: null, busy: false })
|
|
130
|
+
handed = []
|
|
131
|
+
unregister = registerPointAskHandler(async (sent) => {
|
|
132
|
+
handed.push(sent)
|
|
133
|
+
return { ok: true }
|
|
134
|
+
})
|
|
135
|
+
stopSession = startPointAskSession()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
stopSession()
|
|
140
|
+
unregister()
|
|
141
|
+
useScene.setState(original, true)
|
|
142
|
+
clearSceneHistory()
|
|
143
|
+
globalThis.window = originalWindow
|
|
144
|
+
globalThis.document = originalDocument
|
|
145
|
+
if (originalRaf) globalThis.requestAnimationFrame = originalRaf
|
|
146
|
+
else Reflect.deleteProperty(globalThis, 'requestAnimationFrame')
|
|
147
|
+
if (originalCancelRaf) globalThis.cancelAnimationFrame = originalCancelRaf
|
|
148
|
+
else Reflect.deleteProperty(globalThis, 'cancelAnimationFrame')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
async function sendAsk() {
|
|
152
|
+
usePointAsk.getState().openBubble({
|
|
153
|
+
targets: [target],
|
|
154
|
+
anchor: [2, 1, 0],
|
|
155
|
+
hit: { point: [2, 1, 0], normal: [0, 0, 1] },
|
|
156
|
+
gesture: 'click',
|
|
157
|
+
capturedAt: new Date().toISOString(),
|
|
158
|
+
camera: null,
|
|
159
|
+
crop: { state: 'failed' },
|
|
160
|
+
draft: '',
|
|
161
|
+
error: null,
|
|
162
|
+
})
|
|
163
|
+
await sendBubble('make this wall taller', { x: 0, y: 0, width: 10, height: 10 })
|
|
164
|
+
const askId = usePointAsk.getState().pinOrder[0]
|
|
165
|
+
if (!askId) throw new Error('the ask did not plant a pin')
|
|
166
|
+
return askId
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const status = (askId: string, rest: Record<string, unknown>) =>
|
|
170
|
+
emitter.emit('point-ask:status', { askId, ...rest } as never)
|
|
171
|
+
|
|
172
|
+
describe('retryAsk', () => {
|
|
173
|
+
test('hands the same words and targets to the chat as a new ask, and says it did', async () => {
|
|
174
|
+
const askId = await sendAsk()
|
|
175
|
+
status(askId, { status: 'working' })
|
|
176
|
+
status(askId, { status: 'ended', outcome: 'completed', asked: false, wroteScene: true })
|
|
177
|
+
expect(await retryAsk(askId)).toBe(true)
|
|
178
|
+
expect(handed).toHaveLength(2)
|
|
179
|
+
expect(handed[1]?.askId).not.toBe(askId)
|
|
180
|
+
expect(handed[1]?.context.askId).toBe(handed[1]?.askId)
|
|
181
|
+
expect(handed[1]?.text).toBe('make this wall taller')
|
|
182
|
+
expect(handed[1]?.context.targets.map((t) => t.id)).toEqual([wall.id])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('a finished ask keeps its pin, so its compare and Undo stay', async () => {
|
|
186
|
+
const askId = await sendAsk()
|
|
187
|
+
status(askId, { status: 'working' })
|
|
188
|
+
status(askId, { status: 'ended', outcome: 'completed', asked: false, wroteScene: true })
|
|
189
|
+
await retryAsk(askId)
|
|
190
|
+
const { pinOrder, pins } = usePointAsk.getState()
|
|
191
|
+
expect(pinOrder).toHaveLength(2)
|
|
192
|
+
expect(pins[askId]).toBeDefined()
|
|
193
|
+
const next = pinOrder.find((id) => id !== askId)
|
|
194
|
+
expect(next && pins[next]?.model.state).toBe('sent')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('an errored ask is replaced by the retry, as "Try again" does', async () => {
|
|
198
|
+
const askId = await sendAsk()
|
|
199
|
+
status(askId, { status: 'working' })
|
|
200
|
+
status(askId, {
|
|
201
|
+
status: 'ended',
|
|
202
|
+
outcome: 'errored',
|
|
203
|
+
asked: false,
|
|
204
|
+
wroteScene: false,
|
|
205
|
+
error: 'x',
|
|
206
|
+
})
|
|
207
|
+
await retryAsk(askId)
|
|
208
|
+
const { pinOrder, pins } = usePointAsk.getState()
|
|
209
|
+
expect(pinOrder).toHaveLength(1)
|
|
210
|
+
expect(pins[askId]).toBeUndefined()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("says it could not when the pin is gone (a reload, a collaborator's ask)", async () => {
|
|
214
|
+
expect(await retryAsk('ask_unknown')).toBe(false)
|
|
215
|
+
expect(handed).toHaveLength(0)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PointAskSubmit } from '@pascal-app/core'
|
|
2
|
+
import type { PinRecord } from '../../store/use-point-ask'
|
|
3
|
+
import { createPin } from './pin-machine'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What retrying an ask puts in the world: the same words, context and crop under a new ask id, planted
|
|
7
|
+
* where the old pin was. An errored ask is replaced (the pin's own "Try again"); any other is kept, so
|
|
8
|
+
* a retry from the chat of a finished ask does not take away that result's compare and Undo.
|
|
9
|
+
* Null when the pin kept no submit to send again.
|
|
10
|
+
*/
|
|
11
|
+
export function planRetry(
|
|
12
|
+
old: PinRecord,
|
|
13
|
+
nextAskId: string,
|
|
14
|
+
now: number,
|
|
15
|
+
): { pin: PinRecord; submit: PointAskSubmit; keepOld: boolean } | null {
|
|
16
|
+
if (!old.submit) return null
|
|
17
|
+
const submit: PointAskSubmit = {
|
|
18
|
+
...old.submit,
|
|
19
|
+
askId: nextAskId,
|
|
20
|
+
context: { ...old.submit.context, askId: nextAskId },
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
pin: {
|
|
24
|
+
...old,
|
|
25
|
+
model: createPin({
|
|
26
|
+
askId: nextAskId,
|
|
27
|
+
text: old.model.text,
|
|
28
|
+
targetIds: old.model.targetIds,
|
|
29
|
+
}),
|
|
30
|
+
submit,
|
|
31
|
+
retireAt: null,
|
|
32
|
+
createdAt: now,
|
|
33
|
+
},
|
|
34
|
+
submit,
|
|
35
|
+
keepOld: old.model.state !== 'error',
|
|
36
|
+
}
|
|
37
|
+
}
|