@pascal-app/editor 1.0.0-beta.5 → 1.0.1
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +78 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +63 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +44 -21
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +103 -10
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +5 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +455 -0
- package/src/lib/portable-export.ts +1042 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +38 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
package/src/lib/units.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type BuildingNode,
|
|
5
|
+
type LevelNode,
|
|
6
|
+
pointInPolygon2D,
|
|
7
|
+
resolveAutoZonePolygon,
|
|
8
|
+
snapWorldXZToBuildingLocal,
|
|
9
|
+
UnitNode,
|
|
10
|
+
useScene,
|
|
11
|
+
type ZoneNode,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { useEffect } from 'react'
|
|
15
|
+
import useEditor, { type StructureLayer } from '../store/use-editor'
|
|
16
|
+
import { getActiveBuildingPose } from './world-grid-snap'
|
|
17
|
+
|
|
18
|
+
type Nodes = Readonly<Record<AnyNodeId, AnyNode>>
|
|
19
|
+
|
|
20
|
+
// Unit focus is a session gesture: the layer the user was on when focus began
|
|
21
|
+
// comes back when focus ends, unless they switched layers by hand meanwhile.
|
|
22
|
+
let restoreLayer: StructureLayer | null = null
|
|
23
|
+
let entering = false
|
|
24
|
+
let leaving = false
|
|
25
|
+
|
|
26
|
+
export function focusedUnitNode(): UnitNode | null {
|
|
27
|
+
const id = useViewer.getState().focusedUnitId
|
|
28
|
+
const node = id ? useScene.getState().nodes[id] : undefined
|
|
29
|
+
return node?.type === 'unit' ? node : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Levels carrying member zones, lowest ordinal first. */
|
|
33
|
+
export function unitMemberLevels(unit: UnitNode, nodes: Nodes): LevelNode[] {
|
|
34
|
+
const levels = new Map<LevelNode['id'], LevelNode>()
|
|
35
|
+
for (const zoneId of unit.members) {
|
|
36
|
+
const zone = nodes[zoneId]
|
|
37
|
+
const level =
|
|
38
|
+
zone?.type === 'zone' && zone.parentId ? nodes[zone.parentId as AnyNodeId] : undefined
|
|
39
|
+
if (level?.type === 'level') levels.set(level.id, level)
|
|
40
|
+
}
|
|
41
|
+
return [...levels.values()].sort((a, b) => a.level - b.level)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function enterUnitFocus(unitId: UnitNode['id']): void {
|
|
45
|
+
if (entering) return
|
|
46
|
+
entering = true
|
|
47
|
+
try {
|
|
48
|
+
const nodes = useScene.getState().nodes
|
|
49
|
+
const unit = nodes[unitId]
|
|
50
|
+
if (unit?.type !== 'unit') return
|
|
51
|
+
const editor = useEditor.getState()
|
|
52
|
+
if (restoreLayer === null) restoreLayer = editor.structureLayer
|
|
53
|
+
if (editor.phase !== 'structure') editor.setPhase('structure')
|
|
54
|
+
if (useEditor.getState().structureLayer !== 'zones') {
|
|
55
|
+
useEditor.getState().setStructureLayer('zones')
|
|
56
|
+
}
|
|
57
|
+
const viewer = useViewer.getState()
|
|
58
|
+
if (viewer.focusedUnitId !== unitId) viewer.setFocusedUnit(unitId)
|
|
59
|
+
|
|
60
|
+
const { selection, setSelection } = useViewer.getState()
|
|
61
|
+
const levels = unitMemberLevels(unit, nodes)
|
|
62
|
+
const levelId = levels.some((level) => level.id === selection.levelId)
|
|
63
|
+
? selection.levelId
|
|
64
|
+
: (levels[0]?.id ?? selection.levelId)
|
|
65
|
+
const buildingId = (unit.parentId as BuildingNode['id'] | null) ?? selection.buildingId
|
|
66
|
+
const unitSelected = selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId
|
|
67
|
+
if (selection.buildingId !== buildingId || selection.levelId !== levelId || !unitSelected) {
|
|
68
|
+
setSelection({ buildingId, levelId, selectedIds: [unitId] })
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
entering = false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
|
|
76
|
+
const layer = restoreLayer
|
|
77
|
+
restoreLayer = null
|
|
78
|
+
const editor = useEditor.getState()
|
|
79
|
+
if (
|
|
80
|
+
!keepLayer &&
|
|
81
|
+
layer &&
|
|
82
|
+
layer !== 'zones' &&
|
|
83
|
+
editor.phase === 'structure' &&
|
|
84
|
+
editor.structureLayer === 'zones'
|
|
85
|
+
) {
|
|
86
|
+
editor.setStructureLayer(layer)
|
|
87
|
+
}
|
|
88
|
+
const { selection, setSelection } = useViewer.getState()
|
|
89
|
+
if (selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId) {
|
|
90
|
+
setSelection({ selectedIds: [] })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Ends unit focus. Returns false when no unit was focused. */
|
|
95
|
+
export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
|
|
96
|
+
const viewer = useViewer.getState()
|
|
97
|
+
const unitId = viewer.focusedUnitId
|
|
98
|
+
if (!unitId) return false
|
|
99
|
+
leaving = true
|
|
100
|
+
try {
|
|
101
|
+
viewer.setFocusedUnit(null)
|
|
102
|
+
} finally {
|
|
103
|
+
leaving = false
|
|
104
|
+
}
|
|
105
|
+
finishLeave(unitId, options?.keepLayer ?? false)
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Puts a zone in exactly one unit (or none): it leaves every other unit in the
|
|
111
|
+
* same store update, so the move is one undo step.
|
|
112
|
+
*/
|
|
113
|
+
export function assignZoneToUnit(zoneId: ZoneNode['id'], unitId: UnitNode['id'] | null): void {
|
|
114
|
+
const { nodes, updateNodes } = useScene.getState()
|
|
115
|
+
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
|
116
|
+
for (const node of Object.values(nodes)) {
|
|
117
|
+
if (node.type !== 'unit' || node.id === unitId || !node.members.includes(zoneId)) continue
|
|
118
|
+
updates.push({ id: node.id, data: { members: node.members.filter((id) => id !== zoneId) } })
|
|
119
|
+
}
|
|
120
|
+
const target = unitId ? nodes[unitId] : undefined
|
|
121
|
+
if (target?.type === 'unit' && !target.members.includes(zoneId)) {
|
|
122
|
+
updates.push({ id: target.id, data: { members: [...target.members, zoneId] } })
|
|
123
|
+
}
|
|
124
|
+
if (updates.length > 0) updateNodes(updates)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function toggleZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
|
|
128
|
+
const unit = useScene.getState().nodes[unitId]
|
|
129
|
+
if (unit?.type !== 'unit') return
|
|
130
|
+
assignZoneToUnit(zoneId, unit.members.includes(zoneId) ? null : unitId)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The zone under a point on the current level, in building-local XZ. A 3D
|
|
135
|
+
* click lands on whatever surface is closest (a floor slab sits above the
|
|
136
|
+
* zone fill), so painting resolves the zone from the hit point instead of the
|
|
137
|
+
* hit mesh. Nested zones resolve to the smallest one.
|
|
138
|
+
*/
|
|
139
|
+
export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
|
|
140
|
+
const levelId = useViewer.getState().selection.levelId
|
|
141
|
+
if (!levelId) return null
|
|
142
|
+
const nodes = useScene.getState().nodes
|
|
143
|
+
const resolve = (id: AnyNodeId) => nodes[id]
|
|
144
|
+
let best: ZoneNode | null = null
|
|
145
|
+
let bestArea = Number.POSITIVE_INFINITY
|
|
146
|
+
for (const node of Object.values(nodes)) {
|
|
147
|
+
if (node.type !== 'zone' || node.parentId !== levelId) continue
|
|
148
|
+
const polygon = resolveAutoZonePolygon(node, resolve)
|
|
149
|
+
if (!pointInPolygon2D([x, z], polygon)) continue
|
|
150
|
+
let area = 0
|
|
151
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
152
|
+
const a = polygon[i]!
|
|
153
|
+
const b = polygon[(i + 1) % polygon.length]!
|
|
154
|
+
area += a[0] * b[1] - b[0] * a[1]
|
|
155
|
+
}
|
|
156
|
+
area = Math.abs(area) / 2
|
|
157
|
+
if (area < bestArea) {
|
|
158
|
+
best = node
|
|
159
|
+
bestArea = area
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return best
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function zoneAtWorldPoint(worldX: number, worldZ: number): ZoneNode | null {
|
|
166
|
+
const pose = getActiveBuildingPose()
|
|
167
|
+
const [x, z] = pose
|
|
168
|
+
? snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0).local
|
|
169
|
+
: [worldX, worldZ]
|
|
170
|
+
return zoneAtLevelPoint(x, z)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const ZONE_PAINT_DELAY_MS = 250
|
|
174
|
+
const pendingPaints = new Map<ZoneNode['id'], ReturnType<typeof setTimeout>>()
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The canvas paint gesture. The toggle waits out the double-click window so
|
|
178
|
+
* the second click of a double-click cancels it instead of toggling twice;
|
|
179
|
+
* the double-click itself then selects the zone.
|
|
180
|
+
*/
|
|
181
|
+
export function paintZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
|
|
182
|
+
if (cancelPendingZonePaint(zoneId)) return
|
|
183
|
+
pendingPaints.set(
|
|
184
|
+
zoneId,
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
pendingPaints.delete(zoneId)
|
|
187
|
+
toggleZoneMembership(unitId, zoneId)
|
|
188
|
+
}, ZONE_PAINT_DELAY_MS),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function cancelPendingZonePaint(zoneId: ZoneNode['id']): boolean {
|
|
193
|
+
const pending = pendingPaints.get(zoneId)
|
|
194
|
+
if (!pending) return false
|
|
195
|
+
clearTimeout(pending)
|
|
196
|
+
pendingPaints.delete(zoneId)
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createUnitInBuilding(buildingId: BuildingNode['id']): UnitNode['id'] | null {
|
|
201
|
+
const scene = useScene.getState()
|
|
202
|
+
const building = scene.nodes[buildingId]
|
|
203
|
+
if (building?.type !== 'building') return null
|
|
204
|
+
const unitCount = building.children.filter((id) => scene.nodes[id]?.type === 'unit').length
|
|
205
|
+
const unit = UnitNode.parse({ name: `Unit ${unitCount + 1}` })
|
|
206
|
+
scene.createNode(unit, buildingId)
|
|
207
|
+
enterUnitFocus(unit.id)
|
|
208
|
+
return unit.id
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Keeps unit focus coherent with the rest of the editor: a focus set from
|
|
213
|
+
* anywhere (the unit inspector calls the viewer store directly) gets the full
|
|
214
|
+
* enter treatment, and focus ends when the user switches layer or phase by
|
|
215
|
+
* hand, selects anything other than the focused unit, or the unit is deleted.
|
|
216
|
+
*/
|
|
217
|
+
export function useUnitFocusRules(): void {
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const unsubscribeViewer = useViewer.subscribe((state, prev) => {
|
|
220
|
+
if (state.focusedUnitId !== prev.focusedUnitId) {
|
|
221
|
+
if (state.focusedUnitId) enterUnitFocus(state.focusedUnitId)
|
|
222
|
+
else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId, false)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
const unitId = state.focusedUnitId
|
|
226
|
+
if (!unitId || state.selection === prev.selection) return
|
|
227
|
+
// Selecting a zone keeps focus: renaming or recolouring a zone row is
|
|
228
|
+
// part of arranging the unit, and zones live on the layer focus uses.
|
|
229
|
+
const { selectedIds } = state.selection
|
|
230
|
+
const selectsOther =
|
|
231
|
+
selectedIds.length > 0 && !(selectedIds.length === 1 && selectedIds[0] === unitId)
|
|
232
|
+
if (selectsOther) leaveUnitFocus()
|
|
233
|
+
})
|
|
234
|
+
const unsubscribeEditor = useEditor.subscribe((state, prev) => {
|
|
235
|
+
if (state.structureLayer === prev.structureLayer && state.phase === prev.phase) return
|
|
236
|
+
if (!useViewer.getState().focusedUnitId) return
|
|
237
|
+
if (state.phase !== 'structure' || state.structureLayer !== 'zones') {
|
|
238
|
+
leaveUnitFocus({ keepLayer: true })
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
const unsubscribeScene = useScene.subscribe((state, prev) => {
|
|
242
|
+
if (state.nodes === prev.nodes) return
|
|
243
|
+
const unitId = useViewer.getState().focusedUnitId
|
|
244
|
+
if (unitId && state.nodes[unitId]?.type !== 'unit') leaveUnitFocus()
|
|
245
|
+
})
|
|
246
|
+
return () => {
|
|
247
|
+
unsubscribeViewer()
|
|
248
|
+
unsubscribeEditor()
|
|
249
|
+
unsubscribeScene()
|
|
250
|
+
for (const pending of pendingPaints.values()) clearTimeout(pending)
|
|
251
|
+
pendingPaints.clear()
|
|
252
|
+
}
|
|
253
|
+
}, [])
|
|
254
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AnyNode } from '@pascal-app/core'
|
|
2
|
+
import type { Object3D } from 'three'
|
|
3
|
+
import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
|
|
4
|
+
import { createExportTextureUtils } from './export-texture-utils'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
7
|
+
type GlbExport,
|
|
8
|
+
type GlbExportOptions,
|
|
9
|
+
preparePortableSceneFromViewer,
|
|
10
|
+
withExportDeadline,
|
|
11
|
+
} from './glb-export'
|
|
12
|
+
import { createUsdzScene, disposeExportResources } from './portable-export'
|
|
13
|
+
|
|
14
|
+
export type UsdzExportOptions = Pick<
|
|
15
|
+
GlbExportOptions,
|
|
16
|
+
'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning' | 'timeoutMs'
|
|
17
|
+
>
|
|
18
|
+
|
|
19
|
+
/** Export a native, self-contained USDZ with no glTF conversion fallback. */
|
|
20
|
+
export async function exportSceneToUsdz(
|
|
21
|
+
sceneGroup: Object3D,
|
|
22
|
+
nodes: Record<string, AnyNode>,
|
|
23
|
+
options: UsdzExportOptions = {},
|
|
24
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
25
|
+
const textureUtils = createExportTextureUtils()
|
|
26
|
+
const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
|
|
27
|
+
...options,
|
|
28
|
+
textures: 'embed',
|
|
29
|
+
animations: 'none',
|
|
30
|
+
textureUtils,
|
|
31
|
+
})
|
|
32
|
+
// Assigned inside the raced closure, so plain `let`s narrow to `never` in
|
|
33
|
+
// the `finally`.
|
|
34
|
+
const run: { prepared: GlbExport | null; scene: Object3D | null; abandoned: boolean } = {
|
|
35
|
+
prepared: null,
|
|
36
|
+
scene: null,
|
|
37
|
+
abandoned: false,
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return await withExportDeadline(
|
|
41
|
+
(async () => {
|
|
42
|
+
run.prepared = await preparation
|
|
43
|
+
if (run.abandoned) throw new Error('USDZ export abandoned')
|
|
44
|
+
for (const warning of run.prepared.warnings) options.onWarning?.(warning)
|
|
45
|
+
run.scene = createUsdzScene(run.prepared.scene)
|
|
46
|
+
const exporter = new USDZExporter()
|
|
47
|
+
exporter.textureUtils = textureUtils as unknown as USDZExporter['textureUtils']
|
|
48
|
+
return exporter.parseAsync(run.scene, {
|
|
49
|
+
onlyVisible: options.onlyVisible ?? true,
|
|
50
|
+
quickLookCompatible: true,
|
|
51
|
+
})
|
|
52
|
+
})(),
|
|
53
|
+
options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
54
|
+
'USDZ',
|
|
55
|
+
() => {
|
|
56
|
+
run.abandoned = true
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
} finally {
|
|
60
|
+
if (run.scene) disposeExportResources(run.scene, { textures: false })
|
|
61
|
+
if (run.prepared) run.prepared.dispose()
|
|
62
|
+
else preparation.then((late) => late.dispose()).catch(() => {})
|
|
63
|
+
await textureUtils.dispose()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,40 +1,16 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { useMemo } from 'react'
|
|
5
|
+
import { getLinearDisplay } from './linear-display'
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
* every length input honors the metric/imperial toggle identically.
|
|
10
|
-
*
|
|
11
|
-
* Values are always STORED in the field's own unit (meters for `unit === 'm'`).
|
|
12
|
-
* When the viewer preference is imperial AND the field is a meter length, the
|
|
13
|
-
* value is DISPLAYED (and edited) in feet; otherwise the conversions are the
|
|
14
|
-
* identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
|
|
15
|
-
* …) behave exactly as before.
|
|
16
|
-
*
|
|
17
|
-
* Used by both `SliderControl` and `MetricControl` — keep the two in sync via
|
|
18
|
-
* this single source of truth.
|
|
19
|
-
*/
|
|
20
|
-
export function useLinearDisplay(unit: string, precision: number) {
|
|
7
|
+
/** Shared display/input conversion; values and bounds stay in the stored unit. */
|
|
8
|
+
export function useLinearDisplay(unit: string, precision: number, step = 1) {
|
|
21
9
|
const viewerUnit = useViewer((state) => state.unit)
|
|
22
|
-
const
|
|
23
|
-
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
|
|
10
|
+
const metricNotation = useViewer((state) => state.metricNotation)
|
|
24
11
|
|
|
25
|
-
|
|
26
|
-
(
|
|
27
|
-
[
|
|
12
|
+
return useMemo(
|
|
13
|
+
() => getLinearDisplay(unit, viewerUnit, metricNotation, precision, step),
|
|
14
|
+
[unit, viewerUnit, metricNotation, precision, step],
|
|
28
15
|
)
|
|
29
|
-
const toStored = useCallback(
|
|
30
|
-
(display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
|
|
31
|
-
[isImperial],
|
|
32
|
-
)
|
|
33
|
-
// Round a stored value so it lands on a clean number of DISPLAY-unit digits.
|
|
34
|
-
const roundStored = useCallback(
|
|
35
|
-
(stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
|
|
36
|
-
[toDisplay, toStored, precision],
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
return { isImperial, displayUnit, toDisplay, toStored, roundStored }
|
|
40
16
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grab pointer lock on the viewer canvas for a walkthrough (walk / drone)
|
|
3
|
+
* entry. Must run synchronously inside a user-gesture task — callers flip the
|
|
4
|
+
* first-person flags in a `flushSync` first so the controls are mounted when
|
|
5
|
+
* the lock lands.
|
|
6
|
+
*
|
|
7
|
+
* `retryWhile`: the browser's re-lock cooldown (~1.25s after any unlock)
|
|
8
|
+
* rejects the request outright, which bites the natural "free the cursor,
|
|
9
|
+
* immediately pick the other camera" flow. When given, one delayed retry
|
|
10
|
+
* fires after the cooldown — only while the predicate still holds.
|
|
11
|
+
*/
|
|
12
|
+
export function requestWalkthroughPointerLock(options?: { retryWhile?: () => boolean }) {
|
|
13
|
+
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
|
|
14
|
+
if (!canvas) return
|
|
15
|
+
|
|
16
|
+
if (!canvas.hasAttribute('tabindex')) {
|
|
17
|
+
canvas.tabIndex = -1
|
|
18
|
+
}
|
|
19
|
+
canvas.focus({ preventScroll: true })
|
|
20
|
+
|
|
21
|
+
if (document.pointerLockElement === canvas) return
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// The request can also reject ASYNC (browser cooldown after a recent
|
|
25
|
+
// unlock) — swallow it like the P-resume path; clicking the canvas
|
|
26
|
+
// re-requests once the cooldown passes.
|
|
27
|
+
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
|
28
|
+
if (result && typeof result.catch === 'function') {
|
|
29
|
+
result.catch(() => {
|
|
30
|
+
const retryWhile = options?.retryWhile
|
|
31
|
+
if (!retryWhile) return
|
|
32
|
+
window.setTimeout(() => {
|
|
33
|
+
if (!retryWhile()) return
|
|
34
|
+
if (document.pointerLockElement === canvas) return
|
|
35
|
+
try {
|
|
36
|
+
const retried = canvas.requestPointerLock?.() as Promise<void> | undefined
|
|
37
|
+
if (retried && typeof retried.catch === 'function') retried.catch(() => {})
|
|
38
|
+
} catch {
|
|
39
|
+
// Best effort — clicking the canvas still locks.
|
|
40
|
+
}
|
|
41
|
+
}, 1400)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -85,6 +85,7 @@ describe('live draft preview stores', () => {
|
|
|
85
85
|
{
|
|
86
86
|
angle: 90,
|
|
87
87
|
branchAngle: 90,
|
|
88
|
+
damperAngle: 0,
|
|
88
89
|
diameter: 6,
|
|
89
90
|
diameter2: 6,
|
|
90
91
|
ductMaterial: 'sheet-metal' as const,
|
|
@@ -94,6 +95,8 @@ describe('live draft preview stores', () => {
|
|
|
94
95
|
id: 'duct-fitting_live-draft-0' as const,
|
|
95
96
|
metadata: {},
|
|
96
97
|
object: 'node' as const,
|
|
98
|
+
panelHeight: 0.15,
|
|
99
|
+
panelWidth: 0.25,
|
|
97
100
|
parentId: 'level_1' as const,
|
|
98
101
|
position: [1, 2, 3] as [number, number, number],
|
|
99
102
|
rotation: [0, 0, 0] as [number, number, number],
|
|
@@ -52,8 +52,7 @@ describe('terrain-sculpt mode lifecycle', () => {
|
|
|
52
52
|
|
|
53
53
|
test('a phase switch out of site drops the scope', () => {
|
|
54
54
|
useEditor.getState().setMode('terrain-sculpt')
|
|
55
|
-
//
|
|
56
|
-
// exactly why the scope sync is its own function.
|
|
55
|
+
// The phase setter delegates its mode rewrite to the ToolMode transition.
|
|
57
56
|
useEditor.getState().setPhase('structure')
|
|
58
57
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
|
59
58
|
})
|
|
@@ -153,10 +152,8 @@ describe('the brush and the 3D canvas travel together', () => {
|
|
|
153
152
|
|
|
154
153
|
describe('the modes that hide the editing canvas release the brush', () => {
|
|
155
154
|
// Preview / walkthrough / studio all unmount `ToolManager` (via `noEditing`),
|
|
156
|
-
// so the sculpt tool that would release the scope on unmount is gone.
|
|
157
|
-
//
|
|
158
|
-
// bug class `setPhase` had: a `sculpting` scope leaked here disables selection
|
|
159
|
-
// across the whole editor with nothing left mounted to clear it.
|
|
155
|
+
// so the sculpt tool that would release the scope on unmount is gone. Their
|
|
156
|
+
// ToolMode transitions must release the scope before the canvas disappears.
|
|
160
157
|
test('entering preview releases it', () => {
|
|
161
158
|
useEditor.getState().setMode('terrain-sculpt')
|
|
162
159
|
useEditor.getState().setPreviewMode(true)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import useEditor, { normalizePersistedEditorUiState } from './use-editor'
|
|
3
|
+
|
|
4
|
+
function resetToolMode() {
|
|
5
|
+
useEditor.getState().setPhase('structure')
|
|
6
|
+
useEditor.getState().setStructureLayer('elements')
|
|
7
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
8
|
+
useEditor.getState().setActivePaintMaterial(null)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
beforeEach(resetToolMode)
|
|
12
|
+
afterEach(resetToolMode)
|
|
13
|
+
|
|
14
|
+
describe('ToolMode transition', () => {
|
|
15
|
+
test('choosing a paint swatch while selected arms material paint', () => {
|
|
16
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
17
|
+
useEditor.getState().armMaterialPaint({
|
|
18
|
+
materialPreset: 'library:test-paint',
|
|
19
|
+
sourceTarget: 'wall',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
expect(useEditor.getState().toolMode).toEqual({ mode: 'material-paint' })
|
|
23
|
+
expect(useEditor.getState().mode).toBe('material-paint')
|
|
24
|
+
expect(useEditor.getState().tool).toBeNull()
|
|
25
|
+
expect(useEditor.getState().activePaintMaterial?.materialPreset).toBe('library:test-paint')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('leaving build clears the materialized tool', () => {
|
|
29
|
+
useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
|
|
30
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
31
|
+
|
|
32
|
+
expect(useEditor.getState().toolMode).toEqual({ mode: 'select' })
|
|
33
|
+
expect(useEditor.getState().mode).toBe('select')
|
|
34
|
+
expect(useEditor.getState().tool).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('the setMode compatibility wrapper elects a default build tool', () => {
|
|
38
|
+
useEditor.getState().setMode('build')
|
|
39
|
+
|
|
40
|
+
expect(useEditor.getState().toolMode).toEqual({ mode: 'build', tool: 'wall' })
|
|
41
|
+
expect(useEditor.getState().mode).toBe('build')
|
|
42
|
+
expect(useEditor.getState().tool).toBe('wall')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('setTool enters build and null exits it', () => {
|
|
46
|
+
useEditor.getState().armToolMode({ mode: 'select' })
|
|
47
|
+
useEditor.getState().setTool('slab')
|
|
48
|
+
|
|
49
|
+
expect(useEditor.getState().toolMode).toEqual({ mode: 'build', tool: 'slab' })
|
|
50
|
+
expect(useEditor.getState().mode).toBe('build')
|
|
51
|
+
expect(useEditor.getState().tool).toBe('slab')
|
|
52
|
+
|
|
53
|
+
useEditor.getState().setTool(null)
|
|
54
|
+
expect(useEditor.getState().toolMode).toEqual({ mode: 'select' })
|
|
55
|
+
expect(useEditor.getState().tool).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('persisted ToolMode normalization', () => {
|
|
60
|
+
test('elects a default for build with a null tool', () => {
|
|
61
|
+
const state = normalizePersistedEditorUiState({
|
|
62
|
+
phase: 'structure',
|
|
63
|
+
toolMode: { mode: 'build', tool: null as never },
|
|
64
|
+
mode: 'select',
|
|
65
|
+
tool: 'slab',
|
|
66
|
+
structureLayer: 'elements',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(state.toolMode).toEqual({ mode: 'build', tool: 'wall' })
|
|
70
|
+
expect(state.mode).toBe('build')
|
|
71
|
+
expect(state.tool).toBe('wall')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('clears a persisted tool from a non-build mode', () => {
|
|
75
|
+
const state = normalizePersistedEditorUiState({
|
|
76
|
+
phase: 'structure',
|
|
77
|
+
toolMode: { mode: 'select' },
|
|
78
|
+
mode: 'build',
|
|
79
|
+
tool: 'wall',
|
|
80
|
+
structureLayer: 'elements',
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(state.toolMode).toEqual({ mode: 'select' })
|
|
84
|
+
expect(state.mode).toBe('select')
|
|
85
|
+
expect(state.tool).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
|
|
3
|
+
/** The camera actions the canvas hint can name. */
|
|
4
|
+
export type CameraHintAction = 'Pan' | 'Rotate' | 'Zoom'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which camera controls the canvas hint is currently allowed to name.
|
|
8
|
+
*
|
|
9
|
+
* `null` — the default — means all of them, which is what the editor shows on
|
|
10
|
+
* its own: a first-time user has no idea which button does what, and the panel
|
|
11
|
+
* is there to answer that once.
|
|
12
|
+
*
|
|
13
|
+
* A host teaching the camera one gesture at a time needs the opposite. Showing
|
|
14
|
+
* three controls while asking for one turns the answer into a search, and
|
|
15
|
+
* leaving all three up after the lesson leaves a permanent widget over the
|
|
16
|
+
* canvas explaining something the user has just been walked through. So a host
|
|
17
|
+
* can narrow the panel to the gesture it is asking for, and to nothing at all
|
|
18
|
+
* once it is done — an empty list hides the panel outright.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately a store rather than a prop: the thing that knows which gesture
|
|
21
|
+
* is being taught is a host surface several levels away from the canvas, and
|
|
22
|
+
* threading it through would put a teaching concern in every component between.
|
|
23
|
+
* The editor knows only "show these actions"; it never learns why.
|
|
24
|
+
*/
|
|
25
|
+
type CameraHintFocus = {
|
|
26
|
+
actions: readonly CameraHintAction[] | null
|
|
27
|
+
focus: (actions: readonly CameraHintAction[] | null) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const useCameraHintFocus = create<CameraHintFocus>((set) => ({
|
|
31
|
+
actions: null,
|
|
32
|
+
focus: (actions) =>
|
|
33
|
+
set((state) => {
|
|
34
|
+
const current = state.actions
|
|
35
|
+
if (
|
|
36
|
+
current === actions ||
|
|
37
|
+
(current !== null &&
|
|
38
|
+
actions !== null &&
|
|
39
|
+
current.length === actions.length &&
|
|
40
|
+
current.every((action, index) => action === actions[index]))
|
|
41
|
+
) {
|
|
42
|
+
return state
|
|
43
|
+
}
|
|
44
|
+
return { actions }
|
|
45
|
+
}),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
export default useCameraHintFocus
|