@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,1391 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
beginScenePreview,
|
|
5
|
+
emitter,
|
|
6
|
+
hasPointAskHandler,
|
|
7
|
+
type NodeEvent,
|
|
8
|
+
type PointAskStatus,
|
|
9
|
+
type PointCamera,
|
|
10
|
+
resolveLevelId,
|
|
11
|
+
type SceneCommit,
|
|
12
|
+
type SceneSnapshot,
|
|
13
|
+
sceneRegistry,
|
|
14
|
+
submitPointAsk,
|
|
15
|
+
subscribeSceneCommits,
|
|
16
|
+
subscribeScenePreview,
|
|
17
|
+
useScene,
|
|
18
|
+
} from '@pascal-app/core'
|
|
19
|
+
import { sceneViewBounds, sceneViewPose } from '@pascal-app/core/agent-operations'
|
|
20
|
+
import { retractNodes, useViewer } from '@pascal-app/viewer'
|
|
21
|
+
import { type Object3D, Vector2, Vector3 } from 'three'
|
|
22
|
+
import {
|
|
23
|
+
resolveRoofSegmentSelectionTarget,
|
|
24
|
+
roomForEvent,
|
|
25
|
+
} from '../../components/editor/selection-manager'
|
|
26
|
+
import { getEditorThreeContext } from '../../components/editor/three-context-bridge'
|
|
27
|
+
import { resolveEditorRoomHit } from '../../hooks/use-selected-room'
|
|
28
|
+
import { cameraPoseStore } from '../../store/camera-pose-store'
|
|
29
|
+
import useEditor, { getActiveContinuationContext } from '../../store/use-editor'
|
|
30
|
+
import useFollowPascal from '../../store/use-follow-pascal'
|
|
31
|
+
import useInteractionScope from '../../store/use-interaction-scope'
|
|
32
|
+
import {
|
|
33
|
+
type BubbleTarget,
|
|
34
|
+
type PinRecord,
|
|
35
|
+
type PointBubble,
|
|
36
|
+
type PointHit,
|
|
37
|
+
type PointHover,
|
|
38
|
+
usePointAsk,
|
|
39
|
+
type WorldBox,
|
|
40
|
+
} from '../../store/use-point-ask'
|
|
41
|
+
import { isViewVisible, VIEW_3D } from '../../store/view-layout'
|
|
42
|
+
import { keyCyclableContinuationContext } from '../continuation'
|
|
43
|
+
import { resolveCanvasSelectionNode } from '../selection-routing'
|
|
44
|
+
import { triggerSFX } from '../sfx-bus'
|
|
45
|
+
import { buildPointContext } from './build-context'
|
|
46
|
+
import { type CapturedCrop, captureViewCrop } from './capture-crop'
|
|
47
|
+
import { CLICK_SLOP_PX, MAX_TARGETS, TAP_LATCH_MS } from './choreography'
|
|
48
|
+
import { createCompare } from './compare'
|
|
49
|
+
import { describeTarget } from './describe-target'
|
|
50
|
+
import {
|
|
51
|
+
createdSince,
|
|
52
|
+
type HistoryMark,
|
|
53
|
+
markHistory,
|
|
54
|
+
stateAfterMark,
|
|
55
|
+
stepsSince,
|
|
56
|
+
} from './history-marks'
|
|
57
|
+
import { outlineBox } from './outline-box'
|
|
58
|
+
import { resolvePointPick } from './pick'
|
|
59
|
+
import { createPin } from './pin-machine'
|
|
60
|
+
import { canvasRect, projectBox, unionRect } from './projector'
|
|
61
|
+
import {
|
|
62
|
+
formatRegionSize,
|
|
63
|
+
isRegionBigEnough,
|
|
64
|
+
nearestEdgePoint,
|
|
65
|
+
rankInRegion,
|
|
66
|
+
rectFromDrag,
|
|
67
|
+
regionFractions,
|
|
68
|
+
regionLabel,
|
|
69
|
+
regionSizeMetres,
|
|
70
|
+
} from './region'
|
|
71
|
+
import { planRetry } from './retry'
|
|
72
|
+
import { pointCommitEvent } from './scene-commit'
|
|
73
|
+
import type { Rect } from './types'
|
|
74
|
+
|
|
75
|
+
// Point and ask's session (the owner, 8 October): the mode's entry and exit, what the pointer
|
|
76
|
+
// picks, the bubble's lifecycle, the send, and the pins' link to the chat and the scene. It owns
|
|
77
|
+
// no pixels: the overlay draws from `usePointAsk`, this module writes to it.
|
|
78
|
+
|
|
79
|
+
const CROP_WAIT_MS = 1_500
|
|
80
|
+
|
|
81
|
+
type Down = {
|
|
82
|
+
x: number
|
|
83
|
+
y: number
|
|
84
|
+
moved: boolean
|
|
85
|
+
hover: PointHover | null
|
|
86
|
+
capturedAt: string
|
|
87
|
+
camera: PointCamera | null
|
|
88
|
+
crop: Promise<CapturedCrop | null>
|
|
89
|
+
shift: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let stopSession: (() => void) | null = null
|
|
93
|
+
|
|
94
|
+
// ─── what is pointed at ─────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/** The editor's own lookups for a hit, handed to the pure pick. */
|
|
97
|
+
function pickFor(event: NodeEvent, alt: boolean, selectedTargetIds: readonly string[]) {
|
|
98
|
+
const editor = useEditor.getState()
|
|
99
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
100
|
+
const levelId = useViewer.getState().selection.levelId ?? null
|
|
101
|
+
return resolvePointPick({
|
|
102
|
+
event,
|
|
103
|
+
alt,
|
|
104
|
+
selectedTargetIds,
|
|
105
|
+
nodes,
|
|
106
|
+
currentLevelId: levelId,
|
|
107
|
+
phase: editor.phase,
|
|
108
|
+
zonesPickable: false,
|
|
109
|
+
resolveRoom: (e) => roomForEvent(e),
|
|
110
|
+
roomAtPoint: (level, x, z) => resolveEditorRoomHit(null, level, [x, z], '3d'),
|
|
111
|
+
resolveRoofSegment: (e) => resolveRoofSegmentSelectionTarget(e),
|
|
112
|
+
resolveNode: (node) =>
|
|
113
|
+
resolveCanvasSelectionNode({
|
|
114
|
+
node,
|
|
115
|
+
nodes,
|
|
116
|
+
selectedIds: useViewer.getState().selection.selectedIds,
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The element's world box as view_scene frames it, or null when it has none to draw. */
|
|
122
|
+
function worldBoxOf(nodes: Record<string, AnyNode>, id: string): WorldBox | null {
|
|
123
|
+
try {
|
|
124
|
+
const node = nodes[id]
|
|
125
|
+
return node ? outlineBox(node.type, sceneViewBounds(nodes, id)) : null
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function targetFor(id: string): BubbleTarget | null {
|
|
132
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
133
|
+
const node = nodes[id]
|
|
134
|
+
if (!node) return null
|
|
135
|
+
const viewer = useViewer.getState()
|
|
136
|
+
const described = describeTarget(node, nodes, {
|
|
137
|
+
unit: viewer.unit,
|
|
138
|
+
metricNotation: viewer.metricNotation,
|
|
139
|
+
})
|
|
140
|
+
const {
|
|
141
|
+
sizes: _sizes,
|
|
142
|
+
levelId: _levelId,
|
|
143
|
+
zoneId: _zoneId,
|
|
144
|
+
parentId: _parentId,
|
|
145
|
+
...target
|
|
146
|
+
} = described
|
|
147
|
+
return { ...target, box: worldBoxOf(nodes, id) }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const targetCache = new Map<string, BubbleTarget>()
|
|
151
|
+
|
|
152
|
+
function cachedTarget(id: string): BubbleTarget | null {
|
|
153
|
+
const cached = targetCache.get(id)
|
|
154
|
+
if (cached) return cached
|
|
155
|
+
const target = targetFor(id)
|
|
156
|
+
if (target) targetCache.set(id, target)
|
|
157
|
+
return target
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A pick under the cursor, as the store's hover. */
|
|
161
|
+
function hoverFromEvent(event: NodeEvent): PointHover | null {
|
|
162
|
+
const state = usePointAsk.getState()
|
|
163
|
+
const pick = pickFor(event, state.alt, state.bubble?.targets.map((t) => t.id) ?? [])
|
|
164
|
+
if (!pick) return null
|
|
165
|
+
const target = cachedTarget(pick.nodeId)
|
|
166
|
+
if (!target) return null
|
|
167
|
+
const point = event.position as [number, number, number]
|
|
168
|
+
const normal = (event.normal ?? [0, 1, 0]) as [number, number, number]
|
|
169
|
+
const hit: PointHit = { point, normal, ...(pick.face ? { face: pick.face } : {}) }
|
|
170
|
+
return { target, ambiguous: pick.ambiguous, hit }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── the camera the person pointed from ─────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function currentCamera(): PointCamera | null {
|
|
176
|
+
const pose = cameraPoseStore.getState().pose
|
|
177
|
+
const rect = canvasRect()
|
|
178
|
+
if (!(pose && rect)) return null
|
|
179
|
+
return {
|
|
180
|
+
position: pose.position,
|
|
181
|
+
target: pose.target,
|
|
182
|
+
fov: pose.fov ?? 50,
|
|
183
|
+
aspect: rect.width / Math.max(1, rect.height),
|
|
184
|
+
projection: pose.projection,
|
|
185
|
+
...(pose.projection === 'orthographic' && pose.viewWidth !== undefined
|
|
186
|
+
? { viewWidth: pose.viewWidth }
|
|
187
|
+
: {}),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── entering and leaving ───────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
let suspendedTool: ReturnType<typeof useEditor.getState>['toolMode'] | null = null
|
|
194
|
+
let cDown: number | null = null
|
|
195
|
+
let leaveOnClose = false
|
|
196
|
+
|
|
197
|
+
/** Whether the mode can start now: a host answers asks, the view is 3D, nothing else holds the pointer. */
|
|
198
|
+
export function canPoint(): boolean {
|
|
199
|
+
if (!hasPointAskHandler()) return false
|
|
200
|
+
const editor = useEditor.getState()
|
|
201
|
+
if (!isViewVisible(editor, VIEW_3D) || editor.isFirstPersonMode || editor.isPreviewMode)
|
|
202
|
+
return false
|
|
203
|
+
if (editor.workspaceMode !== 'edit') return false
|
|
204
|
+
return !!getEditorThreeContext()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether C is the mode's key right now. It is the tool's while a gesture is under way or the armed
|
|
209
|
+
* tool cycles something with it (the placement tools' "place once"); an armed tool that is only
|
|
210
|
+
* waiting (a wall about to be drawn) is put aside and comes back when the mode leaves.
|
|
211
|
+
*/
|
|
212
|
+
function cIsOurs(): boolean {
|
|
213
|
+
const editor = useEditor.getState()
|
|
214
|
+
if (editor.mode !== 'select' && editor.mode !== 'build') return false
|
|
215
|
+
if (useInteractionScope.getState().scope.kind !== 'idle') return false
|
|
216
|
+
return keyCyclableContinuationContext(getActiveContinuationContext()) === null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function isPointing(): boolean {
|
|
220
|
+
return usePointAsk.getState().active
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function enterPointMode(latched: boolean): boolean {
|
|
224
|
+
if (usePointAsk.getState().active) return true
|
|
225
|
+
if (!canPoint()) return false
|
|
226
|
+
const editor = useEditor.getState()
|
|
227
|
+
// An armed tool is put aside and comes back when the mode leaves.
|
|
228
|
+
if (editor.mode !== 'select') {
|
|
229
|
+
suspendedTool = editor.toolMode
|
|
230
|
+
editor.armToolMode({ mode: 'select' })
|
|
231
|
+
} else suspendedTool = null
|
|
232
|
+
const scope = useInteractionScope.getState()
|
|
233
|
+
if (scope.scope.kind !== 'idle') scope.end()
|
|
234
|
+
scope.begin({ kind: 'pointing' })
|
|
235
|
+
useViewer.getState().setHoverHighlightMode('point')
|
|
236
|
+
useViewer.getState().setHoveredId(null)
|
|
237
|
+
usePointAsk.getState().setActive(true, latched)
|
|
238
|
+
setCursor(true)
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function leavePointMode() {
|
|
243
|
+
const state = usePointAsk.getState()
|
|
244
|
+
if (!state.active) return
|
|
245
|
+
endCompare()
|
|
246
|
+
// Off first: ending the scope below must not find the mode still on and leave it again.
|
|
247
|
+
state.setActive(false)
|
|
248
|
+
cDown = null
|
|
249
|
+
leaveOnClose = false
|
|
250
|
+
regionCandidates = null
|
|
251
|
+
const viewer = useViewer.getState()
|
|
252
|
+
viewer.setHoveredId(null)
|
|
253
|
+
viewer.setPointedIds(null)
|
|
254
|
+
useInteractionScope.getState().endIf((s) => s.kind === 'pointing')
|
|
255
|
+
viewer.setHoverHighlightMode(useEditor.getState().mode === 'delete' ? 'delete' : 'default')
|
|
256
|
+
state.setRegion(null)
|
|
257
|
+
targetCache.clear()
|
|
258
|
+
setCursor(false)
|
|
259
|
+
if (suspendedTool) {
|
|
260
|
+
const tool = suspendedTool
|
|
261
|
+
suspendedTool = null
|
|
262
|
+
useEditor
|
|
263
|
+
.getState()
|
|
264
|
+
.armToolMode(tool as Parameters<ReturnType<typeof useEditor.getState>['armToolMode']>[0])
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const CURSOR =
|
|
269
|
+
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'><path d='M5 3l15 7.2-6.4 1.9-2.3 6.4z' fill='%23a99bff' stroke='%2316171b' stroke-width='1.4' stroke-linejoin='round'/><rect x='15' y='15' width='11' height='9' rx='3' fill='%23edece9' stroke='%2316171b' stroke-width='1'/><text x='20.5' y='22' font-family='system-ui,sans-serif' font-size='7.5' font-weight='700' text-anchor='middle' fill='%2316171b'>C</text></svg>\") 5 3, crosshair"
|
|
270
|
+
|
|
271
|
+
function setCursor(on: boolean) {
|
|
272
|
+
const canvas = getEditorThreeContext()?.domElement
|
|
273
|
+
if (canvas) canvas.style.cursor = on ? CURSOR : ''
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── the bubble ─────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function openBubble(
|
|
279
|
+
targets: BubbleTarget[],
|
|
280
|
+
hit: PointHit,
|
|
281
|
+
gesture: PointBubble['gesture'],
|
|
282
|
+
down: Pick<Down, 'crop' | 'capturedAt' | 'camera'> | null,
|
|
283
|
+
area?: PointBubble['area'],
|
|
284
|
+
changesAsk?: PointBubble['changesAsk'],
|
|
285
|
+
) {
|
|
286
|
+
const state = usePointAsk.getState()
|
|
287
|
+
// Asking for more starts from an empty field; the words just sent are not a draft to come back to.
|
|
288
|
+
const draft = changesAsk
|
|
289
|
+
? ''
|
|
290
|
+
: (state.bubble?.draft ?? state.drafts[targets[0]?.id ?? area?.ids.join() ?? ''] ?? '')
|
|
291
|
+
const frame = canvasRect()
|
|
292
|
+
const crop = down?.crop
|
|
293
|
+
const bubble: PointBubble = {
|
|
294
|
+
targets,
|
|
295
|
+
anchor: hit.point,
|
|
296
|
+
hit,
|
|
297
|
+
gesture,
|
|
298
|
+
capturedAt: down?.capturedAt ?? new Date().toISOString(),
|
|
299
|
+
camera: down?.camera ?? currentCamera(),
|
|
300
|
+
crop: { state: crop ? 'pending' : 'none' },
|
|
301
|
+
draft,
|
|
302
|
+
error: null,
|
|
303
|
+
...(area && frame ? { area, region: regionFractions(area.rect, frame) } : {}),
|
|
304
|
+
...(changesAsk ? { changesAsk } : {}),
|
|
305
|
+
}
|
|
306
|
+
state.openBubble(bubble)
|
|
307
|
+
// The bubble's targets keep the point outline while the pointer goes on naming the next pick.
|
|
308
|
+
useViewer
|
|
309
|
+
.getState()
|
|
310
|
+
.setPointedIds(
|
|
311
|
+
targets.length ? (targets.map((t) => t.id) as never) : ((area?.ids as never) ?? null),
|
|
312
|
+
)
|
|
313
|
+
if (crop) {
|
|
314
|
+
const opened = bubble
|
|
315
|
+
crop.then(
|
|
316
|
+
(value) => {
|
|
317
|
+
const current = usePointAsk.getState().bubble
|
|
318
|
+
if (!(current && current.capturedAt === opened.capturedAt)) return
|
|
319
|
+
usePointAsk.getState().patchBubble({
|
|
320
|
+
crop: value
|
|
321
|
+
? {
|
|
322
|
+
state: 'ready',
|
|
323
|
+
dataUrl: value.dataUrl,
|
|
324
|
+
width: value.width,
|
|
325
|
+
height: value.height,
|
|
326
|
+
marked: false,
|
|
327
|
+
}
|
|
328
|
+
: { state: 'failed' },
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
() => undefined,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
downCrops.set(bubble.capturedAt, crop ?? Promise.resolve(null))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The pending crop of each bubble, kept so a send can wait for it. */
|
|
338
|
+
const downCrops = new Map<string, Promise<CapturedCrop | null>>()
|
|
339
|
+
|
|
340
|
+
export function closeBubble(kind: 'send' | 'dismiss' = 'dismiss') {
|
|
341
|
+
const state = usePointAsk.getState()
|
|
342
|
+
const bubble = state.bubble
|
|
343
|
+
if (!bubble) return
|
|
344
|
+
// Words that were not sent are kept for the next time the same element is pointed at.
|
|
345
|
+
if (kind === 'dismiss') state.rememberDraft(bubble.targets[0]?.id ?? '', bubble.draft)
|
|
346
|
+
downCrops.delete(bubble.capturedAt)
|
|
347
|
+
if (bubble.area) state.setRegion(null)
|
|
348
|
+
state.closeBubble(kind)
|
|
349
|
+
useViewer.getState().setPointedIds(null)
|
|
350
|
+
if (leaveOnClose) {
|
|
351
|
+
leaveOnClose = false
|
|
352
|
+
leavePointMode()
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function startCrop(hover: PointHover | null): Promise<CapturedCrop | null> {
|
|
357
|
+
if (!hover?.target.box) return Promise.resolve(null)
|
|
358
|
+
const rect = projectBox(hover.target.box)
|
|
359
|
+
if (!rect) return Promise.resolve(null)
|
|
360
|
+
return captureViewCrop(rect).then(
|
|
361
|
+
(crop) => crop,
|
|
362
|
+
(error) => {
|
|
363
|
+
console.debug('[point-ask] no picture:', error instanceof Error ? error.message : error)
|
|
364
|
+
return null
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── pointer ────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
let down: Down | null = null
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* What is under a page point, found by raycasting the scene's registered nodes: the pick for a press
|
|
375
|
+
* that lands before any hover was read (a fast click, a touch, which has no hover at all). The same
|
|
376
|
+
* `resolvePointPick` decides it, so a click without a hover sends what the hover would have shown.
|
|
377
|
+
*/
|
|
378
|
+
function pickAtPoint(x: number, y: number): PointHover | null {
|
|
379
|
+
const three = getEditorThreeContext()
|
|
380
|
+
const rect = canvasRect()
|
|
381
|
+
if (!(three && rect)) return null
|
|
382
|
+
three.raycaster.setFromCamera(
|
|
383
|
+
new Vector2(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1),
|
|
384
|
+
three.camera,
|
|
385
|
+
)
|
|
386
|
+
const owners = new Map<Object3D, string>()
|
|
387
|
+
for (const [id, object] of sceneRegistry.nodes) owners.set(object, id)
|
|
388
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
389
|
+
for (const hit of three.raycaster.intersectObjects([...owners.keys()], true)) {
|
|
390
|
+
let object: Object3D | null = hit.object
|
|
391
|
+
let hidden = false
|
|
392
|
+
let id: string | undefined
|
|
393
|
+
for (; object; object = object.parent) {
|
|
394
|
+
if (!object.visible) hidden = true
|
|
395
|
+
id ??= owners.get(object)
|
|
396
|
+
}
|
|
397
|
+
const node = id ? nodes[id] : undefined
|
|
398
|
+
if (hidden || !node) continue
|
|
399
|
+
const local = hit.object.worldToLocal(hit.point.clone())
|
|
400
|
+
const event = {
|
|
401
|
+
node,
|
|
402
|
+
position: [hit.point.x, hit.point.y, hit.point.z],
|
|
403
|
+
localPosition: [local.x, local.y, local.z],
|
|
404
|
+
normal: hit.face ? [hit.face.normal.x, hit.face.normal.y, hit.face.normal.z] : undefined,
|
|
405
|
+
faceIndex: hit.faceIndex,
|
|
406
|
+
object: hit.object,
|
|
407
|
+
stopPropagation: () => undefined,
|
|
408
|
+
} as unknown as NodeEvent
|
|
409
|
+
const hover = hoverFromEvent(event)
|
|
410
|
+
if (hover) return hover
|
|
411
|
+
}
|
|
412
|
+
return null
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function onPointerDown(event: PointerEvent) {
|
|
416
|
+
const state = usePointAsk.getState()
|
|
417
|
+
if (!state.active || event.button !== 0) return
|
|
418
|
+
const canvas = getEditorThreeContext()?.domElement
|
|
419
|
+
if (!canvas || event.target !== canvas) return
|
|
420
|
+
event.preventDefault()
|
|
421
|
+
event.stopImmediatePropagation()
|
|
422
|
+
// A touch has no hover: its press names what it lands on, and shows the outline at once.
|
|
423
|
+
const hover =
|
|
424
|
+
state.hover && event.pointerType !== 'touch'
|
|
425
|
+
? state.hover
|
|
426
|
+
: pickAtPoint(event.clientX, event.clientY)
|
|
427
|
+
if (hover && hover !== state.hover) {
|
|
428
|
+
state.setHover(hover)
|
|
429
|
+
useViewer.getState().setHoveredId(hover.target.id as AnyNodeId)
|
|
430
|
+
}
|
|
431
|
+
down = {
|
|
432
|
+
x: event.clientX,
|
|
433
|
+
y: event.clientY,
|
|
434
|
+
moved: false,
|
|
435
|
+
hover,
|
|
436
|
+
capturedAt: new Date().toISOString(),
|
|
437
|
+
camera: currentCamera(),
|
|
438
|
+
crop: startCrop(hover),
|
|
439
|
+
shift: event.shiftKey,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─── a region: a view dragged out on the canvas, not a selection ────────────
|
|
444
|
+
|
|
445
|
+
/** The camera's ray through a page point, for measuring a rectangle on the ground. */
|
|
446
|
+
function cameraRay(x: number, y: number) {
|
|
447
|
+
const three = getEditorThreeContext()
|
|
448
|
+
const rect = canvasRect()
|
|
449
|
+
if (!(three && rect)) return null
|
|
450
|
+
three.raycaster.setFromCamera(
|
|
451
|
+
new Vector2(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1),
|
|
452
|
+
three.camera,
|
|
453
|
+
)
|
|
454
|
+
const { origin, direction } = three.raycaster.ray
|
|
455
|
+
return {
|
|
456
|
+
origin: [origin.x, origin.y, origin.z] as [number, number, number],
|
|
457
|
+
direction: [direction.x, direction.y, direction.z] as [number, number, number],
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** The floor of the level in view, in world metres. */
|
|
462
|
+
function levelFloorY(): number {
|
|
463
|
+
const levelId = useViewer.getState().selection.levelId
|
|
464
|
+
const object = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
465
|
+
if (!object) return 0
|
|
466
|
+
object.updateWorldMatrix(true, false)
|
|
467
|
+
return object.getWorldPosition(new Vector3()).y
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** What a region drag could take, boxed once when the drag begins: rebuilding it per move would walk the whole scene. */
|
|
471
|
+
let regionCandidates: { id: string; box: WorldBox }[] | null = null
|
|
472
|
+
let regionPreviewAt = 0
|
|
473
|
+
|
|
474
|
+
function candidatesForRegion() {
|
|
475
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
476
|
+
const levelId = useViewer.getState().selection.levelId
|
|
477
|
+
const found: { id: string; box: WorldBox }[] = []
|
|
478
|
+
for (const node of Object.values(nodes)) {
|
|
479
|
+
if (!REGION_KINDS.has(node.type) || node.visible === false) continue
|
|
480
|
+
if (levelId && resolveLevelId(node, nodes) !== levelId) continue
|
|
481
|
+
const box = worldBoxOf(nodes, node.id)
|
|
482
|
+
if (box) found.push({ id: node.id, box })
|
|
483
|
+
}
|
|
484
|
+
return found
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The live preview of a region: what its rectangle would take is outlined while it is dragged. */
|
|
488
|
+
function previewRegion(rect: Rect, now: number) {
|
|
489
|
+
if (now - regionPreviewAt < 80) return
|
|
490
|
+
regionPreviewAt = now
|
|
491
|
+
regionCandidates ??= candidatesForRegion()
|
|
492
|
+
const { ids } = rankInRegion(
|
|
493
|
+
regionCandidates.map(({ id, box }) => ({ id, rect: projectBox(box) })),
|
|
494
|
+
rect,
|
|
495
|
+
)
|
|
496
|
+
useViewer.getState().setPointedIds(ids.length ? (ids as never) : null)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function updateRegionDrag(press: Down, event: PointerEvent) {
|
|
500
|
+
const bounds = canvasRect()
|
|
501
|
+
if (!bounds) return
|
|
502
|
+
const rect = rectFromDrag(
|
|
503
|
+
{ x: press.x, y: press.y },
|
|
504
|
+
{ x: event.clientX, y: event.clientY },
|
|
505
|
+
{ x0: bounds.left, y0: bounds.top, x1: bounds.right, y1: bounds.bottom },
|
|
506
|
+
)
|
|
507
|
+
const size = regionSizeMetres(rect, cameraRay, levelFloorY())
|
|
508
|
+
const state = usePointAsk.getState()
|
|
509
|
+
state.setRegion({
|
|
510
|
+
rect,
|
|
511
|
+
sizeLabel: size ? formatRegionSize(size, useViewer.getState().unit) : null,
|
|
512
|
+
settled: false,
|
|
513
|
+
flashKey: state.region?.flashKey ?? 0,
|
|
514
|
+
})
|
|
515
|
+
// A region has no pick: the chip and the hover outline step aside while it is drawn.
|
|
516
|
+
if (state.hover) state.setHover(null)
|
|
517
|
+
useViewer.getState().setHoveredId(null)
|
|
518
|
+
previewRegion(rect, event.timeStamp)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** The elements of the level in view that show inside the rectangle, the biggest first. */
|
|
522
|
+
function elementsInRegion(rect: Rect) {
|
|
523
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
524
|
+
const levelId = useViewer.getState().selection.levelId
|
|
525
|
+
const candidates: { id: string; rect: Rect | null }[] = []
|
|
526
|
+
for (const node of Object.values(nodes)) {
|
|
527
|
+
if (!REGION_KINDS.has(node.type) || node.visible === false) continue
|
|
528
|
+
if (levelId && resolveLevelId(node, nodes) !== levelId) continue
|
|
529
|
+
const box = worldBoxOf(nodes, node.id)
|
|
530
|
+
candidates.push({ id: node.id, rect: box ? projectBox(box) : null })
|
|
531
|
+
}
|
|
532
|
+
return rankInRegion(candidates, rect)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const REGION_KINDS = new Set([
|
|
536
|
+
'zone',
|
|
537
|
+
'wall',
|
|
538
|
+
'window',
|
|
539
|
+
'door',
|
|
540
|
+
'item',
|
|
541
|
+
'stair',
|
|
542
|
+
'roof',
|
|
543
|
+
'column',
|
|
544
|
+
'fence',
|
|
545
|
+
'shelf',
|
|
546
|
+
])
|
|
547
|
+
|
|
548
|
+
/** The press became a drag and was let go: the shutter fires and a bubble opens on the rectangle. */
|
|
549
|
+
function finishRegion(press: Down, event: PointerEvent) {
|
|
550
|
+
const state = usePointAsk.getState()
|
|
551
|
+
const region = state.region
|
|
552
|
+
regionCandidates = null
|
|
553
|
+
if (!(region && isRegionBigEnough(region.rect))) {
|
|
554
|
+
state.setRegion(null)
|
|
555
|
+
useViewer.getState().setPointedIds(null)
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
const rect = region.rect
|
|
559
|
+
state.setRegion({ ...region, settled: true, flashKey: region.flashKey + 1 })
|
|
560
|
+
if (state.playful) triggerSFX('sfx:snapshot-capture')
|
|
561
|
+
const crop = captureViewCrop(rect, { pad: 0 }).then(
|
|
562
|
+
(value) => value,
|
|
563
|
+
(error) => {
|
|
564
|
+
console.debug(
|
|
565
|
+
'[point-ask] no picture of the region:',
|
|
566
|
+
error instanceof Error ? error.message : error,
|
|
567
|
+
)
|
|
568
|
+
return null
|
|
569
|
+
},
|
|
570
|
+
)
|
|
571
|
+
const { ids, more } = elementsInRegion(rect)
|
|
572
|
+
const release = { x: event.clientX, y: event.clientY }
|
|
573
|
+
const click = nearestEdgePoint(rect, release)
|
|
574
|
+
// The pin plants on the ground under where the finger let go (the level's floor when the ray misses).
|
|
575
|
+
const planeY = levelFloorY()
|
|
576
|
+
const ray = cameraRay(release.x, release.y)
|
|
577
|
+
const t = ray && ray.direction[1] < -1e-6 ? (planeY - ray.origin[1]) / ray.direction[1] : null
|
|
578
|
+
const point: [number, number, number] =
|
|
579
|
+
ray && t !== null && t > 0
|
|
580
|
+
? [ray.origin[0] + ray.direction[0] * t, planeY, ray.origin[2] + ray.direction[2] * t]
|
|
581
|
+
: (cameraPoseStore.getState().pose?.target ?? [0, planeY, 0])
|
|
582
|
+
openBubble(
|
|
583
|
+
[],
|
|
584
|
+
{ point, normal: [0, 1, 0] },
|
|
585
|
+
'region',
|
|
586
|
+
{ ...press, crop },
|
|
587
|
+
{ rect, click, ids, more },
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function onPointerMove(event: PointerEvent) {
|
|
592
|
+
const point = usePointAsk.getState()
|
|
593
|
+
// Over a pin, its card or the bubble the pointer is no longer on the scene: nothing is named.
|
|
594
|
+
if (
|
|
595
|
+
point.active &&
|
|
596
|
+
!down &&
|
|
597
|
+
point.hover &&
|
|
598
|
+
event.target !== getEditorThreeContext()?.domElement
|
|
599
|
+
) {
|
|
600
|
+
point.setHover(null)
|
|
601
|
+
useViewer.getState().setHoveredId(null)
|
|
602
|
+
}
|
|
603
|
+
if (!down) return
|
|
604
|
+
if (!down.moved && Math.hypot(event.clientX - down.x, event.clientY - down.y) > CLICK_SLOP_PX) {
|
|
605
|
+
down.moved = true
|
|
606
|
+
// A drag begun while composing re-points: the open bubble goes, its words are kept.
|
|
607
|
+
if (usePointAsk.getState().bubble) closeBubble('dismiss')
|
|
608
|
+
}
|
|
609
|
+
if (down.moved) updateRegionDrag(down, event)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function onPointerUp(event: PointerEvent) {
|
|
613
|
+
const press = down
|
|
614
|
+
down = null
|
|
615
|
+
if (!press || event.button !== 0) return
|
|
616
|
+
const state = usePointAsk.getState()
|
|
617
|
+
if (!state.active) return
|
|
618
|
+
event.preventDefault()
|
|
619
|
+
event.stopImmediatePropagation()
|
|
620
|
+
if (press.moved) {
|
|
621
|
+
finishRegion(press, event)
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
handleClick(press, event.shiftKey || press.shift)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** A native click that follows a press this mode took must not reach the scene. */
|
|
628
|
+
function swallowCanvasClick(event: MouseEvent) {
|
|
629
|
+
if (!usePointAsk.getState().active) return
|
|
630
|
+
const canvas = getEditorThreeContext()?.domElement
|
|
631
|
+
if (canvas && event.target === canvas) {
|
|
632
|
+
event.preventDefault()
|
|
633
|
+
event.stopImmediatePropagation()
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function handleClick(press: Down, shift: boolean) {
|
|
638
|
+
const state = usePointAsk.getState()
|
|
639
|
+
const hover = press.hover
|
|
640
|
+
if (!hover) {
|
|
641
|
+
if (state.bubble) closeBubble('dismiss')
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
const bubble = state.bubble
|
|
645
|
+
if (bubble && shift) {
|
|
646
|
+
const has = bubble.targets.some((t) => t.id === hover.target.id)
|
|
647
|
+
const targets = has
|
|
648
|
+
? bubble.targets.filter((t) => t.id !== hover.target.id)
|
|
649
|
+
: bubble.targets.length < MAX_TARGETS
|
|
650
|
+
? [...bubble.targets, hover.target]
|
|
651
|
+
: bubble.targets
|
|
652
|
+
if (targets.length === 0) return closeBubble('dismiss')
|
|
653
|
+
state.patchBubble({ targets, gesture: targets.length > 1 ? 'multi' : 'click' })
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
// A plain click on another element while composing moves the bubble there and keeps the words.
|
|
657
|
+
openBubble([hover.target], hover.hit, 'click', press)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ─── hover (the node events the viewer already raises) ──────────────────────
|
|
661
|
+
|
|
662
|
+
function onAnyEvent(type: string | symbol, event: unknown) {
|
|
663
|
+
if (typeof type !== 'string') return
|
|
664
|
+
const state = usePointAsk.getState()
|
|
665
|
+
if (!state.active) return
|
|
666
|
+
// Each pointer event is emitted per kind (`wall:move`) and again generically (`node:move`), and
|
|
667
|
+
// then climbs to the level, the building and the site: only the innermost kind's own event is read.
|
|
668
|
+
if (type.startsWith('node:')) return
|
|
669
|
+
const suffix = type.slice(type.lastIndexOf(':') + 1)
|
|
670
|
+
if (suffix !== 'enter' && suffix !== 'move' && suffix !== 'leave') return
|
|
671
|
+
const nodeEvent = event as NodeEvent | undefined
|
|
672
|
+
if (!nodeEvent?.node) return
|
|
673
|
+
if (
|
|
674
|
+
nodeEvent.node.type === 'level' ||
|
|
675
|
+
nodeEvent.node.type === 'building' ||
|
|
676
|
+
nodeEvent.node.type === 'site'
|
|
677
|
+
)
|
|
678
|
+
return
|
|
679
|
+
const viewer = useViewer.getState()
|
|
680
|
+
if (viewer.cameraDragging || viewer.inputDragging || down?.moved) return
|
|
681
|
+
if (suffix === 'leave') {
|
|
682
|
+
const current = state.hover
|
|
683
|
+
if (current) {
|
|
684
|
+
// The next enter in the same pointer move replaces it; a leave with no enter clears.
|
|
685
|
+
queueMicrotask(() => {
|
|
686
|
+
const now = usePointAsk.getState()
|
|
687
|
+
if (now.hover === current) {
|
|
688
|
+
now.setHover(null)
|
|
689
|
+
useViewer.getState().setHoveredId(null)
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
lastEvent = nodeEvent
|
|
696
|
+
// What is under the pointer is read here and goes no higher.
|
|
697
|
+
nodeEvent.stopPropagation()
|
|
698
|
+
const hover = hoverFromEvent(nodeEvent)
|
|
699
|
+
state.setHover(hover)
|
|
700
|
+
useViewer.getState().setHoveredId((hover?.target.id as AnyNodeId | undefined) ?? null)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let lastEvent: NodeEvent | null = null
|
|
704
|
+
|
|
705
|
+
function replayHover() {
|
|
706
|
+
if (!lastEvent) return
|
|
707
|
+
const hover = hoverFromEvent(lastEvent)
|
|
708
|
+
const state = usePointAsk.getState()
|
|
709
|
+
state.setHover(hover)
|
|
710
|
+
useViewer.getState().setHoveredId((hover?.target.id as AnyNodeId | undefined) ?? null)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ─── keys ───────────────────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
const typing = (target: EventTarget | null) =>
|
|
716
|
+
target instanceof HTMLElement &&
|
|
717
|
+
(target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))
|
|
718
|
+
|
|
719
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
720
|
+
const state = usePointAsk.getState()
|
|
721
|
+
if (event.key === 'Alt' || event.key === 'Shift') {
|
|
722
|
+
state.setModifiers(event.altKey, event.shiftKey)
|
|
723
|
+
if (state.active) replayHover()
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
if (event.key === 'Escape') {
|
|
727
|
+
if (state.cardFor) {
|
|
728
|
+
state.openCard(null)
|
|
729
|
+
event.stopImmediatePropagation()
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
if (state.bubble) {
|
|
733
|
+
closeBubble('dismiss')
|
|
734
|
+
event.stopImmediatePropagation()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (state.active) {
|
|
738
|
+
leavePointMode()
|
|
739
|
+
event.stopImmediatePropagation()
|
|
740
|
+
}
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && state.active && !state.bubble) {
|
|
744
|
+
if (state.hover) {
|
|
745
|
+
event.preventDefault()
|
|
746
|
+
event.stopImmediatePropagation()
|
|
747
|
+
openBubble([state.hover.target], state.hover.hit, 'click', null)
|
|
748
|
+
}
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
if (event.key !== 'c' && event.key !== 'C') return
|
|
752
|
+
// A held C never types: its repeats are swallowed until the key goes up.
|
|
753
|
+
if (cDown !== null) {
|
|
754
|
+
event.preventDefault()
|
|
755
|
+
event.stopImmediatePropagation()
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
if (event.metaKey || event.ctrlKey || event.altKey || typing(event.target)) return
|
|
759
|
+
if (event.repeat) return
|
|
760
|
+
if (state.active && state.latched) {
|
|
761
|
+
event.preventDefault()
|
|
762
|
+
event.stopImmediatePropagation()
|
|
763
|
+
if (state.bubble) closeBubble('dismiss')
|
|
764
|
+
leavePointMode()
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
if (state.active) return
|
|
768
|
+
if (!cIsOurs()) return
|
|
769
|
+
if (enterPointMode(false)) {
|
|
770
|
+
cDown = event.timeStamp
|
|
771
|
+
event.preventDefault()
|
|
772
|
+
event.stopImmediatePropagation()
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function onKeyUp(event: KeyboardEvent) {
|
|
777
|
+
const state = usePointAsk.getState()
|
|
778
|
+
if (event.key === 'Alt' || event.key === 'Shift') {
|
|
779
|
+
state.setModifiers(event.altKey, event.shiftKey)
|
|
780
|
+
if (state.active) replayHover()
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
if ((event.key === 'c' || event.key === 'C') && cDown !== null) {
|
|
784
|
+
// Both times are the input's own: a slow render between the two must not turn a tap into a hold.
|
|
785
|
+
const held = event.timeStamp - cDown
|
|
786
|
+
cDown = null
|
|
787
|
+
if (held < TAP_LATCH_MS) state.setLatched(true)
|
|
788
|
+
else if (state.bubble) leaveOnClose = true
|
|
789
|
+
else leavePointMode()
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function onBlur() {
|
|
794
|
+
usePointAsk.getState().setModifiers(false, false)
|
|
795
|
+
if (cDown !== null) {
|
|
796
|
+
cDown = null
|
|
797
|
+
if (usePointAsk.getState().bubble) leaveOnClose = true
|
|
798
|
+
else leavePointMode()
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ─── send ───────────────────────────────────────────────────────────────────
|
|
803
|
+
|
|
804
|
+
let askCounter = 0
|
|
805
|
+
const newAskId = () =>
|
|
806
|
+
`ask_${Date.now().toString(36)}${(askCounter++).toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
807
|
+
|
|
808
|
+
/** The scene history now, marked so what an ask's turn wrote can be counted when its Undo comes. */
|
|
809
|
+
const markNow = () => markHistory(useScene.temporal.getState().pastStates)
|
|
810
|
+
const stepsFrom = (mark: HistoryMark) => stepsSince(useScene.temporal.getState().pastStates, mark)
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The chat has taken the ask: the scene as it stands now is the "before" of its turn. Taken here and
|
|
814
|
+
* not at the chat's first "working", which comes from the stream and can follow the turn's first
|
|
815
|
+
* write. An ask the chat queued behind another turn is marked when its own turn starts.
|
|
816
|
+
*/
|
|
817
|
+
function markAccepted(askId: string) {
|
|
818
|
+
const marks = askMarks.get(askId)
|
|
819
|
+
const pin = usePointAsk.getState().pins[askId]
|
|
820
|
+
if (marks && marks.start === null && pin && pin.model.state !== 'queued') marks.start = markNow()
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const askMarks = new Map<
|
|
824
|
+
string,
|
|
825
|
+
{ start: HistoryMark | null; end: HistoryMark | null; undoneSteps: number; undone: boolean }
|
|
826
|
+
>()
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Sends the bubble's ask: the pin plants on the frame of Enter and the bubble folds into it; the
|
|
830
|
+
* handoff to the chat follows (after the crop, up to 1.5 s), and a refusal brings the bubble back
|
|
831
|
+
* with its words and the reason.
|
|
832
|
+
*/
|
|
833
|
+
export async function sendBubble(
|
|
834
|
+
text: string,
|
|
835
|
+
from: { x: number; y: number; width: number; height: number },
|
|
836
|
+
) {
|
|
837
|
+
const state = usePointAsk.getState()
|
|
838
|
+
const bubble = state.bubble
|
|
839
|
+
const words = text.trim()
|
|
840
|
+
if (!(bubble && words)) return
|
|
841
|
+
const askId = newAskId()
|
|
842
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
843
|
+
const first = bubble.targets[0]
|
|
844
|
+
const area = bubble.area
|
|
845
|
+
const object = first ? sceneRegistry.nodes.get(first.id) : undefined
|
|
846
|
+
let local: PinRecord['local'] = null
|
|
847
|
+
if (object && first) {
|
|
848
|
+
object.updateWorldMatrix(true, false)
|
|
849
|
+
const p = object.worldToLocal(new Vector3(...bubble.anchor))
|
|
850
|
+
local = { nodeId: first.id, offset: [p.x, p.y, p.z] }
|
|
851
|
+
}
|
|
852
|
+
// A region has no targets: what shows inside it is what the pin watches for changes.
|
|
853
|
+
const model = createPin({
|
|
854
|
+
askId,
|
|
855
|
+
text: words,
|
|
856
|
+
targetIds: area ? area.ids : bubble.targets.map((t) => t.id),
|
|
857
|
+
})
|
|
858
|
+
const label = area
|
|
859
|
+
? regionLabel(area.ids.length + area.more)
|
|
860
|
+
: bubble.targets.length > 1
|
|
861
|
+
? `${first?.name} and ${bubble.targets.length - 1} more`
|
|
862
|
+
: (first?.name ?? 'Selection')
|
|
863
|
+
state.addPin({
|
|
864
|
+
model,
|
|
865
|
+
anchor: bubble.anchor,
|
|
866
|
+
local,
|
|
867
|
+
label,
|
|
868
|
+
levelId: useViewer.getState().selection.levelId ?? null,
|
|
869
|
+
retireAt: null,
|
|
870
|
+
createdAt: Date.now(),
|
|
871
|
+
...(bubble.crop.state === 'ready' ? { before: bubble.crop } : {}),
|
|
872
|
+
})
|
|
873
|
+
askMarks.set(askId, { start: null, end: null, undoneSteps: 0, undone: false })
|
|
874
|
+
const restore = bubble
|
|
875
|
+
// "Change more": the earlier pin gives way to this ask's, which now stands where it stood.
|
|
876
|
+
const changes = bubble.changesAsk?.askId
|
|
877
|
+
if (changes) state.patchPin(changes, { supersededBy: askId })
|
|
878
|
+
closeBubble('send')
|
|
879
|
+
state.dispatchPin(askId, { type: 'sent' })
|
|
880
|
+
|
|
881
|
+
// The crop is usually ready by now (it started at pointer-down); wait for it only briefly.
|
|
882
|
+
let crop = bubble.crop
|
|
883
|
+
if (crop.state === 'pending') {
|
|
884
|
+
const pending = downCrops.get(bubble.capturedAt)
|
|
885
|
+
const waited = pending
|
|
886
|
+
? await Promise.race([
|
|
887
|
+
pending,
|
|
888
|
+
new Promise<null>((resolve) => setTimeout(() => resolve(null), CROP_WAIT_MS)),
|
|
889
|
+
])
|
|
890
|
+
: null
|
|
891
|
+
crop = waited
|
|
892
|
+
? {
|
|
893
|
+
state: 'ready',
|
|
894
|
+
dataUrl: waited.dataUrl,
|
|
895
|
+
width: waited.width,
|
|
896
|
+
height: waited.height,
|
|
897
|
+
marked: false,
|
|
898
|
+
}
|
|
899
|
+
: { state: 'failed' }
|
|
900
|
+
}
|
|
901
|
+
const firstNode = first ? nodes[first.id] : undefined
|
|
902
|
+
const levelId =
|
|
903
|
+
useViewer.getState().selection.levelId ??
|
|
904
|
+
(firstNode ? (resolveLevelId(firstNode, nodes) ?? '') : '')
|
|
905
|
+
const context = buildPointContext({
|
|
906
|
+
askId,
|
|
907
|
+
capturedAt: bubble.capturedAt,
|
|
908
|
+
gesture: bubble.gesture,
|
|
909
|
+
nodes: nodes as Record<string, AnyNode>,
|
|
910
|
+
levelId,
|
|
911
|
+
targets: bubble.targets.map((t, i) => ({ id: t.id, ...(i === 0 ? { hit: bubble.hit } : {}) })),
|
|
912
|
+
...(area ? { inRegion: { ids: area.ids, more: area.more } } : {}),
|
|
913
|
+
camera: bubble.camera ??
|
|
914
|
+
currentCamera() ?? {
|
|
915
|
+
position: [0, 5, 5],
|
|
916
|
+
target: [0, 0, 0],
|
|
917
|
+
fov: 50,
|
|
918
|
+
aspect: 1.6,
|
|
919
|
+
projection: 'perspective',
|
|
920
|
+
},
|
|
921
|
+
...(bubble.region ? { region: bubble.region } : {}),
|
|
922
|
+
...(crop.state === 'ready' && crop.dataUrl
|
|
923
|
+
? {
|
|
924
|
+
image: {
|
|
925
|
+
dataUrl: crop.dataUrl,
|
|
926
|
+
width: crop.width ?? 0,
|
|
927
|
+
height: crop.height ?? 0,
|
|
928
|
+
marked: false,
|
|
929
|
+
},
|
|
930
|
+
}
|
|
931
|
+
: {}),
|
|
932
|
+
})
|
|
933
|
+
const submit = { askId, text: words, context, from }
|
|
934
|
+
usePointAsk.getState().patchPin(askId, { submit })
|
|
935
|
+
const result = await submitPointAsk(submit)
|
|
936
|
+
if (result.ok) {
|
|
937
|
+
markAccepted(askId)
|
|
938
|
+
if (changes) {
|
|
939
|
+
usePointAsk.getState().dispatchPin(changes, { type: 'keep' })
|
|
940
|
+
usePointAsk.getState().removePin(changes)
|
|
941
|
+
askMarks.delete(changes)
|
|
942
|
+
}
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
// The chat did not take it: the pin goes, and the bubble returns with the reason and the words.
|
|
946
|
+
usePointAsk.getState().removePin(askId)
|
|
947
|
+
if (changes) usePointAsk.getState().patchPin(changes, { supersededBy: undefined })
|
|
948
|
+
askMarks.delete(askId)
|
|
949
|
+
usePointAsk.getState().openBubble({ ...restore, draft: words, error: result.message })
|
|
950
|
+
if (restore.area)
|
|
951
|
+
usePointAsk
|
|
952
|
+
.getState()
|
|
953
|
+
.setRegion({ rect: restore.area.rect, sizeLabel: null, settled: true, flashKey: 0 })
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** The camera flies to what an ask is about (the edge marker's click): the targets' union box, as view_scene frames it. */
|
|
957
|
+
export function lookAtAsk(askId: string) {
|
|
958
|
+
const pin = usePointAsk.getState().pins[askId]
|
|
959
|
+
if (!pin) return
|
|
960
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
961
|
+
const boxes = pin.model.targetIds.flatMap((id) => {
|
|
962
|
+
const box = worldBoxOf(nodes, id)
|
|
963
|
+
return box ? [box] : []
|
|
964
|
+
})
|
|
965
|
+
if (boxes.length === 0) return
|
|
966
|
+
const box = {
|
|
967
|
+
min: [0, 1, 2].map((i) => Math.min(...boxes.map((b) => b.min[i] as number))) as [
|
|
968
|
+
number,
|
|
969
|
+
number,
|
|
970
|
+
number,
|
|
971
|
+
],
|
|
972
|
+
max: [0, 1, 2].map((i) => Math.max(...boxes.map((b) => b.max[i] as number))) as [
|
|
973
|
+
number,
|
|
974
|
+
number,
|
|
975
|
+
number,
|
|
976
|
+
],
|
|
977
|
+
}
|
|
978
|
+
// The person asked to look: Follow Pascal must not take the camera back while it flies there.
|
|
979
|
+
useFollowPascal.getState().pause()
|
|
980
|
+
emitter.emit('camera-controls:apply-pose', sceneViewPose(box, {}))
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** The picture of what the turn made, from the same view when the camera has not moved: the card's "after". */
|
|
984
|
+
async function captureAfter(askId: string) {
|
|
985
|
+
const pin = usePointAsk.getState().pins[askId]
|
|
986
|
+
if (!pin) return
|
|
987
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
988
|
+
const boxes = pin.model.targetIds.flatMap((id) => {
|
|
989
|
+
const box = worldBoxOf(nodes, id)
|
|
990
|
+
const rect = box ? projectBox(box) : null
|
|
991
|
+
return rect ? [rect] : []
|
|
992
|
+
})
|
|
993
|
+
const rect = unionRect(boxes)
|
|
994
|
+
if (!rect) return
|
|
995
|
+
try {
|
|
996
|
+
const crop = await captureViewCrop(rect)
|
|
997
|
+
usePointAsk.getState().patchPin(askId, {
|
|
998
|
+
after: {
|
|
999
|
+
state: 'ready',
|
|
1000
|
+
dataUrl: crop.dataUrl,
|
|
1001
|
+
width: crop.width,
|
|
1002
|
+
height: crop.height,
|
|
1003
|
+
marked: false,
|
|
1004
|
+
},
|
|
1005
|
+
})
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
console.debug(
|
|
1008
|
+
'[point-ask] no picture of the result:',
|
|
1009
|
+
error instanceof Error ? error.message : error,
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Try again: the same words and context go as a new ask, planted where the old pin was. After an
|
|
1016
|
+
* error the new pin replaces the old; from the chat a finished ask keeps its pin. False when the pin
|
|
1017
|
+
* is gone (a reload, an ask of a collaborator's): the caller sends the words again without one.
|
|
1018
|
+
*/
|
|
1019
|
+
export async function retryAsk(askId: string): Promise<boolean> {
|
|
1020
|
+
const state = usePointAsk.getState()
|
|
1021
|
+
const old = state.pins[askId]
|
|
1022
|
+
const next = newAskId()
|
|
1023
|
+
const plan = old ? planRetry(old, next, Date.now()) : null
|
|
1024
|
+
if (!plan) return false
|
|
1025
|
+
state.addPin(plan.pin)
|
|
1026
|
+
if (!plan.keepOld) state.removePin(askId)
|
|
1027
|
+
state.openCard(null)
|
|
1028
|
+
askMarks.set(next, { start: null, end: null, undoneSteps: 0, undone: false })
|
|
1029
|
+
state.dispatchPin(next, { type: 'sent' })
|
|
1030
|
+
const result = await submitPointAsk(plan.submit)
|
|
1031
|
+
if (result.ok) {
|
|
1032
|
+
markAccepted(next)
|
|
1033
|
+
return true
|
|
1034
|
+
}
|
|
1035
|
+
usePointAsk.getState().dispatchPin(next, {
|
|
1036
|
+
type: 'status',
|
|
1037
|
+
status: {
|
|
1038
|
+
askId: next,
|
|
1039
|
+
status: 'ended',
|
|
1040
|
+
outcome: 'errored',
|
|
1041
|
+
asked: false,
|
|
1042
|
+
wroteScene: false,
|
|
1043
|
+
error: result.message,
|
|
1044
|
+
},
|
|
1045
|
+
})
|
|
1046
|
+
return true
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ─── the card: change more, and the scene as it was ─────────────────────────
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* "Change more": the done pin's bubble opens again on the same elements (a fresh view and picture
|
|
1053
|
+
* of them, the field focused, chips that move on) and sends as its own ask. The pin gives way while
|
|
1054
|
+
* the bubble is open, and goes when the new ask is taken.
|
|
1055
|
+
*/
|
|
1056
|
+
export function changeMore(askId: string): boolean {
|
|
1057
|
+
const state = usePointAsk.getState()
|
|
1058
|
+
const pin = state.pins[askId]
|
|
1059
|
+
if (!pin) return false
|
|
1060
|
+
const targets = pin.model.targetIds
|
|
1061
|
+
.map((id) => targetFor(id))
|
|
1062
|
+
.filter((target): target is BubbleTarget => target !== null)
|
|
1063
|
+
.slice(0, MAX_TARGETS)
|
|
1064
|
+
if (targets.length === 0) return false
|
|
1065
|
+
if (!state.active && !enterPointMode(true)) return false
|
|
1066
|
+
endCompare()
|
|
1067
|
+
if (usePointAsk.getState().bubble) closeBubble('dismiss')
|
|
1068
|
+
state.openCard(null)
|
|
1069
|
+
const rect = unionRect(
|
|
1070
|
+
targets.flatMap((target) => {
|
|
1071
|
+
const projected = target.box ? projectBox(target.box) : null
|
|
1072
|
+
return projected ? [projected] : []
|
|
1073
|
+
}),
|
|
1074
|
+
)
|
|
1075
|
+
const crop = rect
|
|
1076
|
+
? captureViewCrop(rect).then(
|
|
1077
|
+
(value) => value,
|
|
1078
|
+
() => null,
|
|
1079
|
+
)
|
|
1080
|
+
: Promise.resolve(null)
|
|
1081
|
+
openBubble(
|
|
1082
|
+
targets,
|
|
1083
|
+
{ point: pin.anchor, normal: [0, 1, 0] },
|
|
1084
|
+
targets.length > 1 ? 'multi' : 'click',
|
|
1085
|
+
{ crop, capturedAt: new Date().toISOString(), camera: currentCamera() },
|
|
1086
|
+
undefined,
|
|
1087
|
+
{ askId, after: pin.model.text },
|
|
1088
|
+
)
|
|
1089
|
+
return true
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** The scene as it was before the ask's turn: the state Undo would restore, null once it cannot. */
|
|
1093
|
+
function beforeAsk(askId: string): SceneSnapshot | null {
|
|
1094
|
+
const marks = askMarks.get(askId)
|
|
1095
|
+
if (!(marks?.start && usePointAsk.getState().pins[askId]) || marks.undone) return null
|
|
1096
|
+
const past = useScene.temporal.getState().pastStates as unknown as SceneSnapshot[]
|
|
1097
|
+
return stateAfterMark(past, marks.start)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Only a turn that is writing swallows a preview. One that has been sent and not started cannot
|
|
1101
|
+
// (its first write lets go of the preview itself), and one that is waiting on the person is not
|
|
1102
|
+
// writing: a stuck "sent" pin must not shut every hold for good.
|
|
1103
|
+
const TURN_WRITING = new Set(['working'])
|
|
1104
|
+
const COMPARE_REFUSAL_MS = 3500
|
|
1105
|
+
|
|
1106
|
+
const compare = createCompare({
|
|
1107
|
+
before: beforeAsk,
|
|
1108
|
+
// A pin that says "working" while the chat is idle never heard its turn end: it is not writing.
|
|
1109
|
+
turnsWorking: () => {
|
|
1110
|
+
const state = usePointAsk.getState()
|
|
1111
|
+
return state.busy && Object.values(state.pins).some((pin) => TURN_WRITING.has(pin.model.state))
|
|
1112
|
+
},
|
|
1113
|
+
begin: beginScenePreview,
|
|
1114
|
+
onChange: (askId) => usePointAsk.getState().setComparing(askId),
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
/** Shows the scene as it was before that ask's turn, until `endCompare`. False when it may not. */
|
|
1118
|
+
export function startCompare(askId: string) {
|
|
1119
|
+
const started = compare.start(askId)
|
|
1120
|
+
const reason = compare.refusal()
|
|
1121
|
+
const state = usePointAsk.getState()
|
|
1122
|
+
if (started || !reason) {
|
|
1123
|
+
state.setCompareRefusal(null)
|
|
1124
|
+
return started
|
|
1125
|
+
}
|
|
1126
|
+
state.setCompareRefusal({ askId, reason })
|
|
1127
|
+
setTimeout(() => {
|
|
1128
|
+
const refusal = usePointAsk.getState().compareRefusal
|
|
1129
|
+
if (refusal?.askId === askId && refusal.reason === reason) {
|
|
1130
|
+
usePointAsk.getState().setCompareRefusal(null)
|
|
1131
|
+
}
|
|
1132
|
+
}, COMPARE_REFUSAL_MS)
|
|
1133
|
+
return false
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Lets go: the live scene is back. Idempotent, so every way out can call it. */
|
|
1137
|
+
export const endCompare = () => compare.end()
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* A hold that keeps its release by pointer capture (the pin) cannot see the pointer leave: it ends
|
|
1141
|
+
* when the pointer has drifted `radius` px from where it pressed.
|
|
1142
|
+
*/
|
|
1143
|
+
export function endCompareWhenPointerDrifts(origin: { x: number; y: number }, radius: number) {
|
|
1144
|
+
const watch = (event: PointerEvent) => {
|
|
1145
|
+
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > radius) endCompare()
|
|
1146
|
+
}
|
|
1147
|
+
const stop = () => {
|
|
1148
|
+
window.removeEventListener('pointermove', watch, true)
|
|
1149
|
+
off()
|
|
1150
|
+
}
|
|
1151
|
+
window.addEventListener('pointermove', watch, true)
|
|
1152
|
+
const off = usePointAsk.subscribe((state) => {
|
|
1153
|
+
if (state.comparing === null) stop()
|
|
1154
|
+
})
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ─── the chat's word on a turn, and the scene's commits ─────────────────────
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* The ask whose turn is running: the last one the chat reported "working", until it ends. Only its
|
|
1161
|
+
* pin takes the scene's commits. A pin that says "working" and never heard its end is an older turn
|
|
1162
|
+
* that is not running, and must not absorb this one's changes.
|
|
1163
|
+
*/
|
|
1164
|
+
let runningAskId: string | null = null
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Pins that said "working" or "asking" for a turn the chat has moved past end, unfinished. `running`
|
|
1168
|
+
* is the ask that is working now; none when the chat is idle.
|
|
1169
|
+
*/
|
|
1170
|
+
function settleStalePins(running: string | null) {
|
|
1171
|
+
const state = usePointAsk.getState()
|
|
1172
|
+
for (const askId of state.pinOrder) {
|
|
1173
|
+
if (askId === running) continue
|
|
1174
|
+
const model = state.pins[askId]?.model
|
|
1175
|
+
if (model && (model.state === 'working' || model.state === 'needs-input')) {
|
|
1176
|
+
state.dispatchPin(askId, {
|
|
1177
|
+
type: 'status',
|
|
1178
|
+
status: {
|
|
1179
|
+
askId,
|
|
1180
|
+
status: 'ended',
|
|
1181
|
+
outcome: 'cancelled',
|
|
1182
|
+
asked: false,
|
|
1183
|
+
wroteScene: false,
|
|
1184
|
+
},
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* The chat has no turn running: an ask that still says "working" never heard its end, and the next
|
|
1192
|
+
* turn (a plain message, say) is not its. Called by the host's busy flag going false.
|
|
1193
|
+
*/
|
|
1194
|
+
export function chatWentIdle() {
|
|
1195
|
+
runningAskId = null
|
|
1196
|
+
settleStalePins(null)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function onStatus(status: PointAskStatus) {
|
|
1200
|
+
// A turn starting to write while the before is held would be swallowed by it.
|
|
1201
|
+
if (status.status === 'working' || status.status === 'queued') endCompare()
|
|
1202
|
+
const state = usePointAsk.getState()
|
|
1203
|
+
const pin = state.pins[status.askId]
|
|
1204
|
+
if (!pin) return
|
|
1205
|
+
if (status.status === 'working') {
|
|
1206
|
+
runningAskId = status.askId
|
|
1207
|
+
settleStalePins(status.askId)
|
|
1208
|
+
}
|
|
1209
|
+
const marks = askMarks.get(status.askId)
|
|
1210
|
+
if (status.status === 'queued' && marks) marks.start = null
|
|
1211
|
+
if (status.status === 'working' && marks && marks.start === null) marks.start = markNow()
|
|
1212
|
+
state.dispatchPin(status.askId, { type: 'status', status })
|
|
1213
|
+
if (status.status === 'ended') {
|
|
1214
|
+
if (runningAskId === status.askId) runningAskId = null
|
|
1215
|
+
if (marks) marks.end = markNow()
|
|
1216
|
+
state.patchPin(status.askId, { retireAt: null })
|
|
1217
|
+
const settled = usePointAsk.getState().pins[status.askId]
|
|
1218
|
+
if (settled?.model.state === 'done') void captureAfter(status.askId)
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function onCommit(commit: SceneCommit) {
|
|
1223
|
+
if (!runningAskId) return
|
|
1224
|
+
const state = usePointAsk.getState()
|
|
1225
|
+
const pin = state.pins[runningAskId]
|
|
1226
|
+
if (pin?.model.state !== 'working') return
|
|
1227
|
+
const event = pointCommitEvent(commit, pin.model.targetIds)
|
|
1228
|
+
if (event) state.dispatchPin(runningAskId, event)
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** What an undo of the turn will take out of the scene: the nodes the turn created, not those it only changed. */
|
|
1232
|
+
function createdByTurn(start: HistoryMark): string[] {
|
|
1233
|
+
const before = stateAfterMark(useScene.temporal.getState().pastStates, start)
|
|
1234
|
+
if (!before) return []
|
|
1235
|
+
return createdSince(
|
|
1236
|
+
before.nodes as Record<string, unknown>,
|
|
1237
|
+
useScene.getState().nodes as Record<string, unknown>,
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Undo for an ask from its pin: the history steps its turn wrote (the chat has no turn undo
|
|
1243
|
+
* tonight). What the turn built plays backwards first (the construction in reverse, so taking
|
|
1244
|
+
* something back is as satisfying as making it) and the steps are undone the moment it has gone.
|
|
1245
|
+
* `needs-confirm` when the scene changed after the turn ended, which Undo would remove too;
|
|
1246
|
+
* `unavailable` when the history no longer holds the whole turn.
|
|
1247
|
+
*/
|
|
1248
|
+
export async function undoAsk(
|
|
1249
|
+
askId: string,
|
|
1250
|
+
options: { force?: boolean } = {},
|
|
1251
|
+
): Promise<'undone' | 'needs-confirm' | 'unavailable'> {
|
|
1252
|
+
const marks = askMarks.get(askId)
|
|
1253
|
+
if (!marks?.start) return 'unavailable'
|
|
1254
|
+
const steps = stepsFrom(marks.start)
|
|
1255
|
+
if (steps === null || steps <= 0) return 'unavailable'
|
|
1256
|
+
if (!options.force && handEditsSince(askId) > 0) return 'needs-confirm'
|
|
1257
|
+
usePointAsk.getState().dispatchPin(askId, { type: 'undo' })
|
|
1258
|
+
// Only what the undo removes is retracted: a node it merely changes would go and come back whole.
|
|
1259
|
+
const created = createdByTurn(marks.start)
|
|
1260
|
+
if (created.length > 0) {
|
|
1261
|
+
// Walls the level has batched are drawn by a merged mesh a pose cannot move: a dirty mark hands
|
|
1262
|
+
// each back to its own mesh, as an edit does, or the walls would stand whole until they vanish.
|
|
1263
|
+
for (const id of created) useScene.getState().markDirty(id as AnyNodeId)
|
|
1264
|
+
await retractNodes(created)
|
|
1265
|
+
}
|
|
1266
|
+
// The steps are counted again: the history can have moved while the build played backwards.
|
|
1267
|
+
const now = stepsFrom(marks.start) ?? steps
|
|
1268
|
+
useScene.temporal.getState().undo(now)
|
|
1269
|
+
marks.undone = true
|
|
1270
|
+
marks.undoneSteps = now
|
|
1271
|
+
usePointAsk.getState().dispatchPin(askId, { type: 'undone', ok: true })
|
|
1272
|
+
return 'undone'
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** Redo for an ask the person just undid, while nothing else has been done since. */
|
|
1276
|
+
export function redoAsk(askId: string): boolean {
|
|
1277
|
+
const marks = askMarks.get(askId)
|
|
1278
|
+
if (!marks?.undone) return false
|
|
1279
|
+
const temporal = useScene.temporal.getState()
|
|
1280
|
+
// Anything done after the undo clears the future, so there is nothing of the ask to bring back.
|
|
1281
|
+
if (temporal.futureStates.length < marks.undoneSteps) return false
|
|
1282
|
+
temporal.redo(marks.undoneSteps)
|
|
1283
|
+
marks.undone = false
|
|
1284
|
+
usePointAsk.getState().dispatchPin(askId, { type: 'redo' })
|
|
1285
|
+
return true
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* How many steps the scene has taken since the ask's turn ended: the person's own edits, which an
|
|
1290
|
+
* Undo would remove too. A history that no longer holds the turn's end counts as one.
|
|
1291
|
+
*/
|
|
1292
|
+
export function handEditsSince(askId: string): number {
|
|
1293
|
+
const marks = askMarks.get(askId)
|
|
1294
|
+
if (!marks?.end) return 0
|
|
1295
|
+
return stepsFrom(marks.end) ?? 1
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// ─── lifecycle ──────────────────────────────────────────────────────────────
|
|
1299
|
+
|
|
1300
|
+
/** Starts listening for the keys, the pointer, the chat's statuses and the scene's commits. */
|
|
1301
|
+
export function startPointAskSession(): () => void {
|
|
1302
|
+
if (stopSession) return stopSession
|
|
1303
|
+
const offs: (() => void)[] = []
|
|
1304
|
+
const listen = <K extends keyof WindowEventMap>(
|
|
1305
|
+
type: K,
|
|
1306
|
+
handler: (event: WindowEventMap[K]) => void,
|
|
1307
|
+
options: AddEventListenerOptions | boolean = true,
|
|
1308
|
+
) => {
|
|
1309
|
+
window.addEventListener(type, handler as EventListener, options)
|
|
1310
|
+
offs.push(() => window.removeEventListener(type, handler as EventListener, options))
|
|
1311
|
+
}
|
|
1312
|
+
listen('keydown', onKeyDown)
|
|
1313
|
+
listen('keyup', onKeyUp)
|
|
1314
|
+
listen('blur', onBlur, false)
|
|
1315
|
+
// A hold on the "before" ends on any way out: a key (Cmd+S, Cmd+Z, Delete are not for a preview),
|
|
1316
|
+
// the window or tab losing focus, the page going away.
|
|
1317
|
+
listen('keydown', (event) => {
|
|
1318
|
+
if (!event.repeat) endCompare()
|
|
1319
|
+
})
|
|
1320
|
+
listen('pointerup', endCompare)
|
|
1321
|
+
listen('blur', endCompare, false)
|
|
1322
|
+
listen('pagehide', endCompare, false)
|
|
1323
|
+
listen('pointercancel', endCompare)
|
|
1324
|
+
const onHidden = () => {
|
|
1325
|
+
if (document.hidden) endCompare()
|
|
1326
|
+
}
|
|
1327
|
+
document.addEventListener('visibilitychange', onHidden)
|
|
1328
|
+
offs.push(() => document.removeEventListener('visibilitychange', onHidden))
|
|
1329
|
+
// The scene lets go of a preview itself when something is about to write: the tag follows.
|
|
1330
|
+
offs.push(
|
|
1331
|
+
subscribeScenePreview((previewing) => {
|
|
1332
|
+
if (!previewing) compare.released()
|
|
1333
|
+
}),
|
|
1334
|
+
)
|
|
1335
|
+
listen('pointerdown', onPointerDown)
|
|
1336
|
+
listen('pointermove', onPointerMove)
|
|
1337
|
+
listen('pointerup', onPointerUp)
|
|
1338
|
+
listen('click', swallowCanvasClick)
|
|
1339
|
+
listen('dblclick', swallowCanvasClick)
|
|
1340
|
+
emitter.on('*', onAnyEvent as never)
|
|
1341
|
+
offs.push(() => emitter.off('*', onAnyEvent as never))
|
|
1342
|
+
emitter.on('point-ask:status', onStatus as never)
|
|
1343
|
+
offs.push(() => emitter.off('point-ask:status', onStatus as never))
|
|
1344
|
+
offs.push(subscribeSceneCommits(onCommit))
|
|
1345
|
+
// The editor's selection manager resets the hover style whenever the tool mode changes, which
|
|
1346
|
+
// putting an armed tool aside does: while the mode is on the point style is kept.
|
|
1347
|
+
offs.push(
|
|
1348
|
+
useViewer.subscribe((state) => {
|
|
1349
|
+
if (usePointAsk.getState().active && state.hoverHighlightMode !== 'point')
|
|
1350
|
+
state.setHoverHighlightMode('point')
|
|
1351
|
+
}),
|
|
1352
|
+
)
|
|
1353
|
+
// Another interaction taking the pointer ends the mode; a level or view change drops stale hover.
|
|
1354
|
+
offs.push(
|
|
1355
|
+
useInteractionScope.subscribe((state) => {
|
|
1356
|
+
if (usePointAsk.getState().active && state.scope.kind !== 'pointing') leavePointMode()
|
|
1357
|
+
}),
|
|
1358
|
+
)
|
|
1359
|
+
offs.push(
|
|
1360
|
+
useEditor.subscribe((state, prev) => {
|
|
1361
|
+
if (!usePointAsk.getState().active) return
|
|
1362
|
+
if (!isViewVisible(state, VIEW_3D)) leavePointMode()
|
|
1363
|
+
if (state.isFirstPersonMode && !prev.isFirstPersonMode) leavePointMode()
|
|
1364
|
+
}),
|
|
1365
|
+
)
|
|
1366
|
+
offs.push(
|
|
1367
|
+
useScene.subscribe((state, prev) => {
|
|
1368
|
+
if (state.nodes === prev.nodes) return
|
|
1369
|
+
const point = usePointAsk.getState()
|
|
1370
|
+
if (point.hover && !state.nodes[point.hover.target.id as AnyNodeId]) point.setHover(null)
|
|
1371
|
+
targetCache.clear()
|
|
1372
|
+
}),
|
|
1373
|
+
)
|
|
1374
|
+
stopSession = () => {
|
|
1375
|
+
endCompare()
|
|
1376
|
+
for (const off of offs) off()
|
|
1377
|
+
leavePointMode()
|
|
1378
|
+
runningAskId = null
|
|
1379
|
+
stopSession = null
|
|
1380
|
+
}
|
|
1381
|
+
return stopSession
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Used by the overlay to know a node's level for hiding pins on hidden levels.
|
|
1385
|
+
export function levelOfNode(id: string): string | null {
|
|
1386
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
1387
|
+
const node = nodes[id]
|
|
1388
|
+
return node ? (resolveLevelId(node, nodes) ?? null) : null
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
export { unionRect }
|