@pascal-app/editor 1.0.0-beta.4 → 1.0.0
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/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- 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 +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -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 +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- 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/linear-resize-drag.ts +67 -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 +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- 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 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- 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-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 +204 -114
- 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/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/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 +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- 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 +6 -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 +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- 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 +63 -8
- 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 +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- 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 +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- 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 +12 -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/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- 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 +23 -6
- 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 +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- 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/panel-wrapper.tsx +228 -12
- 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 +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- 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/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- 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/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- 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 +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- 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 +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -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/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -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/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 +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- 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.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -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/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -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-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- 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/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- 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 +366 -0
- package/src/lib/portable-export.ts +969 -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 +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -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 +30 -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.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -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/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- 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-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-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'motion/react'
|
|
|
5
5
|
import { forwardRef, memo, useEffect, useRef } from 'react'
|
|
6
6
|
import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
|
|
7
7
|
import useEditor from '../../../../../store/use-editor'
|
|
8
|
+
import { expandSessionSelectionForNode } from '../../../../../store/use-session-groups'
|
|
8
9
|
|
|
9
10
|
export function handleTreeSelection(
|
|
10
11
|
e: React.MouseEvent,
|
|
@@ -48,7 +49,13 @@ export function handleTreeSelection(
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
if (e.altKey) {
|
|
53
|
+
setSelection({ selectedIds: [nodeId] })
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const expanded = expandSessionSelectionForNode(nodeId)
|
|
58
|
+
setSelection({ selectedIds: expanded && expanded.length > 1 ? expanded : [nodeId] })
|
|
52
59
|
return false
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -110,6 +117,12 @@ interface TreeNodeProps {
|
|
|
110
117
|
isLast?: boolean
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
type TreeNodeComponent = React.ComponentType<{
|
|
121
|
+
depth: number
|
|
122
|
+
isLast?: boolean
|
|
123
|
+
nodeId: AnyNodeId
|
|
124
|
+
}>
|
|
125
|
+
|
|
113
126
|
// Per-kind tree-node components keyed by `node.type`. Lookup replaces
|
|
114
127
|
// the legacy switch — adding a kind to this map is now the only edit
|
|
115
128
|
// needed in this file (the switch's `case '<kind>':` clauses were
|
|
@@ -117,10 +130,7 @@ interface TreeNodeProps {
|
|
|
117
130
|
// outside the registry; future work moves these to a
|
|
118
131
|
// `def.presentation`-driven generic tree-node and removes this map
|
|
119
132
|
// entirely).
|
|
120
|
-
const treeNodeByType: Record<
|
|
121
|
-
string,
|
|
122
|
-
React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
|
|
123
|
-
> = {
|
|
133
|
+
const treeNodeByType: Record<string, TreeNodeComponent> = {
|
|
124
134
|
building: BuildingTreeNode as React.ComponentType<{
|
|
125
135
|
depth: number
|
|
126
136
|
isLast?: boolean
|
|
@@ -129,6 +139,7 @@ const treeNodeByType: Record<
|
|
|
129
139
|
cabinet: RegistryTreeNode,
|
|
130
140
|
'cabinet-module': RegistryTreeNode,
|
|
131
141
|
'box-vent': RegistryTreeNode,
|
|
142
|
+
'block': RegistryTreeNode,
|
|
132
143
|
ceiling: CeilingTreeNode,
|
|
133
144
|
chimney: ChimneyTreeNode,
|
|
134
145
|
dormer: DormerTreeNode,
|
|
@@ -162,6 +173,7 @@ const treeNodeByType: Record<
|
|
|
162
173
|
'eyebrow-vent': RegistryTreeNode,
|
|
163
174
|
skylight: RegistryTreeNode,
|
|
164
175
|
roof: RoofTreeNode,
|
|
176
|
+
scan: RegistryTreeNode,
|
|
165
177
|
stair: StairTreeNode,
|
|
166
178
|
door: DoorTreeNode,
|
|
167
179
|
window: WindowTreeNode,
|
|
@@ -173,6 +185,10 @@ const treeNodeByType: Record<
|
|
|
173
185
|
item: ItemTreeNode,
|
|
174
186
|
}
|
|
175
187
|
|
|
188
|
+
export function getTreeNodeComponent(nodeType: string): TreeNodeComponent {
|
|
189
|
+
return treeNodeByType[nodeType] ?? RegistryTreeNode
|
|
190
|
+
}
|
|
191
|
+
|
|
176
192
|
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
|
177
193
|
// Registry-driven row hiding (`def.tree.hidden`) — primitive boolean
|
|
178
194
|
// selector so unrelated scene updates don't re-render every row.
|
|
@@ -184,8 +200,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
|
|
|
184
200
|
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
|
|
185
201
|
if (shouldHide) return null
|
|
186
202
|
if (!nodeType) return null
|
|
187
|
-
const Component =
|
|
188
|
-
if (!Component) return null
|
|
203
|
+
const Component = getTreeNodeComponent(nodeType)
|
|
189
204
|
return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
|
|
190
205
|
})
|
|
191
206
|
|
|
@@ -13,6 +13,12 @@ export type SidebarTab = {
|
|
|
13
13
|
mobileIcon?: ReactNode
|
|
14
14
|
/** Desktop icon shown in the vertical rail (v2 layout). */
|
|
15
15
|
icon?: ReactNode
|
|
16
|
+
/**
|
|
17
|
+
* Rail entry that drives the stage instead of opening a sidebar panel:
|
|
18
|
+
* activating it hides the panel column (preserving its collapse state) and
|
|
19
|
+
* keeps the icon highlighted regardless of collapse.
|
|
20
|
+
*/
|
|
21
|
+
noPanel?: boolean
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
interface TabBarProps {
|
|
@@ -75,11 +81,13 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
|
|
|
75
81
|
const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
|
|
76
82
|
|
|
77
83
|
const renderTab = (tab: SidebarTab) => {
|
|
78
|
-
const showActive = activeTab === tab.id && !collapsed
|
|
84
|
+
const showActive = activeTab === tab.id && (!collapsed || tab.noPanel === true)
|
|
79
85
|
return (
|
|
80
86
|
<Tooltip key={tab.id}>
|
|
81
87
|
<TooltipTrigger asChild>
|
|
82
88
|
<button
|
|
89
|
+
aria-label={tab.label}
|
|
90
|
+
aria-pressed={showActive}
|
|
83
91
|
className={cn(
|
|
84
92
|
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
|
|
85
93
|
showActive
|
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
useSyncExternalStore,
|
|
13
13
|
} from 'react'
|
|
14
14
|
import useEditor from '../../../store/use-editor'
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
editorHostPanelRegistry,
|
|
17
|
+
type EditorHostPanel,
|
|
18
|
+
managedPluginIds,
|
|
19
|
+
showsPluginManager,
|
|
20
|
+
} from '../../../lib/plugin-panels'
|
|
16
21
|
import { ErrorBoundary } from '../primitives/error-boundary'
|
|
17
22
|
import type { ExtraPanel } from './icon-rail'
|
|
18
23
|
import { PluginsPanel } from './panels/plugins-panel'
|
|
@@ -100,6 +105,7 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
|
|
|
100
105
|
)
|
|
101
106
|
const workspaceMode = useEditor((s) => s.workspaceMode)
|
|
102
107
|
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
108
|
+
const readOnly = useScene((s) => s.readOnly)
|
|
103
109
|
const hostIds = new Set(hostPanels?.map((p) => p.id))
|
|
104
110
|
|
|
105
111
|
useEffect(() => {
|
|
@@ -126,7 +132,16 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
|
|
|
126
132
|
pluginId: p.pluginId,
|
|
127
133
|
}),
|
|
128
134
|
)
|
|
135
|
+
// The manager tab is the one panel the editor contributes itself, so it is
|
|
136
|
+
// also the one that can be alone in the rail — see `showsPluginManager`.
|
|
129
137
|
const manager =
|
|
130
|
-
|
|
138
|
+
!hostIds.has(pluginsManagerPanel.id) &&
|
|
139
|
+
showsPluginManager({
|
|
140
|
+
managedPluginCount: managedPluginIds(registered).length,
|
|
141
|
+
readOnly,
|
|
142
|
+
workspaceMode,
|
|
143
|
+
})
|
|
144
|
+
? [pluginsManagerPanel]
|
|
145
|
+
: []
|
|
131
146
|
return [...(hostPanels ?? []), ...fromRegistry, ...manager]
|
|
132
147
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
|
5
|
+
|
|
6
|
+
export type FloorplanCompassButtonProps = {
|
|
7
|
+
northRotationDeg: number
|
|
8
|
+
onAlignNorth: () => void
|
|
9
|
+
needleRef?: React.RefObject<SVGSVGElement | null>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FloorplanCompassButton({
|
|
13
|
+
northRotationDeg,
|
|
14
|
+
onAlignNorth,
|
|
15
|
+
needleRef,
|
|
16
|
+
}: FloorplanCompassButtonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<Tooltip>
|
|
19
|
+
<TooltipTrigger asChild>
|
|
20
|
+
<button
|
|
21
|
+
aria-label="Align view to north"
|
|
22
|
+
className="group pointer-events-auto absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
|
|
23
|
+
onClick={(event) => {
|
|
24
|
+
event.preventDefault()
|
|
25
|
+
event.stopPropagation()
|
|
26
|
+
onAlignNorth()
|
|
27
|
+
}}
|
|
28
|
+
onPointerDown={(event) => {
|
|
29
|
+
event.stopPropagation()
|
|
30
|
+
}}
|
|
31
|
+
type="button"
|
|
32
|
+
>
|
|
33
|
+
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
|
|
34
|
+
<svg
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
className="h-6 w-6"
|
|
37
|
+
ref={needleRef}
|
|
38
|
+
style={{ transform: `rotate(${northRotationDeg}deg)` }}
|
|
39
|
+
viewBox="0 0 48 48"
|
|
40
|
+
>
|
|
41
|
+
<path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
|
|
42
|
+
<path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
|
|
43
|
+
</svg>
|
|
44
|
+
</span>
|
|
45
|
+
</button>
|
|
46
|
+
</TooltipTrigger>
|
|
47
|
+
<TooltipContent side="right">Align view to north</TooltipContent>
|
|
48
|
+
</Tooltip>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { FloorplanGeometry } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
clientToFloorplanPoint,
|
|
5
|
+
getFloorplanBounds,
|
|
6
|
+
padFloorplanBounds,
|
|
7
|
+
panFloorplanViewBox,
|
|
8
|
+
scaleFloorplanViewBox,
|
|
9
|
+
scaleFloorplanViewBoxBetweenClients,
|
|
10
|
+
} from './floorplan-preview-geometry'
|
|
11
|
+
|
|
12
|
+
describe('floorplan preview bounds', () => {
|
|
13
|
+
test('composes nested translation and rotation', () => {
|
|
14
|
+
const geometry: FloorplanGeometry = {
|
|
15
|
+
kind: 'group',
|
|
16
|
+
transform: { translate: [10, 5], rotate: Math.PI / 2 },
|
|
17
|
+
children: [
|
|
18
|
+
{
|
|
19
|
+
kind: 'rect',
|
|
20
|
+
x: 0,
|
|
21
|
+
y: 0,
|
|
22
|
+
width: 4,
|
|
23
|
+
height: 2,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
expect(getFloorplanBounds([geometry])).toEqual({
|
|
29
|
+
minX: 8,
|
|
30
|
+
minY: 5,
|
|
31
|
+
maxX: 10,
|
|
32
|
+
maxY: 9,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('includes image rotation and dimension baselines', () => {
|
|
37
|
+
const geometries: FloorplanGeometry[] = [
|
|
38
|
+
{
|
|
39
|
+
kind: 'image',
|
|
40
|
+
url: '/symbol.png',
|
|
41
|
+
center: [2, 3],
|
|
42
|
+
width: 4,
|
|
43
|
+
height: 2,
|
|
44
|
+
rotation: Math.PI / 2,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
kind: 'dimension',
|
|
48
|
+
start: [-3, -2],
|
|
49
|
+
end: [3, -2],
|
|
50
|
+
dimensionStart: [-4, -1],
|
|
51
|
+
dimensionEnd: [4, -1],
|
|
52
|
+
offsetNormal: [0, -1],
|
|
53
|
+
offsetDistance: 1,
|
|
54
|
+
extensionOvershoot: 0.2,
|
|
55
|
+
text: '8 m',
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
expect(getFloorplanBounds(geometries)).toEqual({
|
|
60
|
+
minX: -4,
|
|
61
|
+
minY: -2,
|
|
62
|
+
maxX: 4,
|
|
63
|
+
maxY: 5,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('pads narrow plans by a usable minimum', () => {
|
|
68
|
+
expect(padFloorplanBounds({ minX: 0, minY: 0, maxX: 0, maxY: 0 })).toEqual({
|
|
69
|
+
minX: -0.75,
|
|
70
|
+
minY: -0.75,
|
|
71
|
+
maxX: 0.75,
|
|
72
|
+
maxY: 0.75,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('maps clients through letterboxed SVG content', () => {
|
|
77
|
+
const viewBox = { x: 0, y: 0, width: 10, height: 10 }
|
|
78
|
+
const viewport = { left: 0, top: 0, width: 1000, height: 500 }
|
|
79
|
+
|
|
80
|
+
expect(clientToFloorplanPoint(viewBox, viewport, 250, 0)).toEqual([0, 0])
|
|
81
|
+
expect(clientToFloorplanPoint(viewBox, viewport, 750, 500)).toEqual([10, 10])
|
|
82
|
+
expect(panFloorplanViewBox(viewBox, viewport, [500, 250], [550, 250])).toEqual({
|
|
83
|
+
x: -1,
|
|
84
|
+
y: 0,
|
|
85
|
+
width: 10,
|
|
86
|
+
height: 10,
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('keeps the plan point beneath a moving pinch midpoint', () => {
|
|
91
|
+
const viewBox = { x: 0, y: 0, width: 10, height: 10 }
|
|
92
|
+
const viewport = { left: 0, top: 0, width: 1000, height: 500 }
|
|
93
|
+
const next = scaleFloorplanViewBoxBetweenClients(viewBox, 0.5, viewport, [500, 250], [600, 250])
|
|
94
|
+
|
|
95
|
+
expect(next).toEqual({ x: 1.5, y: 2.5, width: 5, height: 5 })
|
|
96
|
+
expect(clientToFloorplanPoint(next, viewport, 600, 250)).toEqual([5, 5])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('clamps zoom without changing the view box aspect ratio', () => {
|
|
100
|
+
expect(scaleFloorplanViewBox({ x: 0, y: 0, width: 100, height: 1 }, 0.01)).toEqual({
|
|
101
|
+
x: 37.5,
|
|
102
|
+
y: 0.375,
|
|
103
|
+
width: 25,
|
|
104
|
+
height: 0.25,
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type FloorplanBounds = {
|
|
4
|
+
minX: number
|
|
5
|
+
minY: number
|
|
6
|
+
maxX: number
|
|
7
|
+
maxY: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type FloorplanViewBox = { x: number; y: number; width: number; height: number }
|
|
11
|
+
|
|
12
|
+
export type FloorplanViewport = {
|
|
13
|
+
left: number
|
|
14
|
+
top: number
|
|
15
|
+
width: number
|
|
16
|
+
height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type PlanTransform = { tx: number; ty: number; rotation: number }
|
|
20
|
+
|
|
21
|
+
const IDENTITY: PlanTransform = { tx: 0, ty: 0, rotation: 0 }
|
|
22
|
+
const MIN_VIEWBOX_SIZE = 0.25
|
|
23
|
+
const MAX_VIEWBOX_SIZE = 500
|
|
24
|
+
|
|
25
|
+
function clientToViewBoxFraction(
|
|
26
|
+
viewBox: FloorplanViewBox,
|
|
27
|
+
viewport: FloorplanViewport,
|
|
28
|
+
clientX: number,
|
|
29
|
+
clientY: number,
|
|
30
|
+
): FloorplanPoint {
|
|
31
|
+
const viewportWidth = Math.max(viewport.width, 1)
|
|
32
|
+
const viewportHeight = Math.max(viewport.height, 1)
|
|
33
|
+
const scale = Math.min(viewportWidth / viewBox.width, viewportHeight / viewBox.height)
|
|
34
|
+
const renderedWidth = viewBox.width * scale
|
|
35
|
+
const renderedHeight = viewBox.height * scale
|
|
36
|
+
const renderedLeft = viewport.left + (viewportWidth - renderedWidth) / 2
|
|
37
|
+
const renderedTop = viewport.top + (viewportHeight - renderedHeight) / 2
|
|
38
|
+
return [(clientX - renderedLeft) / renderedWidth, (clientY - renderedTop) / renderedHeight]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clientToFloorplanPoint(
|
|
42
|
+
viewBox: FloorplanViewBox,
|
|
43
|
+
viewport: FloorplanViewport,
|
|
44
|
+
clientX: number,
|
|
45
|
+
clientY: number,
|
|
46
|
+
): FloorplanPoint {
|
|
47
|
+
const [fractionX, fractionY] = clientToViewBoxFraction(viewBox, viewport, clientX, clientY)
|
|
48
|
+
return [viewBox.x + fractionX * viewBox.width, viewBox.y + fractionY * viewBox.height]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function scaleFloorplanViewBox(
|
|
52
|
+
viewBox: FloorplanViewBox,
|
|
53
|
+
factor: number,
|
|
54
|
+
anchorX = 0.5,
|
|
55
|
+
anchorY = 0.5,
|
|
56
|
+
): FloorplanViewBox {
|
|
57
|
+
const minFactor = Math.max(MIN_VIEWBOX_SIZE / viewBox.width, MIN_VIEWBOX_SIZE / viewBox.height)
|
|
58
|
+
const maxFactor = Math.min(MAX_VIEWBOX_SIZE / viewBox.width, MAX_VIEWBOX_SIZE / viewBox.height)
|
|
59
|
+
const clampedFactor = Math.min(Math.max(factor, minFactor), maxFactor)
|
|
60
|
+
const width = viewBox.width * clampedFactor
|
|
61
|
+
const height = viewBox.height * clampedFactor
|
|
62
|
+
return {
|
|
63
|
+
x: viewBox.x + (viewBox.width - width) * anchorX,
|
|
64
|
+
y: viewBox.y + (viewBox.height - height) * anchorY,
|
|
65
|
+
width,
|
|
66
|
+
height,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function scaleFloorplanViewBoxBetweenClients(
|
|
71
|
+
viewBox: FloorplanViewBox,
|
|
72
|
+
factor: number,
|
|
73
|
+
viewport: FloorplanViewport,
|
|
74
|
+
sourceClient: FloorplanPoint,
|
|
75
|
+
targetClient: FloorplanPoint,
|
|
76
|
+
): FloorplanViewBox {
|
|
77
|
+
const sourcePoint = clientToFloorplanPoint(viewBox, viewport, sourceClient[0], sourceClient[1])
|
|
78
|
+
const [targetX, targetY] = clientToViewBoxFraction(
|
|
79
|
+
viewBox,
|
|
80
|
+
viewport,
|
|
81
|
+
targetClient[0],
|
|
82
|
+
targetClient[1],
|
|
83
|
+
)
|
|
84
|
+
const scaled = scaleFloorplanViewBox(viewBox, factor, 0, 0)
|
|
85
|
+
return {
|
|
86
|
+
...scaled,
|
|
87
|
+
x: sourcePoint[0] - targetX * scaled.width,
|
|
88
|
+
y: sourcePoint[1] - targetY * scaled.height,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function panFloorplanViewBox(
|
|
93
|
+
viewBox: FloorplanViewBox,
|
|
94
|
+
viewport: FloorplanViewport,
|
|
95
|
+
sourceClient: FloorplanPoint,
|
|
96
|
+
targetClient: FloorplanPoint,
|
|
97
|
+
): FloorplanViewBox {
|
|
98
|
+
const sourcePoint = clientToFloorplanPoint(viewBox, viewport, sourceClient[0], sourceClient[1])
|
|
99
|
+
const targetPoint = clientToFloorplanPoint(viewBox, viewport, targetClient[0], targetClient[1])
|
|
100
|
+
return {
|
|
101
|
+
...viewBox,
|
|
102
|
+
x: viewBox.x + sourcePoint[0] - targetPoint[0],
|
|
103
|
+
y: viewBox.y + sourcePoint[1] - targetPoint[1],
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applyTransform(point: FloorplanPoint, transform: PlanTransform): FloorplanPoint {
|
|
108
|
+
const cos = Math.cos(transform.rotation)
|
|
109
|
+
const sin = Math.sin(transform.rotation)
|
|
110
|
+
return [
|
|
111
|
+
point[0] * cos - point[1] * sin + transform.tx,
|
|
112
|
+
point[0] * sin + point[1] * cos + transform.ty,
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function composeTransform(
|
|
117
|
+
parent: PlanTransform,
|
|
118
|
+
child: NonNullable<Extract<FloorplanGeometry, { kind: 'group' }>['transform']>,
|
|
119
|
+
): PlanTransform {
|
|
120
|
+
const translated = applyTransform(child.translate ?? [0, 0], parent)
|
|
121
|
+
return {
|
|
122
|
+
tx: translated[0],
|
|
123
|
+
ty: translated[1],
|
|
124
|
+
rotation: parent.rotation + (child.rotate ?? 0),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function includePoint(bounds: FloorplanBounds | null, point: FloorplanPoint): FloorplanBounds {
|
|
129
|
+
if (!bounds) return { minX: point[0], minY: point[1], maxX: point[0], maxY: point[1] }
|
|
130
|
+
return {
|
|
131
|
+
minX: Math.min(bounds.minX, point[0]),
|
|
132
|
+
minY: Math.min(bounds.minY, point[1]),
|
|
133
|
+
maxX: Math.max(bounds.maxX, point[0]),
|
|
134
|
+
maxY: Math.max(bounds.maxY, point[1]),
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function includePoints(
|
|
139
|
+
bounds: FloorplanBounds | null,
|
|
140
|
+
points: readonly FloorplanPoint[],
|
|
141
|
+
transform: PlanTransform,
|
|
142
|
+
): FloorplanBounds | null {
|
|
143
|
+
let next = bounds
|
|
144
|
+
for (const point of points) next = includePoint(next, applyTransform(point, transform))
|
|
145
|
+
return next
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function geometryBounds(
|
|
149
|
+
geometry: FloorplanGeometry,
|
|
150
|
+
transform: PlanTransform,
|
|
151
|
+
bounds: FloorplanBounds | null,
|
|
152
|
+
): FloorplanBounds | null {
|
|
153
|
+
switch (geometry.kind) {
|
|
154
|
+
case 'group': {
|
|
155
|
+
const nextTransform = geometry.transform
|
|
156
|
+
? composeTransform(transform, geometry.transform)
|
|
157
|
+
: transform
|
|
158
|
+
return geometry.children.reduce(
|
|
159
|
+
(next, child) => geometryBounds(child, nextTransform, next),
|
|
160
|
+
bounds,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
case 'polygon':
|
|
164
|
+
case 'polyline':
|
|
165
|
+
case 'hatch':
|
|
166
|
+
return includePoints(bounds, geometry.points, transform)
|
|
167
|
+
case 'rect':
|
|
168
|
+
return includePoints(
|
|
169
|
+
bounds,
|
|
170
|
+
[
|
|
171
|
+
[geometry.x, geometry.y],
|
|
172
|
+
[geometry.x + geometry.width, geometry.y],
|
|
173
|
+
[geometry.x + geometry.width, geometry.y + geometry.height],
|
|
174
|
+
[geometry.x, geometry.y + geometry.height],
|
|
175
|
+
],
|
|
176
|
+
transform,
|
|
177
|
+
)
|
|
178
|
+
case 'circle': {
|
|
179
|
+
const center = applyTransform([geometry.cx, geometry.cy], transform)
|
|
180
|
+
return includePoints(
|
|
181
|
+
bounds,
|
|
182
|
+
[
|
|
183
|
+
[center[0] - geometry.r, center[1] - geometry.r],
|
|
184
|
+
[center[0] + geometry.r, center[1] + geometry.r],
|
|
185
|
+
],
|
|
186
|
+
IDENTITY,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
case 'line':
|
|
190
|
+
case 'hit-line':
|
|
191
|
+
case 'edge-handle':
|
|
192
|
+
return includePoints(
|
|
193
|
+
bounds,
|
|
194
|
+
[
|
|
195
|
+
[geometry.x1, geometry.y1],
|
|
196
|
+
[geometry.x2, geometry.y2],
|
|
197
|
+
],
|
|
198
|
+
transform,
|
|
199
|
+
)
|
|
200
|
+
case 'text':
|
|
201
|
+
return includePoint(bounds, applyTransform([geometry.x, geometry.y], transform))
|
|
202
|
+
case 'image': {
|
|
203
|
+
const halfWidth = geometry.width / 2
|
|
204
|
+
const halfHeight = geometry.height / 2
|
|
205
|
+
const imageTransform = composeTransform(transform, {
|
|
206
|
+
translate: geometry.center,
|
|
207
|
+
rotate: geometry.rotation,
|
|
208
|
+
})
|
|
209
|
+
return includePoints(
|
|
210
|
+
bounds,
|
|
211
|
+
[
|
|
212
|
+
[-halfWidth, -halfHeight],
|
|
213
|
+
[halfWidth, -halfHeight],
|
|
214
|
+
[halfWidth, halfHeight],
|
|
215
|
+
[-halfWidth, halfHeight],
|
|
216
|
+
],
|
|
217
|
+
imageTransform,
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
case 'endpoint-handle':
|
|
221
|
+
case 'midpoint-handle':
|
|
222
|
+
case 'move-handle':
|
|
223
|
+
case 'move-arrow':
|
|
224
|
+
case 'rotate-arrow':
|
|
225
|
+
case 'equal-spacing-badge':
|
|
226
|
+
return includePoint(bounds, applyTransform(geometry.point, transform))
|
|
227
|
+
case 'dimension-label':
|
|
228
|
+
return includePoint(bounds, applyTransform([geometry.cx, geometry.cy], transform))
|
|
229
|
+
case 'dimension':
|
|
230
|
+
return includePoints(
|
|
231
|
+
bounds,
|
|
232
|
+
[
|
|
233
|
+
geometry.start,
|
|
234
|
+
geometry.end,
|
|
235
|
+
geometry.dimensionStart ?? geometry.start,
|
|
236
|
+
geometry.dimensionEnd ?? geometry.end,
|
|
237
|
+
],
|
|
238
|
+
transform,
|
|
239
|
+
)
|
|
240
|
+
case 'dimension-string':
|
|
241
|
+
return geometry.segments.reduce(
|
|
242
|
+
(next, segment) =>
|
|
243
|
+
includePoints(
|
|
244
|
+
next,
|
|
245
|
+
[
|
|
246
|
+
segment.start,
|
|
247
|
+
segment.end,
|
|
248
|
+
segment.dimensionStart ?? segment.start,
|
|
249
|
+
segment.dimensionEnd ?? segment.end,
|
|
250
|
+
],
|
|
251
|
+
transform,
|
|
252
|
+
),
|
|
253
|
+
bounds,
|
|
254
|
+
)
|
|
255
|
+
case 'path':
|
|
256
|
+
return bounds
|
|
257
|
+
default:
|
|
258
|
+
return bounds
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function getFloorplanBounds(
|
|
263
|
+
geometries: readonly FloorplanGeometry[],
|
|
264
|
+
): FloorplanBounds | null {
|
|
265
|
+
return geometries.reduce(
|
|
266
|
+
(bounds, geometry) => geometryBounds(geometry, IDENTITY, bounds),
|
|
267
|
+
null as FloorplanBounds | null,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function padFloorplanBounds(bounds: FloorplanBounds, ratio = 0.12): FloorplanBounds {
|
|
272
|
+
const width = Math.max(bounds.maxX - bounds.minX, 1)
|
|
273
|
+
const height = Math.max(bounds.maxY - bounds.minY, 1)
|
|
274
|
+
const padding = Math.max(Math.max(width, height) * ratio, 0.75)
|
|
275
|
+
return {
|
|
276
|
+
minX: bounds.minX - padding,
|
|
277
|
+
minY: bounds.minY - padding,
|
|
278
|
+
maxX: bounds.maxX + padding,
|
|
279
|
+
maxY: bounds.maxY + padding,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
cameraAzimuthFromFloorplanRotation,
|
|
4
|
+
floorplanRotationFromCameraAzimuth,
|
|
5
|
+
floorplanViewBoxFromNavigationPose,
|
|
6
|
+
nearestEquivalentDegrees,
|
|
7
|
+
visibleFloorplanViewWidth,
|
|
8
|
+
} from './floorplan-preview-navigation'
|
|
9
|
+
|
|
10
|
+
describe('floorplan preview navigation', () => {
|
|
11
|
+
test('keeps continuous compass rotations across the angle seam', () => {
|
|
12
|
+
expect(nearestEquivalentDegrees(-179, 179)).toBe(181)
|
|
13
|
+
expect(floorplanRotationFromCameraAzimuth((-179 * Math.PI) / 180, 179)).toBeCloseTo(181)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('uses the same north-up azimuth convention as the editor', () => {
|
|
17
|
+
expect(cameraAzimuthFromFloorplanRotation(0)).toBe(0)
|
|
18
|
+
expect(cameraAzimuthFromFloorplanRotation(90)).toBeCloseTo(Math.PI / 2)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('maps a camera pose to an aspect-correct centered view box', () => {
|
|
22
|
+
expect(
|
|
23
|
+
floorplanViewBoxFromNavigationPose(
|
|
24
|
+
{
|
|
25
|
+
source: '3d',
|
|
26
|
+
revision: 1,
|
|
27
|
+
target: [0, 0, 0],
|
|
28
|
+
azimuth: 0,
|
|
29
|
+
viewWidth: 20,
|
|
30
|
+
},
|
|
31
|
+
{ x: 4, y: 3 },
|
|
32
|
+
0,
|
|
33
|
+
{ width: 1000, height: 500 },
|
|
34
|
+
),
|
|
35
|
+
).toEqual({ x: -6, y: -2, width: 20, height: 10 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('reports the actual horizontal span for meet-preserved SVG view boxes', () => {
|
|
39
|
+
expect(
|
|
40
|
+
visibleFloorplanViewWidth({ x: 0, y: 0, width: 10, height: 10 }, { width: 200, height: 100 }),
|
|
41
|
+
).toBe(20)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { NavigationSyncPose } from '../../store/use-editor'
|
|
2
|
+
import type { FloorplanViewBox } from './floorplan-preview-geometry'
|
|
3
|
+
|
|
4
|
+
export type FloorplanPreviewViewportSize = {
|
|
5
|
+
width: number
|
|
6
|
+
height: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function nearestEquivalentDegrees(angle: number, reference: number) {
|
|
10
|
+
let next = angle
|
|
11
|
+
while (next - reference > 180) next -= 360
|
|
12
|
+
while (next - reference < -180) next += 360
|
|
13
|
+
return next
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function floorplanRotationFromCameraAzimuth(azimuth: number, reference: number) {
|
|
17
|
+
return nearestEquivalentDegrees((azimuth * 180) / Math.PI, reference)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function cameraAzimuthFromFloorplanRotation(rotationDeg: number) {
|
|
21
|
+
return (rotationDeg * Math.PI) / 180
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function rotateFloorplanPoint(
|
|
25
|
+
point: { x: number; y: number },
|
|
26
|
+
rotationDeg: number,
|
|
27
|
+
): { x: number; y: number } {
|
|
28
|
+
if (rotationDeg === 0) return point
|
|
29
|
+
const radians = (rotationDeg * Math.PI) / 180
|
|
30
|
+
const cos = Math.cos(radians)
|
|
31
|
+
const sin = Math.sin(radians)
|
|
32
|
+
return {
|
|
33
|
+
x: point.x * cos - point.y * sin,
|
|
34
|
+
y: point.x * sin + point.y * cos,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function visibleFloorplanViewWidth(
|
|
39
|
+
viewBox: FloorplanViewBox,
|
|
40
|
+
viewport: FloorplanPreviewViewportSize,
|
|
41
|
+
) {
|
|
42
|
+
const aspect = Math.max(viewport.width, 1) / Math.max(viewport.height, 1)
|
|
43
|
+
return Math.max(viewBox.width, viewBox.height * aspect)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function floorplanViewBoxFromNavigationPose(
|
|
47
|
+
pose: NavigationSyncPose,
|
|
48
|
+
localCenter: { x: number; y: number },
|
|
49
|
+
sceneRotationDeg: number,
|
|
50
|
+
viewport: FloorplanPreviewViewportSize,
|
|
51
|
+
): FloorplanViewBox {
|
|
52
|
+
const center = rotateFloorplanPoint(localCenter, sceneRotationDeg)
|
|
53
|
+
const aspect = Math.max(viewport.width, 1) / Math.max(viewport.height, 1)
|
|
54
|
+
const width = Math.max(pose.viewWidth, 0.001)
|
|
55
|
+
const height = width / aspect
|
|
56
|
+
return {
|
|
57
|
+
x: center.x - width / 2,
|
|
58
|
+
y: center.y - height / 2,
|
|
59
|
+
width,
|
|
60
|
+
height,
|
|
61
|
+
}
|
|
62
|
+
}
|