@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,45 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { springKeyframes } from './spring-keyframes'
|
|
3
|
+
|
|
4
|
+
// The bubble grows from the click as a spring: scale 0.92 to 1 with the opacity and the blur
|
|
5
|
+
// arriving together. Written first: a first frame that is not the start, a last frame that is not
|
|
6
|
+
// the rest, opacity leaving 0 to 1, and a critically damped spring overshooting.
|
|
7
|
+
describe('springKeyframes', () => {
|
|
8
|
+
const frames = springKeyframes(
|
|
9
|
+
{ scale: 0.92, opacity: 0, blur: 4 },
|
|
10
|
+
{ scale: 1, opacity: 1, blur: 0 },
|
|
11
|
+
360,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
test('starts at the start and rests at the end', () => {
|
|
15
|
+
expect(frames[0]).toMatchObject({
|
|
16
|
+
offset: 0,
|
|
17
|
+
opacity: 0,
|
|
18
|
+
transform: 'translate3d(0px, 0px, 0) scale(0.92)',
|
|
19
|
+
filter: 'blur(4px)',
|
|
20
|
+
})
|
|
21
|
+
const last = frames.at(-1)!
|
|
22
|
+
expect(last.opacity).toBeCloseTo(1, 2)
|
|
23
|
+
expect(String(last.transform)).toContain('scale(1')
|
|
24
|
+
expect(String(last.filter)).toBe('blur(0px)')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('critically damped, it never overshoots the end', () => {
|
|
28
|
+
for (const frame of frames) expect(Number(frame.opacity)).toBeLessThanOrEqual(1)
|
|
29
|
+
const scales = frames.map((f) => Number(/scale\(([\d.]+)\)/.exec(String(f.transform))?.[1]))
|
|
30
|
+
expect(Math.max(...scales)).toBeLessThanOrEqual(1.0001)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('with momentum (damping 0.8) the scale overshoots, and opacity still stays in 0-1', () => {
|
|
34
|
+
const springy = springKeyframes({ scale: 0.9 }, { scale: 1 }, 400, { damping: 0.8 })
|
|
35
|
+
const scales = springy.map((f) => Number(/scale\(([\d.]+)\)/.exec(String(f.transform))?.[1]))
|
|
36
|
+
expect(Math.max(...scales)).toBeGreaterThan(1)
|
|
37
|
+
for (const f of springy) expect(Number(f.opacity)).toBeGreaterThanOrEqual(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('moves along x and y too', () => {
|
|
41
|
+
const slid = springKeyframes({ y: 8 }, { y: 0 }, 320)
|
|
42
|
+
expect(String(slid[0]?.transform)).toContain('translate3d(0px, 8px, 0)')
|
|
43
|
+
expect(String(slid.at(-1)?.transform)).toContain('translate3d(0px, 0')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { springValueAt } from './spring'
|
|
2
|
+
|
|
3
|
+
// A spring played as a Web Animations keyframe list: the element moves on the compositor with a
|
|
4
|
+
// spring's shape (critically damped, or overshooting when the gesture carried momentum), and the
|
|
5
|
+
// animation can still be cancelled and re-read mid-flight like any other.
|
|
6
|
+
|
|
7
|
+
export type SpringFrame = Record<string, number | string>
|
|
8
|
+
|
|
9
|
+
const lerp = (a: number, b: number, p: number) => a + (b - a) * p
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `from` and `to` are the numeric ends of scale, opacity, blur (px), x and y (px); the result is
|
|
13
|
+
* ready for `element.animate(frames, { duration, easing: 'linear', fill: 'both' })`.
|
|
14
|
+
*/
|
|
15
|
+
export function springKeyframes(
|
|
16
|
+
from: { scale?: number; opacity?: number; blur?: number; x?: number; y?: number },
|
|
17
|
+
to: { scale?: number; opacity?: number; blur?: number; x?: number; y?: number },
|
|
18
|
+
durationMs: number,
|
|
19
|
+
options: { damping?: number; response?: number; samples?: number } = {},
|
|
20
|
+
): Keyframe[] {
|
|
21
|
+
const { damping = 1, response = 0.35, samples = 18 } = options
|
|
22
|
+
const frames: Keyframe[] = []
|
|
23
|
+
for (let i = 0; i <= samples; i++) {
|
|
24
|
+
const offset = i / samples
|
|
25
|
+
// The last frame is the rest exactly: a spring that has not quite settled must not hold short.
|
|
26
|
+
const p = i === samples ? 1 : springValueAt((offset * durationMs) / 1000, damping, response)
|
|
27
|
+
const scale = lerp(from.scale ?? 1, to.scale ?? 1, p)
|
|
28
|
+
const x = lerp(from.x ?? 0, to.x ?? 0, p)
|
|
29
|
+
const y = lerp(from.y ?? 0, to.y ?? 0, p)
|
|
30
|
+
frames.push({
|
|
31
|
+
offset,
|
|
32
|
+
opacity: Math.min(1, Math.max(0, lerp(from.opacity ?? 1, to.opacity ?? 1, p))),
|
|
33
|
+
transform: `translate3d(${x}px, ${y}px, 0) scale(${scale})`,
|
|
34
|
+
filter: `blur(${Math.max(0, lerp(from.blur ?? 0, to.blur ?? 0, p))}px)`,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
return frames
|
|
38
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { MOMENTUM, SPRING } from './choreography'
|
|
3
|
+
import { Spring } from './spring'
|
|
4
|
+
|
|
5
|
+
const FRAME = 1 / 60
|
|
6
|
+
|
|
7
|
+
function run(spring: Spring, seconds: number) {
|
|
8
|
+
const samples: number[] = []
|
|
9
|
+
for (let t = 0; t < seconds; t += FRAME) samples.push(spring.step(FRAME))
|
|
10
|
+
return samples
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('Spring', () => {
|
|
14
|
+
test('critically damped, it arrives without overshoot', () => {
|
|
15
|
+
const spring = new Spring(0, SPRING).to(1)
|
|
16
|
+
const samples = run(spring, 2)
|
|
17
|
+
|
|
18
|
+
expect(Math.max(...samples)).toBeLessThanOrEqual(1 + 1e-3)
|
|
19
|
+
expect(samples.every((value, i) => i === 0 || value >= samples[i - 1]! - 1e-9)).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('with damping 0.8 it overshoots, then settles', () => {
|
|
23
|
+
const spring = new Spring(0, MOMENTUM).to(1)
|
|
24
|
+
const samples = run(spring, 3)
|
|
25
|
+
|
|
26
|
+
expect(Math.max(...samples)).toBeGreaterThan(1.01)
|
|
27
|
+
expect(spring.rest).toBe(true)
|
|
28
|
+
expect(spring.x).toBe(1)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('settles to rest exactly on its target', () => {
|
|
32
|
+
const spring = new Spring(0.2).to(0.9)
|
|
33
|
+
run(spring, 3)
|
|
34
|
+
|
|
35
|
+
expect(spring.rest).toBe(true)
|
|
36
|
+
expect(spring.x).toBe(0.9)
|
|
37
|
+
expect(spring.v).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('a retarget mid-flight keeps the velocity: no jump, no restart', () => {
|
|
41
|
+
const spring = new Spring(0, SPRING).to(100)
|
|
42
|
+
run(spring, 0.1)
|
|
43
|
+
const velocity = spring.v
|
|
44
|
+
const position = spring.x
|
|
45
|
+
|
|
46
|
+
spring.to(0)
|
|
47
|
+
|
|
48
|
+
expect(velocity).toBeGreaterThan(0)
|
|
49
|
+
expect(spring.v).toBe(velocity)
|
|
50
|
+
expect(spring.x).toBe(position)
|
|
51
|
+
// It carries on forward for a moment before it turns back.
|
|
52
|
+
const next = spring.step(FRAME)
|
|
53
|
+
expect(next).toBeGreaterThan(position)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('a long frame takes sub-steps and stays stable', () => {
|
|
57
|
+
const spring = new Spring(0, SPRING).to(1)
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < 20; i++) spring.step(0.25)
|
|
60
|
+
|
|
61
|
+
expect(spring.x).toBe(1)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { SPRING, type SpringTokens } from './choreography'
|
|
2
|
+
|
|
3
|
+
const TAU = Math.PI * 2
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A spring in Apple's two parameters: damping (1 = critically damped, below 1 it overshoots) and
|
|
7
|
+
* response (seconds to arrive). Retargeting keeps the velocity, so an interrupted motion bends to
|
|
8
|
+
* its new target instead of restarting, which is what makes a grabbed bubble feel held.
|
|
9
|
+
*/
|
|
10
|
+
export class Spring {
|
|
11
|
+
x: number
|
|
12
|
+
t: number
|
|
13
|
+
v = 0
|
|
14
|
+
private k = 0
|
|
15
|
+
private c = 0
|
|
16
|
+
|
|
17
|
+
constructor(x = 0, params: Partial<SpringTokens> = SPRING) {
|
|
18
|
+
this.x = this.t = x
|
|
19
|
+
this.set(params)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
set({ damping = 1, response = 0.35 }: Partial<SpringTokens> = {}) {
|
|
23
|
+
this.k = (TAU / response) ** 2
|
|
24
|
+
this.c = (4 * Math.PI * damping) / response
|
|
25
|
+
return this
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
to(target: number, params?: Partial<SpringTokens>) {
|
|
29
|
+
if (params) this.set(params)
|
|
30
|
+
this.t = target
|
|
31
|
+
return this
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
snap(x: number) {
|
|
35
|
+
this.x = this.t = x
|
|
36
|
+
this.v = 0
|
|
37
|
+
return this
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Advances by `dt` seconds in sub-steps of at most 1/240 s, so a long frame stays stable. */
|
|
41
|
+
step(dt: number) {
|
|
42
|
+
if (this.rest) return this.x
|
|
43
|
+
const n = Math.max(1, Math.ceil(dt * 240))
|
|
44
|
+
const s = dt / n
|
|
45
|
+
for (let i = 0; i < n; i++) {
|
|
46
|
+
this.v += (-this.k * (this.x - this.t) - this.c * this.v) * s
|
|
47
|
+
this.x += this.v * s
|
|
48
|
+
}
|
|
49
|
+
if (Math.abs(this.x - this.t) < 1e-4 && Math.abs(this.v) < 1e-3) {
|
|
50
|
+
this.x = this.t
|
|
51
|
+
this.v = 0
|
|
52
|
+
}
|
|
53
|
+
return this.x
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get rest() {
|
|
57
|
+
return this.x === this.t && this.v === 0
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Two independent springs: 2D motion on one spring desyncs when x and y move at different speeds. */
|
|
62
|
+
export class SpringPair {
|
|
63
|
+
readonly x: Spring
|
|
64
|
+
readonly y: Spring
|
|
65
|
+
|
|
66
|
+
constructor(x = 0, y = 0, params: Partial<SpringTokens> = SPRING) {
|
|
67
|
+
this.x = new Spring(x, params)
|
|
68
|
+
this.y = new Spring(y, params)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
to(x: number, y: number, params?: Partial<SpringTokens>) {
|
|
72
|
+
this.x.to(x, params)
|
|
73
|
+
this.y.to(y, params)
|
|
74
|
+
return this
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
snap(x: number, y: number) {
|
|
78
|
+
this.x.snap(x)
|
|
79
|
+
this.y.snap(y)
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
step(dt: number) {
|
|
84
|
+
this.x.step(dt)
|
|
85
|
+
this.y.step(dt)
|
|
86
|
+
return this.value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get value() {
|
|
90
|
+
return { x: this.x.x, y: this.y.x }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get rest() {
|
|
94
|
+
return this.x.rest && this.y.rest
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The same spring as a closed-form function of time (0 to 1), for motion locked to a clock. */
|
|
99
|
+
export function springValueAt(t: number, damping = 1, response = 0.35) {
|
|
100
|
+
if (t <= 0) return 0
|
|
101
|
+
const w = TAU / response
|
|
102
|
+
if (damping >= 1) return 1 - (1 + w * t) * Math.exp(-w * t)
|
|
103
|
+
const wd = w * Math.sqrt(1 - damping * damping)
|
|
104
|
+
return (
|
|
105
|
+
1 - Math.exp(-damping * w * t) * (Math.cos(wd * t) + ((damping * w) / wd) * Math.sin(wd * t))
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { followUpSuggestionsFor, suggestionsFor } from './suggestions'
|
|
3
|
+
|
|
4
|
+
// Chosen locally from the kind and the element's state, with no model call, so they are there on
|
|
5
|
+
// the first frame (spec 2.4). Three chips each, sentence case.
|
|
6
|
+
|
|
7
|
+
describe('suggestionsFor', () => {
|
|
8
|
+
test('a room: furnish when it is empty, rearrange when it is not', () => {
|
|
9
|
+
const empty = suggestionsFor('room', { empty: true })
|
|
10
|
+
const furnished = suggestionsFor('room', { empty: false })
|
|
11
|
+
|
|
12
|
+
expect(empty[0]).not.toBe(furnished[0])
|
|
13
|
+
expect(suggestionsFor('room', {})[0]).toBe(furnished[0])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('a wall: a window at the clicked station, unless one is there', () => {
|
|
17
|
+
const bare = suggestionsFor('wall', {})
|
|
18
|
+
const windowed = suggestionsFor('wall', { hasWindowAtStation: true })
|
|
19
|
+
|
|
20
|
+
expect(windowed[0]).not.toBe(bare[0])
|
|
21
|
+
expect(windowed.slice(1)).toEqual(bare.slice(1))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('every kind gives three, in sentence case, with no duplicates', () => {
|
|
25
|
+
for (const kind of [
|
|
26
|
+
'room',
|
|
27
|
+
'wall',
|
|
28
|
+
'window',
|
|
29
|
+
'door',
|
|
30
|
+
'item',
|
|
31
|
+
'roof',
|
|
32
|
+
'stair',
|
|
33
|
+
'several',
|
|
34
|
+
'area',
|
|
35
|
+
'other',
|
|
36
|
+
] as const) {
|
|
37
|
+
const chips = suggestionsFor(kind, {})
|
|
38
|
+
|
|
39
|
+
expect(chips).toHaveLength(3)
|
|
40
|
+
expect(new Set(chips).size).toBe(3)
|
|
41
|
+
for (const chip of chips) expect(chip[0]).toBe(chip[0]?.toUpperCase())
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('followUpSuggestionsFor', () => {
|
|
47
|
+
const kinds = [
|
|
48
|
+
'room',
|
|
49
|
+
'wall',
|
|
50
|
+
'window',
|
|
51
|
+
'door',
|
|
52
|
+
'item',
|
|
53
|
+
'roof',
|
|
54
|
+
'stair',
|
|
55
|
+
'several',
|
|
56
|
+
'area',
|
|
57
|
+
'other',
|
|
58
|
+
] as const
|
|
59
|
+
|
|
60
|
+
for (const kind of kinds) {
|
|
61
|
+
test(`${kind}: three fresh chips, none of them what was just asked`, () => {
|
|
62
|
+
const first = suggestionsFor(kind, {})
|
|
63
|
+
const chips = followUpSuggestionsFor(kind, first[0] as string)
|
|
64
|
+
|
|
65
|
+
expect(chips).toHaveLength(3)
|
|
66
|
+
expect(new Set(chips).size).toBe(3)
|
|
67
|
+
expect(chips.map((c) => c.toLowerCase())).not.toContain((first[0] as string).toLowerCase())
|
|
68
|
+
// "Change more" is not the first bubble again: at most one chip is one it already offered.
|
|
69
|
+
expect(chips.filter((chip) => first.includes(chip)).length).toBeLessThanOrEqual(1)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('skips the words just sent, whatever the case or the full stop', () => {
|
|
74
|
+
const [firstOffered] = followUpSuggestionsFor('window', '')
|
|
75
|
+
const chips = followUpSuggestionsFor('window', ` ${(firstOffered as string).toUpperCase()}. `)
|
|
76
|
+
|
|
77
|
+
expect(chips).toHaveLength(3)
|
|
78
|
+
expect(chips).not.toContain(firstOffered)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PointKind, PointTarget } from './types'
|
|
2
|
+
|
|
3
|
+
type SuggestionState = {
|
|
4
|
+
/** A room with nothing in it. */
|
|
5
|
+
empty?: boolean
|
|
6
|
+
/** A window already stands where the wall was clicked. */
|
|
7
|
+
hasWindowAtStation?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const CHIPS: Record<Exclude<PointKind, 'room' | 'wall'>, [string, string, string]> = {
|
|
11
|
+
window: ['Make it wider', 'Add a matching window', 'Change the frame colour'],
|
|
12
|
+
door: ['Flip the swing', 'Make it a sliding door', 'Widen it to 90 cm'],
|
|
13
|
+
item: ['Swap for something similar', 'Put it against the wall', 'Remove it'],
|
|
14
|
+
roof: ['Change the pitch', 'Make it a hip roof', 'Change the roofing'],
|
|
15
|
+
stair: ['Turn it 90°', 'Add a handrail', 'Make it wider'],
|
|
16
|
+
several: ['Make these match', 'Line them up', 'Space them evenly'],
|
|
17
|
+
area: ['What’s wrong here?', 'Make this match the reference', 'Tidy this up'],
|
|
18
|
+
other: ['Tell me about this', 'Change its finish', 'Remove it'],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Three chips chosen locally from the kind and the element's state, with no model call, so they
|
|
23
|
+
* are there on the first frame and teach what Pascal can do here (spec 2.4).
|
|
24
|
+
*/
|
|
25
|
+
export function suggestionsFor(kind: PointKind, state: SuggestionState = {}): string[] {
|
|
26
|
+
if (kind === 'room')
|
|
27
|
+
return [
|
|
28
|
+
state.empty ? 'Furnish this room' : 'Rearrange the furniture',
|
|
29
|
+
'Make it 1 m longer',
|
|
30
|
+
'Change the floor finish',
|
|
31
|
+
]
|
|
32
|
+
if (kind === 'wall')
|
|
33
|
+
return [
|
|
34
|
+
state.hasWindowAtStation ? 'Move the window along the wall' : 'Add a window here',
|
|
35
|
+
'Change the finish on this side',
|
|
36
|
+
'Move it 50 cm',
|
|
37
|
+
]
|
|
38
|
+
return [...CHIPS[kind]]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const THIS: Partial<Record<PointKind, string>> = {
|
|
42
|
+
wall: 'this wall',
|
|
43
|
+
window: 'this window',
|
|
44
|
+
door: 'this door',
|
|
45
|
+
item: 'this item',
|
|
46
|
+
roof: 'this roof',
|
|
47
|
+
stair: 'this stair',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** "Ask about Kitchen…", "Ask about this window…", "Ask about these…", "Ask about this area…". */
|
|
51
|
+
export function placeholderFor(targets: readonly PointTarget[]): string {
|
|
52
|
+
if (targets.length === 0) return 'Ask about this area…'
|
|
53
|
+
if (targets.length > 1) return 'Ask about these…'
|
|
54
|
+
const [only] = targets as [PointTarget]
|
|
55
|
+
if (only.kind === 'room') return `Ask about ${only.name}…`
|
|
56
|
+
if (only.kind === 'area') return 'Ask about this area…'
|
|
57
|
+
return `Ask about ${THIS[only.kind] ?? 'this'}…`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the chips are chosen by, from a node's type. A region is an area whatever it holds. */
|
|
61
|
+
export function kindOf(nodeType: string, isRegion = false): PointKind {
|
|
62
|
+
if (isRegion) return 'area'
|
|
63
|
+
switch (nodeType) {
|
|
64
|
+
case 'zone':
|
|
65
|
+
return 'room'
|
|
66
|
+
case 'wall':
|
|
67
|
+
return 'wall'
|
|
68
|
+
case 'window':
|
|
69
|
+
return 'window'
|
|
70
|
+
case 'door':
|
|
71
|
+
return 'door'
|
|
72
|
+
case 'item':
|
|
73
|
+
case 'procedural-item':
|
|
74
|
+
case 'imported-mesh':
|
|
75
|
+
case 'column':
|
|
76
|
+
return 'item'
|
|
77
|
+
case 'roof':
|
|
78
|
+
case 'roof-segment':
|
|
79
|
+
return 'roof'
|
|
80
|
+
case 'stair':
|
|
81
|
+
case 'stair-segment':
|
|
82
|
+
return 'stair'
|
|
83
|
+
default:
|
|
84
|
+
return 'other'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function kindForTargets(targets: readonly PointTarget[], isRegion = false): PointKind {
|
|
89
|
+
if (isRegion || targets.length === 0) return 'area'
|
|
90
|
+
if (targets.length > 1) return 'several'
|
|
91
|
+
return (targets[0] as PointTarget).kind
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const FOLLOW_UP_CHIPS: Record<PointKind, string[]> = {
|
|
95
|
+
room: ['Add a rug', 'Change the wall colour', 'Hang something on the walls', 'Add more light'],
|
|
96
|
+
wall: ['Add a door here', 'Paint this wall', 'Hang something on it', 'Make it thicker'],
|
|
97
|
+
window: [
|
|
98
|
+
'Make it taller',
|
|
99
|
+
'Move it 30 cm along the wall',
|
|
100
|
+
'Change the sill height',
|
|
101
|
+
'Add a matching window',
|
|
102
|
+
],
|
|
103
|
+
door: ['Make it wider', 'Move it along the wall', 'Change the colour', 'Add a window beside it'],
|
|
104
|
+
item: ['Turn it 90°', 'Make it bigger', 'Change its colour', 'Add another one'],
|
|
105
|
+
roof: ['Add a skylight', 'Make the eaves longer', 'Change the colour', 'Add a chimney'],
|
|
106
|
+
stair: ['Add a landing', 'Change the treads', 'Move it 50 cm', 'Make the rise lower'],
|
|
107
|
+
several: ['Make them bigger', 'Change their colour', 'Group them', 'Remove the second one'],
|
|
108
|
+
area: ['Add something here', 'Clear this space', 'Make it brighter', 'Change the floor here'],
|
|
109
|
+
other: ['Make it bigger', 'Change the colour', 'Move it 50 cm', 'Duplicate it'],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const plain = (words: string) =>
|
|
113
|
+
words
|
|
114
|
+
.trim()
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.replace(/[.!?…]+$/u, '')
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Three chips for asking again on what a turn just changed ("Change more"): the next things to
|
|
120
|
+
* try, never the words that were just sent.
|
|
121
|
+
*/
|
|
122
|
+
export function followUpSuggestionsFor(kind: PointKind, justAsked: string): string[] {
|
|
123
|
+
const sent = plain(justAsked)
|
|
124
|
+
return FOLLOW_UP_CHIPS[kind].filter((chip) => plain(chip) !== sent).slice(0, 3)
|
|
125
|
+
}
|