@pascal-app/editor 0.9.1 → 0.9.2
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/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
getEffectiveRoofSurfaceMaterial,
|
|
14
14
|
getEffectiveSegmentSurfaceMaterial,
|
|
15
15
|
getEffectiveStairSurfaceMaterial,
|
|
16
|
-
getEffectiveWallSurfaceMaterial,
|
|
17
16
|
getLibraryMaterialIdFromRef,
|
|
18
17
|
type MaterialSchema,
|
|
19
18
|
type MaterialTarget,
|
|
@@ -26,28 +25,30 @@ import {
|
|
|
26
25
|
type SlabNode,
|
|
27
26
|
type StairNode,
|
|
28
27
|
type StairSurfaceMaterialRole,
|
|
29
|
-
type WallNode,
|
|
30
28
|
type WallSurfaceSide,
|
|
31
29
|
} from '@pascal-app/core'
|
|
32
30
|
|
|
33
|
-
export type PaintableMaterialTarget =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
31
|
+
export type PaintableMaterialTarget =
|
|
32
|
+
| Extract<
|
|
33
|
+
MaterialTarget,
|
|
34
|
+
| 'wall'
|
|
35
|
+
| 'roof'
|
|
36
|
+
| 'stair'
|
|
37
|
+
| 'fence'
|
|
38
|
+
| 'column'
|
|
39
|
+
| 'slab'
|
|
40
|
+
| 'ceiling'
|
|
41
|
+
| 'shelf'
|
|
42
|
+
| 'cabinet'
|
|
43
|
+
| 'chimney'
|
|
44
|
+
| 'dormer'
|
|
45
|
+
| 'box-vent'
|
|
46
|
+
| 'ridge-vent'
|
|
47
|
+
| 'turbine-vent'
|
|
48
|
+
| 'cupola'
|
|
49
|
+
| 'eyebrow-vent'
|
|
50
|
+
>
|
|
51
|
+
| 'item'
|
|
51
52
|
|
|
52
53
|
export type SingleSurfaceMaterialRole = 'surface'
|
|
53
54
|
|
|
@@ -76,32 +77,6 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null
|
|
|
76
77
|
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
export function buildWallSurfaceMaterialPatch(
|
|
80
|
-
node: WallNode,
|
|
81
|
-
targetSide: WallSurfaceSide,
|
|
82
|
-
material: MaterialSchema | undefined,
|
|
83
|
-
materialPreset: string | undefined,
|
|
84
|
-
): Partial<WallNode> {
|
|
85
|
-
const nextSurfaceMaterial = { material, materialPreset }
|
|
86
|
-
const nextInterior =
|
|
87
|
-
targetSide === 'interior'
|
|
88
|
-
? nextSurfaceMaterial
|
|
89
|
-
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
|
90
|
-
const nextExterior =
|
|
91
|
-
targetSide === 'exterior'
|
|
92
|
-
? nextSurfaceMaterial
|
|
93
|
-
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
interiorMaterial: nextInterior.material,
|
|
97
|
-
interiorMaterialPreset: nextInterior.materialPreset,
|
|
98
|
-
exteriorMaterial: nextExterior.material,
|
|
99
|
-
exteriorMaterialPreset: nextExterior.materialPreset,
|
|
100
|
-
material: undefined,
|
|
101
|
-
materialPreset: undefined,
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
80
|
export function buildRoofSurfaceMaterialPatch(
|
|
106
81
|
node: RoofNode,
|
|
107
82
|
targetRole: RoofSurfaceMaterialRole,
|
|
@@ -179,6 +154,7 @@ export function buildResetSurfaceMaterialUpdates(
|
|
|
179
154
|
if (
|
|
180
155
|
key === 'material' ||
|
|
181
156
|
key === 'materialPreset' ||
|
|
157
|
+
key === 'slots' ||
|
|
182
158
|
key.endsWith('Material') ||
|
|
183
159
|
key.endsWith('MaterialPreset')
|
|
184
160
|
) {
|
|
@@ -276,6 +252,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
276
252
|
| ChimneyMaterialRole
|
|
277
253
|
| DormerSurfaceMaterialRole
|
|
278
254
|
| SingleSurfaceMaterialRole
|
|
255
|
+
| string
|
|
279
256
|
} | null
|
|
280
257
|
}): ActivePaintMaterial | null {
|
|
281
258
|
const { nodes, selectedId, selectedMaterialTarget } = params
|
|
@@ -297,7 +274,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
297
274
|
nodes,
|
|
298
275
|
})
|
|
299
276
|
if (surface) {
|
|
300
|
-
const sourceTarget = selectedNode.type as PaintableMaterialTarget
|
|
277
|
+
const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
|
|
301
278
|
return hasActivePaintMaterial({
|
|
302
279
|
material: surface.material,
|
|
303
280
|
materialPreset: surface.materialPreset,
|
|
@@ -375,11 +352,13 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
375
352
|
// Wall / chimney / dormer flow through the registry-driven path
|
|
376
353
|
// at the top of this function.
|
|
377
354
|
|
|
355
|
+
// Slot-backed kinds resolve via the registry-driven `getEffectiveMaterial`
|
|
356
|
+
// path at the top of this function, including legacy inline-material
|
|
357
|
+
// fallbacks when their capability exposes one.
|
|
358
|
+
|
|
378
359
|
if (
|
|
379
360
|
(selectedNode.type === 'fence' ||
|
|
380
361
|
selectedNode.type === 'column' ||
|
|
381
|
-
selectedNode.type === 'slab' ||
|
|
382
|
-
selectedNode.type === 'ceiling' ||
|
|
383
362
|
selectedNode.type === 'shelf') &&
|
|
384
363
|
selectedMaterialTarget.role === 'surface'
|
|
385
364
|
) {
|
|
@@ -412,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: {
|
|
|
412
391
|
const selectedNode = nodes[selectedId]
|
|
413
392
|
if (!selectedNode) return null
|
|
414
393
|
|
|
394
|
+
const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
|
|
395
|
+
?.materialTarget
|
|
396
|
+
if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget
|
|
397
|
+
|
|
415
398
|
if (selectedNode.type === 'wall') {
|
|
416
399
|
return 'wall'
|
|
417
400
|
}
|
|
@@ -444,6 +427,10 @@ export function resolvePaintTargetFromSelection(params: {
|
|
|
444
427
|
return 'shelf'
|
|
445
428
|
}
|
|
446
429
|
|
|
430
|
+
if (selectedNode.type === 'item') {
|
|
431
|
+
return 'item'
|
|
432
|
+
}
|
|
433
|
+
|
|
447
434
|
if (selectedNode.type === 'chimney') {
|
|
448
435
|
return 'chimney'
|
|
449
436
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
CREATABLE_MEASUREMENT_KINDS,
|
|
4
|
+
DEFAULT_CREATABLE_MEASUREMENT_KIND,
|
|
5
|
+
normalizeCreatableMeasurementKind,
|
|
6
|
+
} from './measurement-kind'
|
|
7
|
+
|
|
8
|
+
describe('creatable measurement kinds', () => {
|
|
9
|
+
test('keeps the supported creation kinds', () => {
|
|
10
|
+
expect(CREATABLE_MEASUREMENT_KINDS).toEqual([
|
|
11
|
+
'distance',
|
|
12
|
+
'angle',
|
|
13
|
+
'area',
|
|
14
|
+
'perimeter',
|
|
15
|
+
'volume',
|
|
16
|
+
])
|
|
17
|
+
for (const kind of CREATABLE_MEASUREMENT_KINDS) {
|
|
18
|
+
expect(normalizeCreatableMeasurementKind(kind)).toBe(kind)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test.each([
|
|
23
|
+
'smart',
|
|
24
|
+
'unknown',
|
|
25
|
+
null,
|
|
26
|
+
undefined,
|
|
27
|
+
])('falls back from a non-creatable persisted value: %s', (value) => {
|
|
28
|
+
expect(normalizeCreatableMeasurementKind(value)).toBe(DEFAULT_CREATABLE_MEASUREMENT_KIND)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const CREATABLE_MEASUREMENT_KINDS = [
|
|
2
|
+
'distance',
|
|
3
|
+
'angle',
|
|
4
|
+
'area',
|
|
5
|
+
'perimeter',
|
|
6
|
+
'volume',
|
|
7
|
+
] as const
|
|
8
|
+
|
|
9
|
+
export type CreatableMeasurementKind = (typeof CREATABLE_MEASUREMENT_KINDS)[number]
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_CREATABLE_MEASUREMENT_KIND: CreatableMeasurementKind = 'distance'
|
|
12
|
+
|
|
13
|
+
export function isCreatableMeasurementKind(value: unknown): value is CreatableMeasurementKind {
|
|
14
|
+
return CREATABLE_MEASUREMENT_KINDS.includes(value as CreatableMeasurementKind)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeCreatableMeasurementKind(value: unknown): CreatableMeasurementKind {
|
|
18
|
+
return isCreatableMeasurementKind(value) ? value : DEFAULT_CREATABLE_MEASUREMENT_KIND
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { measurementCentroid, pointInPolygon2D } from '@pascal-app/core'
|
|
3
|
+
import { measurementPolygonLabelAnchor, triangulateMeasurementPolygon } from './measurement-label'
|
|
4
|
+
|
|
5
|
+
describe('measurementPolygonLabelAnchor', () => {
|
|
6
|
+
test('keeps a concave polygon label inside its visible fill', () => {
|
|
7
|
+
const base = [
|
|
8
|
+
[0, 0, 0],
|
|
9
|
+
[4, 0, 0],
|
|
10
|
+
[4, 0, 1],
|
|
11
|
+
[1, 0, 1],
|
|
12
|
+
[1, 0, 4],
|
|
13
|
+
[0, 0, 4],
|
|
14
|
+
] as [number, number, number][]
|
|
15
|
+
const centroid = measurementCentroid(base)
|
|
16
|
+
const anchor = measurementPolygonLabelAnchor(base)
|
|
17
|
+
|
|
18
|
+
expect(centroid).not.toBeNull()
|
|
19
|
+
expect(
|
|
20
|
+
pointInPolygon2D(
|
|
21
|
+
[centroid![0], centroid![2]],
|
|
22
|
+
base.map(([x, , z]) => [x, z]),
|
|
23
|
+
),
|
|
24
|
+
).toBe(false)
|
|
25
|
+
expect(anchor).not.toBeNull()
|
|
26
|
+
expect(
|
|
27
|
+
pointInPolygon2D(
|
|
28
|
+
[anchor![0], anchor![2]],
|
|
29
|
+
base.map(([x, , z]) => [x, z]),
|
|
30
|
+
),
|
|
31
|
+
).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('returns an on-plane anchor for a sloped polygon', () => {
|
|
35
|
+
const base = [
|
|
36
|
+
[0, 0, 0],
|
|
37
|
+
[4, 2, 0],
|
|
38
|
+
[4, 2, 4],
|
|
39
|
+
[0, 0, 4],
|
|
40
|
+
] as [number, number, number][]
|
|
41
|
+
const anchor = measurementPolygonLabelAnchor(base)
|
|
42
|
+
|
|
43
|
+
expect(triangulateMeasurementPolygon(base)).toHaveLength(2)
|
|
44
|
+
expect(anchor).not.toBeNull()
|
|
45
|
+
expect(anchor![1]).toBeCloseTo(anchor![0] / 2)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type MeasurementPoint, measurementCentroid, measurementNormal } from '@pascal-app/core'
|
|
2
|
+
import { ShapeUtils, Vector2, Vector3 } from 'three'
|
|
3
|
+
|
|
4
|
+
type MeasurementPolygonProjection = {
|
|
5
|
+
contour: Vector2[]
|
|
6
|
+
triangles: number[][]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function projectMeasurementPolygon(
|
|
10
|
+
points: readonly MeasurementPoint[],
|
|
11
|
+
): MeasurementPolygonProjection | null {
|
|
12
|
+
const normalValue = measurementNormal(points)
|
|
13
|
+
const originValue = points[0]
|
|
14
|
+
if (!(normalValue && originValue)) return null
|
|
15
|
+
|
|
16
|
+
const normal = new Vector3(...normalValue)
|
|
17
|
+
const reference = Math.abs(normal.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
|
|
18
|
+
const tangent = new Vector3().crossVectors(reference, normal).normalize()
|
|
19
|
+
const bitangent = new Vector3().crossVectors(normal, tangent).normalize()
|
|
20
|
+
const origin = new Vector3(...originValue)
|
|
21
|
+
const contour = points.map((point) => {
|
|
22
|
+
const relative = new Vector3(...point).sub(origin)
|
|
23
|
+
return new Vector2(relative.dot(tangent), relative.dot(bitangent))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return { contour, triangles: ShapeUtils.triangulateShape(contour, []) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function triangulateMeasurementPolygon(points: readonly MeasurementPoint[]): number[][] {
|
|
30
|
+
return projectMeasurementPolygon(points)?.triangles ?? []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function measurementPolygonLabelAnchor(
|
|
34
|
+
points: readonly MeasurementPoint[],
|
|
35
|
+
): MeasurementPoint | null {
|
|
36
|
+
const projection = projectMeasurementPolygon(points)
|
|
37
|
+
if (!projection || projection.triangles.length === 0) return measurementCentroid(points)
|
|
38
|
+
|
|
39
|
+
let largestTriangle: number[] | null = null
|
|
40
|
+
let largestArea = Number.NEGATIVE_INFINITY
|
|
41
|
+
for (const triangle of projection.triangles) {
|
|
42
|
+
const [firstIndex, secondIndex, thirdIndex] = triangle
|
|
43
|
+
const first = firstIndex === undefined ? null : projection.contour[firstIndex]
|
|
44
|
+
const second = secondIndex === undefined ? null : projection.contour[secondIndex]
|
|
45
|
+
const third = thirdIndex === undefined ? null : projection.contour[thirdIndex]
|
|
46
|
+
if (!(first && second && third)) continue
|
|
47
|
+
const area = Math.abs(
|
|
48
|
+
(second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x),
|
|
49
|
+
)
|
|
50
|
+
if (area > largestArea) {
|
|
51
|
+
largestArea = area
|
|
52
|
+
largestTriangle = triangle
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!largestTriangle) return measurementCentroid(points)
|
|
57
|
+
const [firstIndex, secondIndex, thirdIndex] = largestTriangle
|
|
58
|
+
const first = firstIndex === undefined ? null : points[firstIndex]
|
|
59
|
+
const second = secondIndex === undefined ? null : points[secondIndex]
|
|
60
|
+
const third = thirdIndex === undefined ? null : points[thirdIndex]
|
|
61
|
+
if (!(first && second && third)) return measurementCentroid(points)
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
(first[0] + second[0] + third[0]) / 3,
|
|
65
|
+
(first[1] + second[1] + third[1]) / 3,
|
|
66
|
+
(first[2] + second[2] + third[2]) / 3,
|
|
67
|
+
]
|
|
68
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type Kind, parseQuantity, quantity } from '@pascal-app/lingo'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Natural-language measurement parsing for editor property fields, backed by
|
|
5
|
+
* `@pascal-app/lingo`. Lets a user type `6ft`, `180cm`, `1m80`, `5'11"`, `45°`
|
|
6
|
+
* or `1.57rad` into any measurement field and have it canonicalized to the
|
|
7
|
+
* unit the field stores its value in — independent of the metric/imperial
|
|
8
|
+
* display toggle.
|
|
9
|
+
*
|
|
10
|
+
* The editor stores linear values in meters and angular values in radians (a
|
|
11
|
+
* few fields store inches or degrees); lingo's `length` base is meters and its
|
|
12
|
+
* `angle` base is radians, so a field's `unit` prop already names the stored /
|
|
13
|
+
* canonical unit and doubles as the parse target.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface LingoUnitSpec {
|
|
17
|
+
kind: Kind
|
|
18
|
+
/** Unit id the field's stored `value` is expressed in — the parse target. */
|
|
19
|
+
unitId: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map an editor field `unit` prop to a lingo kind + canonical unit. Returns
|
|
24
|
+
* `null` for units we deliberately do NOT natural-language-parse (plain
|
|
25
|
+
* numbers, percentages, angular rates, counts) so those keep exact
|
|
26
|
+
* `Number.parseFloat` behavior.
|
|
27
|
+
*/
|
|
28
|
+
export function lingoUnitSpec(unit: string | undefined): LingoUnitSpec | null {
|
|
29
|
+
switch (unit) {
|
|
30
|
+
case 'm':
|
|
31
|
+
case 'cm':
|
|
32
|
+
case 'mm':
|
|
33
|
+
case 'in':
|
|
34
|
+
case 'ft':
|
|
35
|
+
return { kind: 'length', unitId: unit }
|
|
36
|
+
case '°':
|
|
37
|
+
case 'deg':
|
|
38
|
+
case 'degrees':
|
|
39
|
+
return { kind: 'angle', unitId: 'deg' }
|
|
40
|
+
case 'rad':
|
|
41
|
+
case 'radians':
|
|
42
|
+
return { kind: 'angle', unitId: 'rad' }
|
|
43
|
+
default:
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ParseMeasurementOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Implied unit for a BARE number (e.g. `6` → `6 ft`). Defaults to the field's
|
|
51
|
+
* own `unitId`. A typed unit (`180cm`) is always honored regardless.
|
|
52
|
+
*/
|
|
53
|
+
bareUnit?: string
|
|
54
|
+
/** Disambiguates gal/ton/cup families; harmless for length/angle. */
|
|
55
|
+
system?: 'metric' | 'us' | 'imperial'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse typed field text into the field's stored numeric unit. Returns `null`
|
|
60
|
+
* when the text can't be read as a quantity, so the caller can fall back to a
|
|
61
|
+
* plain number parse or revert to the previous value.
|
|
62
|
+
*/
|
|
63
|
+
export function parseMeasurement(
|
|
64
|
+
raw: string,
|
|
65
|
+
spec: LingoUnitSpec,
|
|
66
|
+
options: ParseMeasurementOptions = {},
|
|
67
|
+
): number | null {
|
|
68
|
+
const result = parseQuantity(raw, {
|
|
69
|
+
kind: spec.kind,
|
|
70
|
+
unit: options.bareUnit ?? spec.unitId,
|
|
71
|
+
system: options.system,
|
|
72
|
+
strictness: 'forgiving',
|
|
73
|
+
})
|
|
74
|
+
if (!result.ok) return null
|
|
75
|
+
const value = result.quantity.to(spec.unitId).value
|
|
76
|
+
return Number.isFinite(value) ? value : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MeasurementHintOptions extends ParseMeasurementOptions {
|
|
80
|
+
/** Unit id the preview is rendered in (the field's displayed unit). */
|
|
81
|
+
displayUnit?: string
|
|
82
|
+
/** Max fraction digits in the preview. */
|
|
83
|
+
precision?: number
|
|
84
|
+
/**
|
|
85
|
+
* Clamp applied to the parsed stored value before previewing, so the hint
|
|
86
|
+
* reflects what a commit would actually store on a bounded field.
|
|
87
|
+
*/
|
|
88
|
+
clamp?: (stored: number) => number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const PLAIN_NUMBER = /^[+-]?\d*\.?\d*$/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A faint "= 1.83 m" preview of the value currently being typed, shown only
|
|
95
|
+
* when the user typed something beyond a plain decimal in the field's own unit
|
|
96
|
+
* (an explicit unit, a compound like `1m80`, or a number word) and it parses.
|
|
97
|
+
* Returns `null` when there's nothing useful to preview.
|
|
98
|
+
*/
|
|
99
|
+
export function measurementHint(
|
|
100
|
+
raw: string,
|
|
101
|
+
spec: LingoUnitSpec,
|
|
102
|
+
options: MeasurementHintOptions = {},
|
|
103
|
+
): string | null {
|
|
104
|
+
const trimmed = raw.trim()
|
|
105
|
+
if (!trimmed || PLAIN_NUMBER.test(trimmed)) return null
|
|
106
|
+
|
|
107
|
+
const parsed = parseMeasurement(trimmed, spec, options)
|
|
108
|
+
if (parsed === null) return null
|
|
109
|
+
const stored = options.clamp ? options.clamp(parsed) : parsed
|
|
110
|
+
|
|
111
|
+
const displayUnit = options.displayUnit ?? spec.unitId
|
|
112
|
+
const precision = options.precision ?? 2
|
|
113
|
+
const q = quantity(stored, spec.unitId)
|
|
114
|
+
const shown = displayUnit === spec.unitId ? q : q.to(displayUnit)
|
|
115
|
+
return `= ${shown.format({ precision })}`
|
|
116
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
buildMeasurementAngleArcPoints,
|
|
4
|
+
cubicMetersToVolumeUnit,
|
|
5
|
+
formatAreaLabel,
|
|
6
|
+
formatLinearMeasurement,
|
|
7
|
+
formatVolumeLabel,
|
|
8
|
+
getAreaUnitLabel,
|
|
9
|
+
getLinearUnitLabel,
|
|
10
|
+
getVolumeUnitLabel,
|
|
11
|
+
linearControlValueToMeters,
|
|
12
|
+
linearUnitToMeters,
|
|
13
|
+
MEASUREMENT_ACTIVE_COLOR,
|
|
14
|
+
MEASUREMENT_DANGLING_COLOR,
|
|
15
|
+
MEASUREMENT_FLOORPLAN_COLOR,
|
|
16
|
+
MEASUREMENT_PERSISTENT_COLOR,
|
|
17
|
+
measurementFloorplanPresentationColor,
|
|
18
|
+
measurementPresentationColor,
|
|
19
|
+
metersToLinearUnit,
|
|
20
|
+
squareMetersToAreaUnit,
|
|
21
|
+
} from './measurements'
|
|
22
|
+
|
|
23
|
+
describe('measurement presentation', () => {
|
|
24
|
+
test('uses black at rest, indigo while active, and red for dangling references', () => {
|
|
25
|
+
expect(measurementPresentationColor(false, false)).toBe(MEASUREMENT_PERSISTENT_COLOR)
|
|
26
|
+
expect(measurementPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
|
|
27
|
+
expect(measurementPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
|
|
28
|
+
expect(measurementPresentationColor(true, true)).toBe(MEASUREMENT_DANGLING_COLOR)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('uses an indigo analysis color for resting 2D measurements', () => {
|
|
32
|
+
expect(measurementFloorplanPresentationColor(false, false)).toBe(MEASUREMENT_FLOORPLAN_COLOR)
|
|
33
|
+
expect(measurementFloorplanPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
|
|
34
|
+
expect(measurementFloorplanPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('angle arc presentation', () => {
|
|
39
|
+
test('samples the smaller angle from the first ray to the second', () => {
|
|
40
|
+
const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 0, 1], {
|
|
41
|
+
radius: 0.25,
|
|
42
|
+
sampleCount: 8,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(arc).toHaveLength(9)
|
|
46
|
+
expect(arc[0]?.[0]).toBeCloseTo(0.25)
|
|
47
|
+
expect(arc[0]?.[2]).toBeCloseTo(0)
|
|
48
|
+
expect(arc.at(-1)?.[0]).toBeCloseTo(0)
|
|
49
|
+
expect(arc.at(-1)?.[2]).toBeCloseTo(0.25)
|
|
50
|
+
expect(arc[4]?.[0]).toBeGreaterThan(0)
|
|
51
|
+
expect(arc[4]?.[2]).toBeGreaterThan(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('keeps a constant radius on an arbitrary 3D angle plane', () => {
|
|
55
|
+
const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 1, 0], {
|
|
56
|
+
radius: 0.3,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(arc.length).toBeGreaterThan(4)
|
|
60
|
+
for (const point of arc) expect(Math.hypot(...point)).toBeCloseTo(0.3)
|
|
61
|
+
expect(arc.at(-1)?.[1]).toBeCloseTo(0.3)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('omits an arc when either ray is degenerate', () => {
|
|
65
|
+
expect(buildMeasurementAngleArcPoints([0, 0, 0], [0, 0, 0], [1, 0, 0])).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('linear measurements', () => {
|
|
70
|
+
test('formats metric measurements in meters', () => {
|
|
71
|
+
expect(formatLinearMeasurement(3, 'metric')).toBe('3m')
|
|
72
|
+
expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('formats imperial measurements as feet and inches', () => {
|
|
76
|
+
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
|
|
77
|
+
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('carries rounded 12 inches into the next foot', () => {
|
|
81
|
+
expect(formatLinearMeasurement(3.047, 'imperial')).toBe(`10'0"`)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('returns a placeholder for non-finite measurements', () => {
|
|
85
|
+
expect(formatLinearMeasurement(NaN, 'imperial')).toBe('--')
|
|
86
|
+
expect(formatLinearMeasurement(Infinity, 'imperial')).toBe('--')
|
|
87
|
+
expect(formatLinearMeasurement(NaN, 'metric')).toBe('--')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('formats zero measurements', () => {
|
|
91
|
+
expect(formatLinearMeasurement(0, 'imperial')).toBe(`0'0"`)
|
|
92
|
+
expect(formatLinearMeasurement(0, 'metric')).toBe('0m')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('formats sub-foot imperial measurements', () => {
|
|
96
|
+
expect(formatLinearMeasurement(0.1524, 'imperial')).toBe(`0'6"`)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('formats negative measurements with a sign', () => {
|
|
100
|
+
expect(formatLinearMeasurement(-0.1524, 'imperial')).toBe(`-0'6"`)
|
|
101
|
+
expect(formatLinearMeasurement(-0.1524, 'metric')).toBe('-0.15m')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('converts between meters and the active linear unit', () => {
|
|
105
|
+
expect(metersToLinearUnit(0, 'imperial')).toBe(0)
|
|
106
|
+
expect(linearUnitToMeters(0, 'imperial')).toBe(0)
|
|
107
|
+
|
|
108
|
+
expect(metersToLinearUnit(1, 'metric')).toBe(1)
|
|
109
|
+
expect(linearUnitToMeters(1, 'metric')).toBe(1)
|
|
110
|
+
|
|
111
|
+
expect(metersToLinearUnit(0.3048, 'imperial')).toBeCloseTo(1)
|
|
112
|
+
expect(linearUnitToMeters(1, 'imperial')).toBeCloseTo(0.3048)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('converts numeric control input back to meters for wall panel edits', () => {
|
|
116
|
+
expect(linearControlValueToMeters(10, 'imperial')).toBeCloseTo(3.048)
|
|
117
|
+
expect(linearControlValueToMeters(0.5, 'imperial')).toBeCloseTo(0.1524)
|
|
118
|
+
expect(linearControlValueToMeters(-1, 'imperial')).toBeCloseTo(-0.3048)
|
|
119
|
+
expect(linearControlValueToMeters(3.5, 'metric')).toBe(3.5)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('clamps numeric control input after converting to meters', () => {
|
|
123
|
+
expect(linearControlValueToMeters(0.1, 'imperial', { minMeters: 0.1 })).toBe(0.1)
|
|
124
|
+
expect(linearControlValueToMeters(0.3, 'imperial', { minMeters: 0.1 })).toBe(0.1)
|
|
125
|
+
expect(linearControlValueToMeters(19.7, 'imperial', { maxMeters: 6 })).toBe(6)
|
|
126
|
+
expect(linearControlValueToMeters(0.2, 'metric', { minMeters: 0.1 })).toBe(0.2)
|
|
127
|
+
expect(linearControlValueToMeters(0.2, 'metric', { maxMeters: 0.15 })).toBe(0.15)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('returns the display label for numeric controls', () => {
|
|
131
|
+
expect(getLinearUnitLabel('metric')).toBe('m')
|
|
132
|
+
expect(getLinearUnitLabel('imperial')).toBe('ft')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('area measurements', () => {
|
|
137
|
+
test('converts square meters to the active area unit', () => {
|
|
138
|
+
expect(squareMetersToAreaUnit(0, 'imperial')).toBe(0)
|
|
139
|
+
expect(squareMetersToAreaUnit(12.5, 'metric')).toBe(12.5)
|
|
140
|
+
expect(squareMetersToAreaUnit(1, 'imperial')).toBeCloseTo(10.7639)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('returns the display label for area readouts', () => {
|
|
144
|
+
expect(getAreaUnitLabel('metric')).toBe('m²')
|
|
145
|
+
expect(getAreaUnitLabel('imperial')).toBe('ft²')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('formats an area label with value and unit', () => {
|
|
149
|
+
expect(formatAreaLabel(12.34, 'metric')).toBe('12.3m²')
|
|
150
|
+
expect(formatAreaLabel(1, 'imperial')).toBe('10.8ft²')
|
|
151
|
+
expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('returns a placeholder for non-finite areas', () => {
|
|
155
|
+
expect(formatAreaLabel(NaN, 'metric')).toBe('--')
|
|
156
|
+
expect(formatAreaLabel(Infinity, 'imperial')).toBe('--')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('volume measurements', () => {
|
|
161
|
+
test('converts cubic meters to the active volume unit', () => {
|
|
162
|
+
expect(cubicMetersToVolumeUnit(0, 'imperial')).toBe(0)
|
|
163
|
+
expect(cubicMetersToVolumeUnit(12.5, 'metric')).toBe(12.5)
|
|
164
|
+
expect(cubicMetersToVolumeUnit(1, 'imperial')).toBeCloseTo(35.3147)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('returns the display label for volume readouts', () => {
|
|
168
|
+
expect(getVolumeUnitLabel('metric')).toBe('m³')
|
|
169
|
+
expect(getVolumeUnitLabel('imperial')).toBe('ft³')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('formats a volume label with value and unit', () => {
|
|
173
|
+
expect(formatVolumeLabel(12.34, 'metric')).toBe('12.3m³')
|
|
174
|
+
expect(formatVolumeLabel(1, 'imperial')).toBe('35.3ft³')
|
|
175
|
+
expect(formatVolumeLabel(12.34, 'metric', 2)).toBe('12.34m³')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('returns a placeholder for non-finite volumes', () => {
|
|
179
|
+
expect(formatVolumeLabel(NaN, 'metric')).toBe('--')
|
|
180
|
+
expect(formatVolumeLabel(Infinity, 'imperial')).toBe('--')
|
|
181
|
+
expect(formatVolumeLabel(Number.NEGATIVE_INFINITY, 'metric')).toBe('--')
|
|
182
|
+
})
|
|
183
|
+
})
|