@pascal-app/editor 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/package.json +40 -38
- package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
- package/src/components/editor/bake-thumbnail.tsx +6 -15
- package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
- package/src/components/editor/construction-reveal-audio.tsx +40 -0
- package/src/components/editor/custom-camera-controls.tsx +81 -8
- package/src/components/editor/editor-layout-mobile.tsx +1 -1
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/export-manager.tsx +14 -19
- package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
- package/src/components/editor/first-person/build-collider-world.ts +22 -5
- package/src/components/editor/first-person/ground-holes.test.ts +128 -0
- package/src/components/editor/first-person/ground-holes.ts +130 -0
- package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
- package/src/components/editor/first-person/mechanism-targets.ts +52 -0
- package/src/components/editor/first-person-controls.tsx +19 -11
- package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +72 -23
- package/src/components/editor/follow-pascal.test.tsx +912 -0
- package/src/components/editor/follow-pascal.tsx +354 -0
- package/src/components/editor/group-actions.ts +2 -0
- package/src/components/editor/group-move-3d.ts +2 -0
- package/src/components/editor/group-rotate-handle.tsx +2 -0
- package/src/components/editor/group-transform-shared.test.ts +460 -0
- package/src/components/editor/index.tsx +166 -79
- package/src/components/editor/riser-diagram-panel.tsx +14 -7
- package/src/components/editor/room-highlight.tsx +13 -5
- package/src/components/editor/room-labels.test.ts +621 -128
- package/src/components/editor/room-labels.tsx +1090 -190
- package/src/components/editor/selection-manager.tsx +38 -117
- package/src/components/editor/slab-hole-highlights.tsx +2 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
- package/src/components/editor/snapshot-capture.test.ts +69 -58
- package/src/components/editor/snapshot-capture.ts +7 -1
- package/src/components/editor/thumbnail-generator.test.ts +350 -4
- package/src/components/editor/thumbnail-generator.tsx +128 -54
- package/src/components/editor/use-floorplan-background-placement.ts +3 -1
- package/src/components/editor/views/plugins-view.tsx +1 -8
- package/src/components/editor/views/view-bar.test.tsx +54 -0
- package/src/components/editor/views/view-bar.tsx +140 -17
- package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
- package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
- package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
- package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
- package/src/components/point-ask/bubble.tsx +538 -0
- package/src/components/point-ask/hover-chip.tsx +91 -0
- package/src/components/point-ask/mode-pill.tsx +92 -0
- package/src/components/point-ask/optional-motion.ts +22 -0
- package/src/components/point-ask/outline-corners.tsx +93 -0
- package/src/components/point-ask/pins.tsx +711 -0
- package/src/components/point-ask/point-ask-layer.tsx +83 -0
- package/src/components/point-ask/point-ask-styles.ts +105 -0
- package/src/components/point-ask/region-marquee-styles.ts +15 -0
- package/src/components/point-ask/region-marquee.tsx +64 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
- package/src/components/systems/roof/roof-edit-system.tsx +1 -0
- package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
- package/src/components/tools/distribution/connection-compatibility.ts +43 -0
- package/src/components/tools/distribution/distribution-public.ts +9 -0
- package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
- package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
- package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
- package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
- package/src/components/tools/distribution/draw-alignment.ts +35 -0
- package/src/components/tools/distribution/ports.test.ts +56 -0
- package/src/components/tools/distribution/ports.ts +378 -0
- package/src/components/tools/distribution/run-cursor.test.ts +135 -0
- package/src/components/tools/distribution/run-cursor.ts +36 -0
- package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
- package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
- package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
- package/src/components/tools/distribution/run-port-snap.ts +25 -0
- package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
- package/src/components/tools/distribution/run-screen-direction.ts +30 -0
- package/src/components/tools/elevator/elevator-tool.tsx +3 -6
- package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
- package/src/components/tools/item/placement-history.test.tsx +38 -0
- package/src/components/tools/item/placement-strategies.test.ts +46 -1
- package/src/components/tools/item/placement-strategies.ts +3 -6
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
- package/src/components/tools/item/use-draft-node.test.tsx +94 -94
- package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
- package/src/components/tools/registry/item-surface-move.test.ts +62 -63
- package/src/components/tools/registry/item-surface-move.ts +2 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
- package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
- package/src/components/tools/tool-manager.tsx +14 -5
- package/src/components/tools/wall/wall-drafting.test.ts +170 -57
- package/src/components/ui/action-menu/action-button.tsx +6 -1
- package/src/components/ui/action-menu/camera-actions.tsx +7 -3
- package/src/components/ui/action-menu/control-modes.tsx +13 -7
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
- package/src/components/ui/action-menu/index.tsx +56 -33
- package/src/components/ui/action-menu/measurement-control.tsx +11 -9
- package/src/components/ui/action-menu/placement.ts +20 -0
- package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
- package/src/components/ui/action-menu/view-toggles.tsx +47 -42
- package/src/components/ui/build-panel/build-panel.tsx +10 -3
- package/src/components/ui/command-palette/editor-commands.tsx +3 -12
- package/src/components/ui/command-palette/index.tsx +45 -15
- package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
- package/src/components/ui/controls/action-button.tsx +5 -2
- package/src/components/ui/controls/metric-control.tsx +1 -1
- package/src/components/ui/controls/segmented-control.tsx +21 -8
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/slider-control.tsx +1 -1
- package/src/components/ui/controls/toggle-control.tsx +3 -3
- package/src/components/ui/controls/tool-options-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +150 -45
- package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
- package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
- package/src/components/ui/helpers/helper-manager.tsx +7 -1
- package/src/components/ui/helpers/item-helper.tsx +9 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
- package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
- package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
- package/src/components/ui/panels/panel-wrapper.tsx +53 -27
- package/src/components/ui/panels/parametric-field-control.tsx +2 -2
- package/src/components/ui/panels/parametric-inspector.tsx +3 -3
- package/src/components/ui/panels/room-panel.tsx +65 -0
- package/src/components/ui/primitives/badge.tsx +8 -0
- package/src/components/ui/primitives/sidebar.tsx +1 -1
- package/src/components/ui/right-stack.tsx +21 -2
- package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +3 -3
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
- package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
- package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/tab-bar.tsx +4 -4
- package/src/components/viewer/viewer-controls-bar.tsx +18 -6
- package/src/components/viewer/viewer-rooms.tsx +16 -0
- package/src/hooks/use-auto-save.integration.test.tsx +96 -38
- package/src/hooks/use-auto-save.ts +26 -1
- package/src/hooks/use-ceiling-events.test.ts +123 -118
- package/src/hooks/use-grid-events.test.ts +105 -105
- package/src/hooks/use-keyboard.test.ts +65 -16
- package/src/hooks/use-keyboard.ts +29 -12
- package/src/hooks/use-placement-notice.test.ts +24 -1
- package/src/hooks/use-placement-notice.ts +37 -6
- package/src/index.tsx +71 -24
- package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
- package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
- package/src/lib/add-storey.test.ts +177 -0
- package/src/lib/add-storey.ts +59 -0
- package/src/lib/below-grade.test.tsx +171 -0
- package/src/lib/below-grade.ts +133 -0
- package/src/lib/camera-facing.test.ts +24 -0
- package/src/lib/camera-facing.ts +24 -0
- package/src/lib/direct-manipulation.ts +2 -0
- package/src/lib/download-project-file.ts +17 -0
- package/src/lib/editor-level-display.ts +9 -2
- package/src/lib/editor-views.ts +35 -1
- package/src/lib/export-texture-utils.ts +2 -4
- package/src/lib/floor-foundation-support.test.ts +1 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
- package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
- package/src/lib/floorplan/sheet-export.test.ts +24 -0
- package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
- package/src/lib/floorplan/site-plan/coverage.ts +2 -4
- package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
- package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
- package/src/lib/floorplan/site-plan/geometry.ts +1 -416
- package/src/lib/floorplan/site-plan/index.ts +0 -16
- package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
- package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
- package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
- package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
- package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
- package/src/lib/follow-pascal-exterior.test.ts +171 -0
- package/src/lib/follow-pascal-exterior.ts +39 -0
- package/src/lib/follow-pascal-presentation.test.ts +203 -0
- package/src/lib/follow-pascal-presentation.ts +129 -0
- package/src/lib/follow-pascal-sight.ts +84 -0
- package/src/lib/follow-pascal-targets.test.ts +406 -0
- package/src/lib/follow-pascal-targets.ts +198 -0
- package/src/lib/follow-pascal.test.ts +350 -0
- package/src/lib/follow-pascal.ts +284 -0
- package/src/lib/fresh-planar-placement.test.ts +112 -0
- package/src/lib/fresh-planar-placement.ts +57 -3
- package/src/lib/geometry-script/author.ts +6 -1
- package/src/lib/geometry-script/client.ts +8 -1
- package/src/lib/geometry-script/finish-scripts.ts +127 -0
- package/src/lib/geometry-script/protocol.ts +1 -0
- package/src/lib/glb-export.test.ts +187 -162
- package/src/lib/glb-export.ts +4 -1
- package/src/lib/history.test.ts +12 -7
- package/src/lib/interaction/scope.ts +4 -0
- package/src/lib/level-selection.test.ts +57 -0
- package/src/lib/level-selection.ts +16 -1
- package/src/lib/lot/clean-ring.test.ts +2 -1
- package/src/lib/lot/drop-in.ts +9 -24
- package/src/lib/lot/lot-patch.test.ts +1 -1
- package/src/lib/lot/lot-patch.ts +2 -2
- package/src/lib/measurement-kind.test.ts +6 -8
- package/src/lib/measurements.test.ts +23 -0
- package/src/lib/orbit-floor-clearance.test.ts +36 -0
- package/src/lib/orbit-floor-clearance.ts +48 -0
- package/src/lib/paint-pass-through.test.ts +11 -0
- package/src/lib/paint-pass-through.ts +4 -2
- package/src/lib/paint-scope.ts +1 -0
- package/src/lib/parametric-node-update.ts +16 -7
- package/src/lib/plugin-chat.ts +15 -0
- package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
- package/src/lib/point-ask/__fixtures__/house.ts +81 -0
- package/src/lib/point-ask/ask-created.test.ts +195 -0
- package/src/lib/point-ask/ask-flow.test.ts +380 -0
- package/src/lib/point-ask/build-context.test.ts +173 -0
- package/src/lib/point-ask/build-context.ts +94 -0
- package/src/lib/point-ask/capture-crop.ts +129 -0
- package/src/lib/point-ask/choreography.ts +69 -0
- package/src/lib/point-ask/compare.test.ts +145 -0
- package/src/lib/point-ask/compare.ts +73 -0
- package/src/lib/point-ask/describe-target.test.ts +165 -0
- package/src/lib/point-ask/describe-target.ts +234 -0
- package/src/lib/point-ask/dictation.ts +79 -0
- package/src/lib/point-ask/edge-marker.test.ts +61 -0
- package/src/lib/point-ask/edge-marker.ts +27 -0
- package/src/lib/point-ask/frame-loop.ts +25 -0
- package/src/lib/point-ask/history-marks.test.ts +94 -0
- package/src/lib/point-ask/history-marks.ts +39 -0
- package/src/lib/point-ask/host-api.ts +32 -0
- package/src/lib/point-ask/outline-box.test.ts +30 -0
- package/src/lib/point-ask/outline-box.ts +11 -0
- package/src/lib/point-ask/overlay-metrics.ts +6 -0
- package/src/lib/point-ask/pick.test.ts +220 -0
- package/src/lib/point-ask/pick.ts +121 -0
- package/src/lib/point-ask/pin-machine.test.ts +494 -0
- package/src/lib/point-ask/pin-machine.ts +217 -0
- package/src/lib/point-ask/placement.test.ts +203 -0
- package/src/lib/point-ask/placement.ts +141 -0
- package/src/lib/point-ask/pointer-tracker.ts +34 -0
- package/src/lib/point-ask/projector.ts +83 -0
- package/src/lib/point-ask/region.test.ts +157 -0
- package/src/lib/point-ask/region.ts +136 -0
- package/src/lib/point-ask/retry.test.ts +217 -0
- package/src/lib/point-ask/retry.ts +37 -0
- package/src/lib/point-ask/scene-commit.test.ts +312 -0
- package/src/lib/point-ask/scene-commit.ts +32 -0
- package/src/lib/point-ask/session.ts +1391 -0
- package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
- package/src/lib/point-ask/spring-keyframes.ts +38 -0
- package/src/lib/point-ask/spring.test.ts +63 -0
- package/src/lib/point-ask/spring.ts +107 -0
- package/src/lib/point-ask/suggestions.test.ts +80 -0
- package/src/lib/point-ask/suggestions.ts +125 -0
- package/src/lib/point-ask/target-scope.test.ts +622 -0
- package/src/lib/point-ask/target-scope.ts +338 -0
- package/src/lib/point-ask/types.ts +48 -0
- package/src/lib/portable-export.test.ts +34 -1
- package/src/lib/portable-export.ts +119 -53
- package/src/lib/print-feature-thickness.ts +10 -1
- package/src/lib/print-roof-solids.test.ts +39 -1
- package/src/lib/print-roof-solids.ts +43 -10
- package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
- package/src/lib/room-assembly-overlay.ts +2 -3
- package/src/lib/room-selection.test.ts +45 -1
- package/src/lib/scene-clipboard.test.ts +246 -1
- package/src/lib/scene-clipboard.ts +44 -14
- package/src/lib/selection-routing.ts +1 -1
- package/src/lib/sfx-bus.ts +13 -0
- package/src/lib/sfx-player.test.ts +70 -9
- package/src/lib/sfx-player.ts +6 -0
- package/src/lib/stair-levels.test.ts +25 -0
- package/src/lib/stair-levels.ts +22 -3
- package/src/lib/terrace-draft.ts +2 -1
- package/src/lib/viewer-selection.ts +22 -11
- package/src/lib/wall-placement-pointer.test.ts +42 -0
- package/src/lib/wall-placement-pointer.ts +57 -0
- package/src/lib/window-changes.ts +35 -0
- package/src/store/use-audio.test.ts +146 -0
- package/src/store/use-audio.tsx +127 -3
- package/src/store/use-construction-reveal.test.ts +173 -0
- package/src/store/use-construction-reveal.ts +145 -0
- package/src/store/use-corner-snap-hint.test.ts +24 -0
- package/src/store/use-corner-snap-hint.ts +47 -0
- package/src/store/use-follow-pascal.test.ts +62 -0
- package/src/store/use-follow-pascal.ts +53 -0
- package/src/store/use-point-ask.ts +247 -0
- package/src/store/use-stair-build-preview.ts +25 -5
- package/src/store/view-layout.test.ts +27 -1
- package/src/store/view-layout.ts +4 -1
- package/src/test-utils/render-current-markup.tsx +16 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
- package/src/components/tools/stair/stair-click-guard.ts +0 -72
- package/src/components/tools/stair/stair-defaults.ts +0 -15
- package/src/components/tools/stair/stair-tool.tsx +0 -682
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
3
|
+
import { house } from './__fixtures__/house'
|
|
4
|
+
import { describeTarget } from './describe-target'
|
|
5
|
+
|
|
6
|
+
// Point and ask names what the finger is on, in the viewer's unit, for the label chip and the
|
|
7
|
+
// bubble's context row, and in metres for the agent (the context's `size`). Written first: a size
|
|
8
|
+
// in the wrong unit; a wall that does not say which room it bounds; a window's sill missing from the
|
|
9
|
+
// context; an element of a kind nobody described showing no name at all.
|
|
10
|
+
|
|
11
|
+
const nodes = house()
|
|
12
|
+
const metric = { unit: 'metric' } as const
|
|
13
|
+
const at = (id: string, unit: Parameters<typeof describeTarget>[2] = metric) =>
|
|
14
|
+
describeTarget(nodes[id] as AnyNode, nodes, unit)
|
|
15
|
+
|
|
16
|
+
describe('a room', () => {
|
|
17
|
+
test('is named by its zone, sized by its area, and is the kind "room"', () => {
|
|
18
|
+
const room = at('zone_kitchen')
|
|
19
|
+
|
|
20
|
+
expect(room).toMatchObject({
|
|
21
|
+
id: 'zone_kitchen',
|
|
22
|
+
type: 'zone',
|
|
23
|
+
kind: 'room',
|
|
24
|
+
name: 'Kitchen',
|
|
25
|
+
size: '12.0 m²',
|
|
26
|
+
levelId: 'level_0',
|
|
27
|
+
})
|
|
28
|
+
expect(room.sizes).toEqual({ area: 12, width: 4, depth: 3 })
|
|
29
|
+
expect(room.zoneId).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('a room with no name is "Room"', () => {
|
|
33
|
+
const unnamed = { ...nodes.zone_kitchen, name: '' } as AnyNode
|
|
34
|
+
|
|
35
|
+
expect(describeTarget(unnamed, { ...nodes, zone_kitchen: unnamed }, metric).name).toBe('Room')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('a wall', () => {
|
|
40
|
+
test('says which room it bounds, its length by its height, and the room it is in', () => {
|
|
41
|
+
const wall = at('wall_north')
|
|
42
|
+
|
|
43
|
+
expect(wall).toMatchObject({ kind: 'wall', name: 'Wall · Kitchen', zoneId: 'zone_kitchen' })
|
|
44
|
+
expect(wall.sizes.length).toBeCloseTo(4, 6)
|
|
45
|
+
expect(wall.sizes.thickness).toBeGreaterThan(0)
|
|
46
|
+
expect(wall.size).toBe(`4.00 × ${wall.sizes.height!.toFixed(2)} m`)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a wall bounding no room is just "Wall"', () => {
|
|
50
|
+
const { zone_kitchen: _gone, ...without } = nodes
|
|
51
|
+
const wall = describeTarget(nodes.wall_north as AnyNode, without as typeof nodes, metric)
|
|
52
|
+
|
|
53
|
+
expect(wall.name).toBe('Wall')
|
|
54
|
+
expect(wall.zoneId).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('a window and a door', () => {
|
|
59
|
+
test('a window: width by height, its sill in the context, its wall as parent', () => {
|
|
60
|
+
const window = at('window_north')
|
|
61
|
+
|
|
62
|
+
expect(window).toMatchObject({
|
|
63
|
+
kind: 'window',
|
|
64
|
+
name: 'Window',
|
|
65
|
+
size: '1.20 × 1.40 m',
|
|
66
|
+
parentId: 'wall_north',
|
|
67
|
+
zoneId: 'zone_kitchen',
|
|
68
|
+
})
|
|
69
|
+
expect(window.sizes.width).toBeCloseTo(1.2, 6)
|
|
70
|
+
expect(window.sizes.height).toBeCloseTo(1.4, 6)
|
|
71
|
+
expect(window.sizes.sill).toBeCloseTo(0.7, 6)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a door stands on the floor: sill 0', () => {
|
|
75
|
+
const door = at('door_front')
|
|
76
|
+
|
|
77
|
+
expect(door).toMatchObject({ kind: 'door', name: 'Door', size: '0.90 × 2.10 m' })
|
|
78
|
+
expect(door.sizes.sill).toBeCloseTo(0, 6)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('an item', () => {
|
|
83
|
+
test('is named by the person’s name, sized by its width, and sits in the room it stands in', () => {
|
|
84
|
+
const sofa = at('item_sofa')
|
|
85
|
+
|
|
86
|
+
expect(sofa).toMatchObject({
|
|
87
|
+
kind: 'item',
|
|
88
|
+
name: 'Sofa',
|
|
89
|
+
size: '2.10 m wide',
|
|
90
|
+
zoneId: 'zone_kitchen',
|
|
91
|
+
})
|
|
92
|
+
expect(sofa.parentId).toBeUndefined()
|
|
93
|
+
expect(sofa.sizes).toEqual({ width: 2.1, depth: 0.95, height: 0.85 })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('with no name it is the asset’s, and then just "Item"', () => {
|
|
97
|
+
const unnamed = { ...nodes.item_sofa, name: undefined } as AnyNode
|
|
98
|
+
expect(describeTarget(unnamed, nodes, metric).name).toBe('Oslo 3-seat')
|
|
99
|
+
|
|
100
|
+
const bare = {
|
|
101
|
+
...nodes.item_sofa,
|
|
102
|
+
name: undefined,
|
|
103
|
+
asset: { ...(nodes.item_sofa as never as { asset: object }).asset, name: '' },
|
|
104
|
+
} as AnyNode
|
|
105
|
+
expect(describeTarget(bare, nodes, metric).name).toBe('Item')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('units', () => {
|
|
110
|
+
test('imperial reads in feet, inches and square feet', () => {
|
|
111
|
+
const imperial = { unit: 'imperial' } as const
|
|
112
|
+
|
|
113
|
+
expect(at('zone_kitchen', imperial).size).toBe('129.2 ft²')
|
|
114
|
+
expect(at('window_north', imperial).size).toBe(`3'11" × 4'7"`)
|
|
115
|
+
expect(at('item_sofa', imperial).size).toBe(`6'11" wide`)
|
|
116
|
+
// The context stays in metres whatever the viewer shows.
|
|
117
|
+
expect(at('window_north', imperial).sizes.width).toBeCloseTo(1.2, 6)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('millimetres when the viewer shows them', () => {
|
|
121
|
+
const mm = { unit: 'metric', metricNotation: 'millimeters' } as const
|
|
122
|
+
|
|
123
|
+
expect(at('window_north', mm).size).toBe('1200 × 1400 mm')
|
|
124
|
+
expect(at('item_sofa', mm).size).toBe('2100 mm wide')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('everything else', () => {
|
|
129
|
+
test('a kind nobody described still has a name and a type', () => {
|
|
130
|
+
const odd = {
|
|
131
|
+
id: 'column_1',
|
|
132
|
+
type: 'column',
|
|
133
|
+
parentId: 'level_0',
|
|
134
|
+
name: 'Pillar',
|
|
135
|
+
} as unknown as AnyNode
|
|
136
|
+
const anonymous = { id: 'fence_1', type: 'fence', parentId: 'level_0' } as unknown as AnyNode
|
|
137
|
+
|
|
138
|
+
expect(describeTarget(odd, { ...nodes, column_1: odd }, metric)).toMatchObject({
|
|
139
|
+
kind: 'other',
|
|
140
|
+
name: 'Pillar',
|
|
141
|
+
type: 'column',
|
|
142
|
+
})
|
|
143
|
+
expect(describeTarget(anonymous, { ...nodes, fence_1: anonymous }, metric).name).toBe('Fence')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('a roof segment is the kind "roof" and a stair the kind "stair"', () => {
|
|
147
|
+
const segment = {
|
|
148
|
+
id: 'roof_segment_1',
|
|
149
|
+
type: 'roof-segment',
|
|
150
|
+
parentId: 'roof_1',
|
|
151
|
+
width: 8,
|
|
152
|
+
depth: 6,
|
|
153
|
+
} as unknown as AnyNode
|
|
154
|
+
const stair = {
|
|
155
|
+
id: 'stair_1',
|
|
156
|
+
type: 'stair',
|
|
157
|
+
parentId: 'level_0',
|
|
158
|
+
width: 1,
|
|
159
|
+
totalRise: 2.7,
|
|
160
|
+
} as unknown as AnyNode
|
|
161
|
+
|
|
162
|
+
expect(describeTarget(segment, nodes, metric).kind).toBe('roof')
|
|
163
|
+
expect(describeTarget(stair, nodes, metric).kind).toBe('stair')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
area,
|
|
4
|
+
getScaledDimensions,
|
|
5
|
+
getWallCurveLength,
|
|
6
|
+
getWallEffectiveHeightForNodes,
|
|
7
|
+
getWallThickness,
|
|
8
|
+
type ItemNode,
|
|
9
|
+
resolveLevelId,
|
|
10
|
+
type SlabNode,
|
|
11
|
+
type StairNode,
|
|
12
|
+
type WallNode,
|
|
13
|
+
type WindowNode,
|
|
14
|
+
type ZoneNode,
|
|
15
|
+
} from '@pascal-app/core'
|
|
16
|
+
import {
|
|
17
|
+
formatLinearMeasurement,
|
|
18
|
+
getAreaUnitLabel,
|
|
19
|
+
type LinearUnit,
|
|
20
|
+
type MetricNotation,
|
|
21
|
+
squareMetersToAreaUnit,
|
|
22
|
+
} from '../measurements'
|
|
23
|
+
import type { PointKind, PointTarget } from './types'
|
|
24
|
+
|
|
25
|
+
// Point and ask names what the finger is on (the owner, 8 October): the label chip and the
|
|
26
|
+
// bubble's context row read it in the viewer's unit; the agent reads the same element's `sizes` in
|
|
27
|
+
// metres. Pure: the scene is handed in.
|
|
28
|
+
|
|
29
|
+
export type PointUnit = { unit: LinearUnit; metricNotation?: MetricNotation }
|
|
30
|
+
|
|
31
|
+
/** A described element: the chip's fields, plus what the agent's context carries. */
|
|
32
|
+
export type DescribedTarget = PointTarget & {
|
|
33
|
+
levelId: string
|
|
34
|
+
/** The room it is in or bounds. */
|
|
35
|
+
zoneId?: string
|
|
36
|
+
/** The wall an opening is in, the item an item stands on. */
|
|
37
|
+
parentId?: string
|
|
38
|
+
/** Metres: wall {length, height, thickness}; zone {area, width, depth}; window/door {width,
|
|
39
|
+
* height, sill}; item {width, depth, height}. */
|
|
40
|
+
sizes: Record<string, number>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Nodes = Readonly<Record<string, AnyNode | undefined>>
|
|
44
|
+
|
|
45
|
+
const KINDS: Partial<Record<string, PointKind>> = {
|
|
46
|
+
zone: 'room',
|
|
47
|
+
wall: 'wall',
|
|
48
|
+
window: 'window',
|
|
49
|
+
door: 'door',
|
|
50
|
+
item: 'item',
|
|
51
|
+
roof: 'roof',
|
|
52
|
+
'roof-segment': 'roof',
|
|
53
|
+
stair: 'stair',
|
|
54
|
+
'stair-segment': 'stair',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_NAMES: Partial<Record<string, string>> = {
|
|
58
|
+
slab: 'Floor',
|
|
59
|
+
ceiling: 'Ceiling',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sentence = (type: string) => {
|
|
63
|
+
const words = type.replace(/-/g, ' ')
|
|
64
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const metres = (value: number, unit: PointUnit) => {
|
|
68
|
+
if (unit.unit === 'imperial') return formatLinearMeasurement(value, 'imperial')
|
|
69
|
+
return unit.metricNotation === 'millimeters' ? `${Math.round(value * 1000)}` : value.toFixed(2)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** "4.80 × 2.70 m", "3'11" × 4'7"", "1200 × 1400 mm". */
|
|
73
|
+
function dimensions(values: number[], unit: PointUnit): string {
|
|
74
|
+
const parts = values.map((value) => metres(value, unit)).join(' × ')
|
|
75
|
+
if (unit.unit === 'imperial') return parts
|
|
76
|
+
return `${parts} ${unit.metricNotation === 'millimeters' ? 'mm' : 'm'}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** "2.10 m wide". */
|
|
80
|
+
function wide(value: number, unit: PointUnit): string {
|
|
81
|
+
return `${dimensions([value], unit)} wide`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function areaLabel(squareMetres: number, unit: PointUnit): string {
|
|
85
|
+
return `${squareMetersToAreaUnit(squareMetres, unit.unit).toFixed(1)} ${getAreaUnitLabel(unit.unit)}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extent(points: readonly (readonly number[])[], axis: 0 | 1): number {
|
|
89
|
+
const values = points.map((point) => point[axis]!)
|
|
90
|
+
return values.length ? Math.max(...values) - Math.min(...values) : 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function insidePolygon(point: readonly [number, number], polygon: readonly (readonly number[])[]) {
|
|
94
|
+
let inside = false
|
|
95
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
96
|
+
const [xi, zi] = [polygon[i]![0]!, polygon[i]![1]!]
|
|
97
|
+
const [xj, zj] = [polygon[j]![0]!, polygon[j]![1]!]
|
|
98
|
+
if (
|
|
99
|
+
zi > point[1] !== zj > point[1] &&
|
|
100
|
+
point[0] < ((xj - xi) * (point[1] - zi)) / (zj - zi) + xi
|
|
101
|
+
)
|
|
102
|
+
inside = !inside
|
|
103
|
+
}
|
|
104
|
+
return inside
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const zonesOf = (nodes: Nodes, levelId: string) =>
|
|
108
|
+
Object.values(nodes).filter(
|
|
109
|
+
(node): node is ZoneNode => node?.type === 'zone' && node.parentId === levelId,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
/** The room a wall bounds: the zone that lists it among its boundary walls. */
|
|
113
|
+
const roomOfWall = (nodes: Nodes, levelId: string, wallId: string) =>
|
|
114
|
+
zonesOf(nodes, levelId).find((zone) => zone.boundaryWallIds?.includes(wallId as never))
|
|
115
|
+
|
|
116
|
+
function wallHeight(wall: WallNode, nodes: Nodes): number {
|
|
117
|
+
try {
|
|
118
|
+
const height = getWallEffectiveHeightForNodes(wall, nodes as Record<string, AnyNode>)
|
|
119
|
+
if (Number.isFinite(height) && height > 0) return height
|
|
120
|
+
} catch {
|
|
121
|
+
// No slab election to consult in a bare scene: the wall's own height stands.
|
|
122
|
+
}
|
|
123
|
+
return wall.height ?? 2.5
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function describeTarget(node: AnyNode, nodes: Nodes, unit: PointUnit): DescribedTarget {
|
|
127
|
+
const levelId = resolveLevelId(node, nodes as Record<string, AnyNode>)
|
|
128
|
+
const named = (fallback: string) => (node as { name?: string }).name?.trim() || fallback
|
|
129
|
+
const base = {
|
|
130
|
+
id: node.id,
|
|
131
|
+
type: node.type,
|
|
132
|
+
kind: KINDS[node.type] ?? ('other' as PointKind),
|
|
133
|
+
levelId,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (node.type === 'zone') {
|
|
137
|
+
const zone = node as ZoneNode
|
|
138
|
+
const squareMetres = area([{ outer: zone.polygon, holes: zone.holes }])
|
|
139
|
+
return {
|
|
140
|
+
...base,
|
|
141
|
+
name: named('Room'),
|
|
142
|
+
size: areaLabel(squareMetres, unit),
|
|
143
|
+
sizes: {
|
|
144
|
+
area: squareMetres,
|
|
145
|
+
width: extent(zone.polygon, 0),
|
|
146
|
+
depth: extent(zone.polygon, 1),
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (node.type === 'wall') {
|
|
152
|
+
const wall = node as WallNode
|
|
153
|
+
const room = roomOfWall(nodes, levelId, wall.id)
|
|
154
|
+
const length = getWallCurveLength(wall)
|
|
155
|
+
const height = wallHeight(wall, nodes)
|
|
156
|
+
return {
|
|
157
|
+
...base,
|
|
158
|
+
name: named(room ? `Wall · ${room.name?.trim() || 'Room'}` : 'Wall'),
|
|
159
|
+
size: dimensions([length, height], unit),
|
|
160
|
+
sizes: { length, height, thickness: getWallThickness(wall) },
|
|
161
|
+
...(room ? { zoneId: room.id } : {}),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (node.type === 'window' || node.type === 'door') {
|
|
166
|
+
const opening = node as WindowNode & { wallId?: string }
|
|
167
|
+
const wallId = opening.wallId ?? opening.parentId ?? undefined
|
|
168
|
+
const room = wallId ? roomOfWall(nodes, levelId, wallId) : undefined
|
|
169
|
+
return {
|
|
170
|
+
...base,
|
|
171
|
+
name: named(node.type === 'window' ? 'Window' : 'Door'),
|
|
172
|
+
size: dimensions([opening.width, opening.height], unit),
|
|
173
|
+
sizes: {
|
|
174
|
+
width: opening.width,
|
|
175
|
+
height: opening.height,
|
|
176
|
+
sill: Math.max(0, opening.position[1] - opening.height / 2),
|
|
177
|
+
},
|
|
178
|
+
...(opening.parentId ? { parentId: opening.parentId } : {}),
|
|
179
|
+
...(room ? { zoneId: room.id } : {}),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (node.type === 'item') {
|
|
184
|
+
const item = node as ItemNode
|
|
185
|
+
const [width, height, depth] = getScaledDimensions(item)
|
|
186
|
+
const room = zonesOf(nodes, levelId).find((zone) =>
|
|
187
|
+
insidePolygon([item.position[0], item.position[2]], zone.polygon),
|
|
188
|
+
)
|
|
189
|
+
const onItem = item.parentId && nodes[item.parentId]?.type === 'item'
|
|
190
|
+
return {
|
|
191
|
+
...base,
|
|
192
|
+
name: named(item.asset.name?.trim() || 'Item'),
|
|
193
|
+
size: wide(width, unit),
|
|
194
|
+
sizes: { width, depth, height },
|
|
195
|
+
...(onItem ? { parentId: item.parentId as string } : {}),
|
|
196
|
+
...(room ? { zoneId: room.id } : {}),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (node.type === 'roof-segment') {
|
|
201
|
+
const { width, depth } = node as { width: number; depth: number }
|
|
202
|
+
return {
|
|
203
|
+
...base,
|
|
204
|
+
name: named('Roof segment'),
|
|
205
|
+
size: dimensions([width, depth], unit),
|
|
206
|
+
sizes: { width, depth },
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (node.type === 'stair' || node.type === 'stair-segment') {
|
|
211
|
+
const stair = node as StairNode
|
|
212
|
+
const sizes: Record<string, number> = { width: stair.width ?? 1 }
|
|
213
|
+
if (stair.totalRise !== undefined) sizes.rise = stair.totalRise
|
|
214
|
+
return { ...base, name: named('Stair'), size: wide(sizes.width!, unit), sizes }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (node.type === 'slab' || node.type === 'ceiling') {
|
|
218
|
+
const plate = node as SlabNode
|
|
219
|
+
const squareMetres = area([{ outer: plate.polygon, holes: [] }])
|
|
220
|
+
return {
|
|
221
|
+
...base,
|
|
222
|
+
name: named(DEFAULT_NAMES[node.type] ?? sentence(node.type)),
|
|
223
|
+
size: areaLabel(squareMetres, unit),
|
|
224
|
+
sizes: { area: squareMetres },
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
...base,
|
|
230
|
+
name: named(DEFAULT_NAMES[node.type] ?? sentence(node.type)),
|
|
231
|
+
size: '',
|
|
232
|
+
sizes: {},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Dictate in the bubble (the owner said yes to voice, 8 October): the browser's own speech
|
|
2
|
+
// recognition where it exists, hidden where it does not. The words fill the field and never send:
|
|
3
|
+
// the person reads them and presses Enter.
|
|
4
|
+
|
|
5
|
+
type RecognitionResult = { 0: { transcript: string }; isFinal: boolean; length: number }
|
|
6
|
+
type RecognitionEvent = { resultIndex: number; results: ArrayLike<RecognitionResult> }
|
|
7
|
+
type Recognition = {
|
|
8
|
+
lang: string
|
|
9
|
+
interimResults: boolean
|
|
10
|
+
continuous: boolean
|
|
11
|
+
onresult: ((event: RecognitionEvent) => void) | null
|
|
12
|
+
onend: (() => void) | null
|
|
13
|
+
onerror: ((event: { error: string }) => void) | null
|
|
14
|
+
start: () => void
|
|
15
|
+
stop: () => void
|
|
16
|
+
abort: () => void
|
|
17
|
+
}
|
|
18
|
+
type RecognitionConstructor = new () => Recognition
|
|
19
|
+
|
|
20
|
+
function constructorOf(): RecognitionConstructor | null {
|
|
21
|
+
if (typeof window === 'undefined') return null
|
|
22
|
+
const w = window as unknown as {
|
|
23
|
+
SpeechRecognition?: RecognitionConstructor
|
|
24
|
+
webkitSpeechRecognition?: RecognitionConstructor
|
|
25
|
+
}
|
|
26
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isDictationSupported(): boolean {
|
|
30
|
+
return constructorOf() !== null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Dictation = { stop: () => void }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Starts listening. `onText` gets the whole transcript so far (interim words included) each time
|
|
37
|
+
* it changes; `onEnd` fires once when listening stops, by `stop`, by silence or by an error.
|
|
38
|
+
*/
|
|
39
|
+
export function startDictation(options: {
|
|
40
|
+
lang?: string
|
|
41
|
+
onText: (text: string) => void
|
|
42
|
+
onEnd: (reason: 'stopped' | 'error', error?: string) => void
|
|
43
|
+
}): Dictation | null {
|
|
44
|
+
const Ctor = constructorOf()
|
|
45
|
+
if (!Ctor) return null
|
|
46
|
+
const recognition = new Ctor()
|
|
47
|
+
recognition.lang =
|
|
48
|
+
options.lang ?? (typeof navigator === 'undefined' ? 'en-US' : navigator.language)
|
|
49
|
+
recognition.interimResults = true
|
|
50
|
+
recognition.continuous = true
|
|
51
|
+
let finished = false
|
|
52
|
+
const finish = (reason: 'stopped' | 'error', error?: string) => {
|
|
53
|
+
if (finished) return
|
|
54
|
+
finished = true
|
|
55
|
+
options.onEnd(reason, error)
|
|
56
|
+
}
|
|
57
|
+
recognition.onresult = (event) => {
|
|
58
|
+
let text = ''
|
|
59
|
+
for (let i = 0; i < event.results.length; i++) text += event.results[i]?.[0]?.transcript ?? ''
|
|
60
|
+
options.onText(text.trim())
|
|
61
|
+
}
|
|
62
|
+
recognition.onerror = (event) => finish('error', event.error)
|
|
63
|
+
recognition.onend = () => finish('stopped')
|
|
64
|
+
try {
|
|
65
|
+
recognition.start()
|
|
66
|
+
} catch (error) {
|
|
67
|
+
finish('error', error instanceof Error ? error.message : String(error))
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
stop: () => {
|
|
72
|
+
try {
|
|
73
|
+
recognition.stop()
|
|
74
|
+
} catch {
|
|
75
|
+
finish('stopped')
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { edgeMarker } from './edge-marker'
|
|
3
|
+
|
|
4
|
+
// A pin whose element is out of view (spec 2.6, "work outside the view shows an edge marker"):
|
|
5
|
+
// the marker sits on the viewport's edge, on the line from its centre towards the pin, and points
|
|
6
|
+
// at it. Written first: a marker for a pin that is in view; one off the edge on the wrong side;
|
|
7
|
+
// a pin behind the camera sending the marker nowhere.
|
|
8
|
+
const VIEW = { x0: 0, y0: 0, x1: 1000, y1: 600 }
|
|
9
|
+
|
|
10
|
+
describe('edgeMarker', () => {
|
|
11
|
+
test('a pin in view needs none', () => {
|
|
12
|
+
expect(edgeMarker({ x: 400, y: 300 }, VIEW)).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('a pin to the right sits on the right edge, level with the line to it', () => {
|
|
16
|
+
const marker = edgeMarker({ x: 2000, y: 300 }, VIEW)!
|
|
17
|
+
|
|
18
|
+
expect(marker.x).toBe(1000 - 18)
|
|
19
|
+
expect(marker.y).toBeCloseTo(300, 5)
|
|
20
|
+
expect(marker.angle).toBeCloseTo(0, 5)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a pin above points up from the top edge', () => {
|
|
24
|
+
const marker = edgeMarker({ x: 500, y: -900 }, VIEW)!
|
|
25
|
+
|
|
26
|
+
expect(marker.y).toBe(18)
|
|
27
|
+
expect(marker.x).toBeCloseTo(500, 5)
|
|
28
|
+
expect(marker.angle).toBeCloseTo(-90, 5)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('a pin beyond a corner sits in that corner region, on the line from the centre', () => {
|
|
32
|
+
const marker = edgeMarker({ x: 3000, y: 1800 }, VIEW)!
|
|
33
|
+
|
|
34
|
+
expect(marker.x).toBeLessThanOrEqual(1000 - 18)
|
|
35
|
+
expect(marker.y).toBeLessThanOrEqual(600 - 18)
|
|
36
|
+
// On the line through the centre (500, 300) and the pin: equal slope.
|
|
37
|
+
const slope = (marker.y - 300) / (marker.x - 500)
|
|
38
|
+
expect(slope).toBeCloseTo((1800 - 300) / (3000 - 500), 5)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('is always inside the inset viewport', () => {
|
|
42
|
+
for (const p of [
|
|
43
|
+
{ x: -500, y: 1200 },
|
|
44
|
+
{ x: 1500, y: -300 },
|
|
45
|
+
{ x: 2000, y: 640 },
|
|
46
|
+
{ x: -50, y: 300 },
|
|
47
|
+
]) {
|
|
48
|
+
const marker = edgeMarker(p, VIEW)!
|
|
49
|
+
expect(marker.x).toBeGreaterThanOrEqual(18)
|
|
50
|
+
expect(marker.x).toBeLessThanOrEqual(1000 - 18)
|
|
51
|
+
expect(marker.y).toBeGreaterThanOrEqual(18)
|
|
52
|
+
expect(marker.y).toBeLessThanOrEqual(600 - 18)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('a pin behind the camera has no direction: the marker sits at the bottom, pointing down', () => {
|
|
57
|
+
const marker = edgeMarker(null, VIEW)!
|
|
58
|
+
|
|
59
|
+
expect(marker).toEqual({ x: 500, y: 600 - 18, angle: 90 })
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Rect, ScreenPoint } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the edge marker of an off-screen pin goes: on the viewport's inset edge, on the line from
|
|
5
|
+
* the viewport's centre towards the pin, turned to point at it. Null when the pin is in view; a
|
|
6
|
+
* pin behind the camera (`point` null) has no direction, so its marker sits at the bottom.
|
|
7
|
+
*/
|
|
8
|
+
export function edgeMarker(
|
|
9
|
+
point: ScreenPoint | null,
|
|
10
|
+
viewport: Rect,
|
|
11
|
+
inset = 18,
|
|
12
|
+
): { x: number; y: number; angle: number } | null {
|
|
13
|
+
const cx = (viewport.x0 + viewport.x1) / 2
|
|
14
|
+
const cy = (viewport.y0 + viewport.y1) / 2
|
|
15
|
+
const hw = (viewport.x1 - viewport.x0) / 2 - inset
|
|
16
|
+
const hh = (viewport.y1 - viewport.y0) / 2 - inset
|
|
17
|
+
if (!point) return { x: cx, y: cy + hh, angle: 90 }
|
|
18
|
+
const dx = point.x - cx
|
|
19
|
+
const dy = point.y - cy
|
|
20
|
+
if (Math.abs(dx) <= hw + inset && Math.abs(dy) <= hh + inset) return null
|
|
21
|
+
// Scale the direction until it touches the inset rectangle.
|
|
22
|
+
const scale = Math.min(
|
|
23
|
+
dx === 0 ? Number.POSITIVE_INFINITY : hw / Math.abs(dx),
|
|
24
|
+
dy === 0 ? Number.POSITIVE_INFINITY : hh / Math.abs(dy),
|
|
25
|
+
)
|
|
26
|
+
return { x: cx + dx * scale, y: cy + dy * scale, angle: (Math.atan2(dy, dx) * 180) / Math.PI }
|
|
27
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// One requestAnimationFrame loop for everything that follows the camera (the outline corners, the
|
|
2
|
+
// bubble, the pins): it runs only while something subscribes, and each subscriber gets the frame's
|
|
3
|
+
// delta in seconds (capped, so a hidden tab does not fling springs on return).
|
|
4
|
+
|
|
5
|
+
type FrameFn = (dt: number, now: number) => void
|
|
6
|
+
|
|
7
|
+
const subscribers = new Set<FrameFn>()
|
|
8
|
+
let frame = 0
|
|
9
|
+
let last = 0
|
|
10
|
+
|
|
11
|
+
function tick(now: number) {
|
|
12
|
+
const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016)
|
|
13
|
+
last = now
|
|
14
|
+
for (const fn of [...subscribers]) fn(dt, now)
|
|
15
|
+
frame = subscribers.size > 0 ? requestAnimationFrame(tick) : 0
|
|
16
|
+
if (!frame) last = 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function subscribeFrame(fn: FrameFn): () => void {
|
|
20
|
+
subscribers.add(fn)
|
|
21
|
+
if (!frame && typeof requestAnimationFrame === 'function') frame = requestAnimationFrame(tick)
|
|
22
|
+
return () => {
|
|
23
|
+
subscribers.delete(fn)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { createdSince, markHistory, stateAfterMark, stepsSince } from './history-marks'
|
|
3
|
+
|
|
4
|
+
// Undo from a pin steps back through what the ask's turn wrote. The scene history keeps only the
|
|
5
|
+
// last 50 steps and a Cmd+Z shrinks it, so a length alone lies: the mark holds the last step that
|
|
6
|
+
// stood when the turn began, and the steps since are counted from where that step is now.
|
|
7
|
+
const steps = (n: number, from = 0) => Array.from({ length: n }, (_, i) => ({ id: from + i }))
|
|
8
|
+
|
|
9
|
+
describe('stepsSince', () => {
|
|
10
|
+
test('counts the steps pushed after the mark', () => {
|
|
11
|
+
const past = steps(3)
|
|
12
|
+
const mark = markHistory(past)
|
|
13
|
+
const later = [...past, ...steps(2, 3)]
|
|
14
|
+
|
|
15
|
+
expect(stepsSince(later, mark)).toBe(2)
|
|
16
|
+
expect(stepsSince(past, mark)).toBe(0)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('an empty history at the mark counts everything since', () => {
|
|
20
|
+
const mark = markHistory([])
|
|
21
|
+
|
|
22
|
+
expect(stepsSince(steps(4), mark)).toBe(4)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('still counts once old steps fall off the front (the history limit)', () => {
|
|
26
|
+
const past = steps(50)
|
|
27
|
+
const mark = markHistory(past)
|
|
28
|
+
const later = [...past, ...steps(3, 50)].slice(3)
|
|
29
|
+
|
|
30
|
+
expect(stepsSince(later, mark)).toBe(3)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('is null when the marked step itself fell off, so the whole turn cannot be undone', () => {
|
|
34
|
+
const past = steps(50)
|
|
35
|
+
const mark = markHistory(past)
|
|
36
|
+
const later = [...past, ...steps(60, 50)].slice(-50)
|
|
37
|
+
|
|
38
|
+
expect(stepsSince(later, mark)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('is null after the person undid back past the mark', () => {
|
|
42
|
+
const past = steps(5)
|
|
43
|
+
const mark = markHistory(past)
|
|
44
|
+
|
|
45
|
+
expect(stepsSince(past.slice(0, 2), mark)).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a Cmd+Z that only took part of the turn leaves the rest countable', () => {
|
|
49
|
+
const past = steps(3)
|
|
50
|
+
const mark = markHistory(past)
|
|
51
|
+
const written = [...past, ...steps(4, 3)]
|
|
52
|
+
const afterOneUndo = written.slice(0, -1)
|
|
53
|
+
|
|
54
|
+
expect(stepsSince(afterOneUndo, mark)).toBe(3)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('stateAfterMark', () => {
|
|
59
|
+
test('is the state the first step since the mark was taken from', () => {
|
|
60
|
+
const past = steps(3)
|
|
61
|
+
const mark = markHistory(past)
|
|
62
|
+
const later = [...past, ...steps(2, 3)]
|
|
63
|
+
|
|
64
|
+
expect(stateAfterMark(later, mark)).toBe(later[3] ?? null)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('with an empty history at the mark it is the oldest state', () => {
|
|
68
|
+
const mark = markHistory([])
|
|
69
|
+
const later = steps(2)
|
|
70
|
+
|
|
71
|
+
expect(stateAfterMark(later, mark)).toBe(later[0] ?? null)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('is null when nothing was pushed since the mark, or the marked step is gone', () => {
|
|
75
|
+
const past = steps(3)
|
|
76
|
+
const mark = markHistory(past)
|
|
77
|
+
|
|
78
|
+
expect(stateAfterMark(past, mark)).toBeNull()
|
|
79
|
+
expect(stateAfterMark(steps(2, 10), mark)).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('createdSince', () => {
|
|
84
|
+
test('is the nodes the scene has now that the earlier state did not', () => {
|
|
85
|
+
const before = { wall_a: {}, window_a: {} }
|
|
86
|
+
const now = { wall_a: {}, window_a: {}, window_b: {}, wall_b: {} }
|
|
87
|
+
|
|
88
|
+
expect(createdSince(before, now).sort()).toEqual(['wall_b', 'window_b'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a node only changed is not created, and one deleted is not either', () => {
|
|
92
|
+
expect(createdSince({ a: {}, b: {} }, { a: { width: 2 } })).toEqual([])
|
|
93
|
+
})
|
|
94
|
+
})
|