@pascal-app/editor 0.8.0 → 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 +15 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +795 -28
- package/src/components/editor/editor-layout-mobile.tsx +8 -3
- package/src/components/editor/editor-layout-v2.tsx +76 -51
- package/src/components/editor/export-manager.tsx +69 -32
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
- package/src/components/editor/first-person/build-collider-world.ts +228 -83
- package/src/components/editor/first-person-controls.tsx +943 -27
- package/src/components/editor/floating-action-menu.tsx +676 -208
- package/src/components/editor/floating-building-action-menu.tsx +2 -2
- 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 +6916 -12137
- package/src/components/editor/grid.tsx +197 -36
- 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 +420 -0
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +396 -0
- package/src/components/editor/group-transform-shared.ts +489 -0
- package/src/components/editor/handles/handle-arrow.tsx +581 -0
- package/src/components/editor/handles/use-handle-drag.ts +246 -0
- package/src/components/editor/index.tsx +412 -162
- package/src/components/editor/measurement-pill.tsx +115 -0
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +1646 -0
- 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 +1106 -483
- package/src/components/editor/site-edge-labels.tsx +45 -21
- package/src/components/editor/slab-hole-highlights.tsx +474 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +140 -41
- package/src/components/editor/use-floorplan-background-placement.ts +190 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +19 -7
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +14 -19
- package/src/components/editor/wall-move-side-handles.tsx +870 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- 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 +392 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- 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-measurements-layer.tsx +108 -66
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
- package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
- package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/stair/stair-edit-system.tsx +1 -1
- 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-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +375 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
- package/src/components/tools/fence/fence-drafting.ts +141 -27
- package/src/components/tools/item/move-tool.tsx +45 -105
- package/src/components/tools/item/placement-math.test.ts +22 -0
- package/src/components/tools/item/placement-math.ts +59 -7
- package/src/components/tools/item/placement-strategies.ts +432 -16
- package/src/components/tools/item/placement-types.ts +22 -1
- package/src/components/tools/item/use-draft-node.ts +51 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
- package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
- package/src/components/tools/roof/roof-tool.tsx +350 -28
- package/src/components/tools/select/box-select-state.ts +88 -0
- package/src/components/tools/select/box-select-tool.tsx +442 -520
- 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 +570 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
- 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/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +305 -0
- package/src/components/tools/shared/polygon-editor.tsx +866 -175
- package/src/components/tools/shared/segment-angle.ts +56 -0
- package/src/components/tools/site/site-boundary-editor.tsx +520 -16
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +2 -1
- package/src/components/tools/stair/stair-tool.tsx +342 -37
- package/src/components/tools/tool-manager.tsx +239 -62
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +248 -171
- package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +86 -82
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -170
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +3 -117
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +27 -93
- package/src/components/ui/action-menu/view-toggles.tsx +279 -90
- package/src/components/ui/command-palette/editor-commands.tsx +20 -15
- package/src/components/ui/command-palette/index.tsx +5 -3
- package/src/components/ui/controls/material-paint-panel.tsx +152 -0
- package/src/components/ui/controls/material-picker.tsx +87 -177
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +142 -46
- 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 +47 -10
- 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 +235 -23
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
- 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 +40 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
- package/src/components/ui/level-duplicate-dialog.tsx +2 -1
- package/src/components/ui/panels/node-display.ts +17 -16
- package/src/components/ui/panels/panel-manager.tsx +55 -58
- package/src/components/ui/panels/panel-wrapper.tsx +238 -12
- package/src/components/ui/panels/parametric-inspector.tsx +482 -0
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- 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/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
- 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 +92 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
- 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 +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -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 +112 -39
- 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/panels/zone-panel/index.tsx +51 -2
- package/src/components/ui/sidebar/tab-bar.tsx +75 -1
- 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 +253 -61
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +64 -10
- package/src/hooks/use-ceiling-events.ts +176 -0
- package/src/hooks/use-drag-action.ts +127 -0
- package/src/hooks/use-keyboard.ts +493 -95
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +451 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/constants.ts +6 -2
- 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 +50 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +117 -0
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +10 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +339 -0
- package/src/lib/fresh-planar-placement.ts +130 -0
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +11 -1
- 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-duplication.ts +54 -42
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +227 -48
- 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/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +100 -0
- package/src/lib/planar-cursor-placement.ts +42 -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 +7 -7
- 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 +332 -0
- package/src/lib/scene.ts +40 -8
- 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 +69 -10
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +148 -55
- package/src/lib/site-boundary.ts +1 -0
- 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/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- 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 +231 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +767 -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 +39 -0
- 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-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +34 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
- package/src/components/tools/fence/move-fence-tool.tsx +0 -302
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -364
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
- package/src/components/tools/wall/move-wall-tool.tsx +0 -358
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -249
- package/src/components/ui/panels/column-panel.tsx +0 -759
- package/src/components/ui/panels/door-panel.tsx +0 -1299
- package/src/components/ui/panels/fence-panel.tsx +0 -222
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -280
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -161
- package/src/components/ui/panels/stair-panel.tsx +0 -577
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -273
- package/src/components/ui/panels/window-panel.tsx +0 -968
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
4
6
|
type CeilingNode,
|
|
7
|
+
type ChimneyMaterialRole,
|
|
8
|
+
type ChimneyNode,
|
|
5
9
|
type ColumnNode,
|
|
10
|
+
type DormerSurfaceMaterialRole,
|
|
6
11
|
type FenceNode,
|
|
7
12
|
getCatalogMaterialById,
|
|
8
13
|
getEffectiveRoofSurfaceMaterial,
|
|
14
|
+
getEffectiveSegmentSurfaceMaterial,
|
|
9
15
|
getEffectiveStairSurfaceMaterial,
|
|
10
|
-
getEffectiveWallSurfaceMaterial,
|
|
11
16
|
getLibraryMaterialIdFromRef,
|
|
12
17
|
type MaterialSchema,
|
|
13
18
|
type MaterialTarget,
|
|
19
|
+
nodeRegistry,
|
|
14
20
|
type RoofNode,
|
|
21
|
+
type RoofSegmentNode,
|
|
22
|
+
type RoofSegmentSurfaceMaterialRole,
|
|
15
23
|
type RoofSurfaceMaterialRole,
|
|
24
|
+
type ShelfNode,
|
|
16
25
|
type SlabNode,
|
|
17
26
|
type StairNode,
|
|
18
27
|
type StairSurfaceMaterialRole,
|
|
19
|
-
type WallNode,
|
|
20
28
|
type WallSurfaceSide,
|
|
21
29
|
} from '@pascal-app/core'
|
|
22
30
|
|
|
23
|
-
export type PaintableMaterialTarget =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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'
|
|
27
52
|
|
|
28
53
|
export type SingleSurfaceMaterialRole = 'surface'
|
|
29
54
|
|
|
@@ -52,45 +77,51 @@ export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null
|
|
|
52
77
|
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
export function
|
|
56
|
-
node:
|
|
57
|
-
|
|
80
|
+
export function buildRoofSurfaceMaterialPatch(
|
|
81
|
+
node: RoofNode,
|
|
82
|
+
targetRole: RoofSurfaceMaterialRole,
|
|
58
83
|
material: MaterialSchema | undefined,
|
|
59
84
|
materialPreset: string | undefined,
|
|
60
|
-
): Partial<
|
|
85
|
+
): Partial<RoofNode> {
|
|
61
86
|
const nextSurfaceMaterial = { material, materialPreset }
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
? nextSurfaceMaterial
|
|
69
|
-
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
|
87
|
+
const nextTop =
|
|
88
|
+
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
|
|
89
|
+
const nextEdge =
|
|
90
|
+
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
|
|
91
|
+
const nextWall =
|
|
92
|
+
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
|
|
70
93
|
|
|
71
94
|
return {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
topMaterial: nextTop.material,
|
|
96
|
+
topMaterialPreset: nextTop.materialPreset,
|
|
97
|
+
edgeMaterial: nextEdge.material,
|
|
98
|
+
edgeMaterialPreset: nextEdge.materialPreset,
|
|
99
|
+
wallMaterial: nextWall.material,
|
|
100
|
+
wallMaterialPreset: nextWall.materialPreset,
|
|
76
101
|
material: undefined,
|
|
77
102
|
materialPreset: undefined,
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Build a per-segment paint patch for one of the three surface roles. The
|
|
108
|
+
* segment ends up with role-specific fields set (and the legacy catch-all
|
|
109
|
+
* `material` cleared) so subsequent reads pick the role override over any
|
|
110
|
+
* parent-roof fallback.
|
|
111
|
+
*/
|
|
112
|
+
export function buildRoofSegmentSurfaceMaterialPatch(
|
|
113
|
+
node: RoofSegmentNode,
|
|
114
|
+
targetRole: RoofSegmentSurfaceMaterialRole,
|
|
84
115
|
material: MaterialSchema | undefined,
|
|
85
116
|
materialPreset: string | undefined,
|
|
86
|
-
): Partial<
|
|
117
|
+
): Partial<RoofSegmentNode> {
|
|
87
118
|
const nextSurfaceMaterial = { material, materialPreset }
|
|
88
119
|
const nextTop =
|
|
89
|
-
targetRole === 'top' ? nextSurfaceMaterial :
|
|
120
|
+
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'top')
|
|
90
121
|
const nextEdge =
|
|
91
|
-
targetRole === 'edge' ? nextSurfaceMaterial :
|
|
122
|
+
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'edge')
|
|
92
123
|
const nextWall =
|
|
93
|
-
targetRole === 'wall' ? nextSurfaceMaterial :
|
|
124
|
+
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'wall')
|
|
94
125
|
|
|
95
126
|
return {
|
|
96
127
|
topMaterial: nextTop.material,
|
|
@@ -104,6 +135,51 @@ export function buildRoofSurfaceMaterialPatch(
|
|
|
104
135
|
}
|
|
105
136
|
}
|
|
106
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Clear every painted material on a node back to its default. Works for any
|
|
140
|
+
* kind without per-type knowledge: it nulls the catch-all `material` /
|
|
141
|
+
* `materialPreset` plus any role field (`*Material` / `*MaterialPreset`) that
|
|
142
|
+
* the node actually carries. For a roof it also resets every child segment, so
|
|
143
|
+
* a single call defaults the whole roof system. `updateNode` merges patches
|
|
144
|
+
* shallowly without re-validation, so the `undefined` values land as cleared
|
|
145
|
+
* fields and the renderer falls back to the theme defaults.
|
|
146
|
+
*/
|
|
147
|
+
export function buildResetSurfaceMaterialUpdates(
|
|
148
|
+
nodes: Record<string, AnyNode>,
|
|
149
|
+
node: AnyNode,
|
|
150
|
+
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
|
|
151
|
+
const clearPatch = (target: AnyNode): Partial<AnyNode> => {
|
|
152
|
+
const patch: Record<string, undefined> = {}
|
|
153
|
+
for (const key of Object.keys(target)) {
|
|
154
|
+
if (
|
|
155
|
+
key === 'material' ||
|
|
156
|
+
key === 'materialPreset' ||
|
|
157
|
+
key === 'slots' ||
|
|
158
|
+
key.endsWith('Material') ||
|
|
159
|
+
key.endsWith('MaterialPreset')
|
|
160
|
+
) {
|
|
161
|
+
patch[key] = undefined
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return patch as Partial<AnyNode>
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
|
|
168
|
+
{ id: node.id as AnyNodeId, data: clearPatch(node) },
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
if (node.type === 'roof') {
|
|
172
|
+
for (const segmentId of (node as RoofNode).children ?? []) {
|
|
173
|
+
const segment = nodes[segmentId as AnyNodeId]
|
|
174
|
+
if (segment?.type === 'roof-segment') {
|
|
175
|
+
updates.push({ id: segment.id as AnyNodeId, data: clearPatch(segment) })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return updates
|
|
181
|
+
}
|
|
182
|
+
|
|
107
183
|
export function buildStairSurfaceMaterialPatch(
|
|
108
184
|
node: StairNode,
|
|
109
185
|
targetRole: StairSurfaceMaterialRole,
|
|
@@ -133,7 +209,7 @@ export function buildStairSurfaceMaterialPatch(
|
|
|
133
209
|
}
|
|
134
210
|
|
|
135
211
|
export function buildSingleSurfaceMaterialPatch<
|
|
136
|
-
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
|
|
212
|
+
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
|
137
213
|
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
|
138
214
|
return {
|
|
139
215
|
material,
|
|
@@ -141,6 +217,29 @@ export function buildSingleSurfaceMaterialPatch<
|
|
|
141
217
|
} as Partial<TNode>
|
|
142
218
|
}
|
|
143
219
|
|
|
220
|
+
// Chimney / dormer patch builders moved to
|
|
221
|
+
// `@pascal-app/nodes/<kind>/paint.ts` and are wired into the kind's
|
|
222
|
+
// `capabilities.paint.buildPatch`. The selection-manager invokes them
|
|
223
|
+
// through the registry; no editor-side helper needed here.
|
|
224
|
+
//
|
|
225
|
+
// `getEffectiveChimneyMaterial` below stays because
|
|
226
|
+
// `resolveActivePaintMaterialFromSelection` (also in this file) still
|
|
227
|
+
// has wall / roof / stair arms that follow the same shape — they all
|
|
228
|
+
// migrate together in a follow-up.
|
|
229
|
+
|
|
230
|
+
export function getEffectiveChimneyMaterial(
|
|
231
|
+
node: ChimneyNode,
|
|
232
|
+
role: ChimneyMaterialRole,
|
|
233
|
+
): { material: MaterialSchema | undefined; materialPreset: string | undefined } {
|
|
234
|
+
if (role === 'top') {
|
|
235
|
+
const hasTop = node.topMaterial !== undefined || node.topMaterialPreset !== undefined
|
|
236
|
+
if (hasTop) {
|
|
237
|
+
return { material: node.topMaterial, materialPreset: node.topMaterialPreset }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { material: node.material, materialPreset: node.materialPreset }
|
|
241
|
+
}
|
|
242
|
+
|
|
144
243
|
export function resolveActivePaintMaterialFromSelection(params: {
|
|
145
244
|
nodes: Record<string, any>
|
|
146
245
|
selectedId: string | null
|
|
@@ -150,7 +249,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
150
249
|
| WallSurfaceSide
|
|
151
250
|
| StairSurfaceMaterialRole
|
|
152
251
|
| RoofSurfaceMaterialRole
|
|
252
|
+
| ChimneyMaterialRole
|
|
253
|
+
| DormerSurfaceMaterialRole
|
|
153
254
|
| SingleSurfaceMaterialRole
|
|
255
|
+
| string
|
|
154
256
|
} | null
|
|
155
257
|
}): ActivePaintMaterial | null {
|
|
156
258
|
const { nodes, selectedId, selectedMaterialTarget } = params
|
|
@@ -160,22 +262,31 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
160
262
|
const selectedNode = nodes[selectedId]
|
|
161
263
|
if (!selectedNode) return null
|
|
162
264
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
265
|
+
// Registry-driven path. Kinds that declare
|
|
266
|
+
// `capabilities.paint.getEffectiveMaterial` resolve their effective
|
|
267
|
+
// material here without an editor-side per-kind arm. Wall,
|
|
268
|
+
// chimney, dormer use this; roof / stair stay legacy below.
|
|
269
|
+
const paintCap = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
|
|
270
|
+
if (paintCap?.getEffectiveMaterial) {
|
|
271
|
+
const surface = paintCap.getEffectiveMaterial({
|
|
272
|
+
node: selectedNode,
|
|
273
|
+
role: selectedMaterialTarget.role as string,
|
|
274
|
+
nodes,
|
|
172
275
|
})
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
276
|
+
if (surface) {
|
|
277
|
+
const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
|
|
278
|
+
return hasActivePaintMaterial({
|
|
279
|
+
material: surface.material,
|
|
280
|
+
materialPreset: surface.materialPreset,
|
|
281
|
+
sourceTarget,
|
|
282
|
+
})
|
|
283
|
+
? {
|
|
284
|
+
material: surface.material,
|
|
285
|
+
materialPreset: surface.materialPreset,
|
|
286
|
+
sourceTarget,
|
|
287
|
+
}
|
|
288
|
+
: null
|
|
289
|
+
}
|
|
179
290
|
}
|
|
180
291
|
|
|
181
292
|
if (
|
|
@@ -184,7 +295,27 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
184
295
|
selectedMaterialTarget.role === 'edge' ||
|
|
185
296
|
selectedMaterialTarget.role === 'wall')
|
|
186
297
|
) {
|
|
187
|
-
|
|
298
|
+
let surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
|
299
|
+
if (
|
|
300
|
+
selectedMaterialTarget.role === 'top' &&
|
|
301
|
+
surface.material === undefined &&
|
|
302
|
+
surface.materialPreset === undefined
|
|
303
|
+
) {
|
|
304
|
+
const roofNode = selectedNode as RoofNode
|
|
305
|
+
const fallbackSegment = (roofNode.children ?? [])
|
|
306
|
+
.map((id: AnyNodeId) => nodes[id as AnyNodeId] as RoofSegmentNode | undefined)
|
|
307
|
+
.find(
|
|
308
|
+
(segment: RoofSegmentNode | undefined) =>
|
|
309
|
+
segment?.type === 'roof-segment' &&
|
|
310
|
+
(segment.material !== undefined || segment.materialPreset !== undefined),
|
|
311
|
+
)
|
|
312
|
+
if (fallbackSegment) {
|
|
313
|
+
surface = {
|
|
314
|
+
material: fallbackSegment.material,
|
|
315
|
+
materialPreset: fallbackSegment.materialPreset,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
188
319
|
return hasActivePaintMaterial({
|
|
189
320
|
material: surface.material,
|
|
190
321
|
materialPreset: surface.materialPreset,
|
|
@@ -218,13 +349,21 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|
|
218
349
|
: null
|
|
219
350
|
}
|
|
220
351
|
|
|
352
|
+
// Wall / chimney / dormer flow through the registry-driven path
|
|
353
|
+
// at the top of this function.
|
|
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
|
+
|
|
221
359
|
if (
|
|
222
360
|
(selectedNode.type === 'fence' ||
|
|
223
361
|
selectedNode.type === 'column' ||
|
|
224
|
-
selectedNode.type === '
|
|
225
|
-
selectedNode.type === 'ceiling') &&
|
|
362
|
+
selectedNode.type === 'shelf') &&
|
|
226
363
|
selectedMaterialTarget.role === 'surface'
|
|
227
364
|
) {
|
|
365
|
+
// Roof vents previously lived here too; they now resolve via the
|
|
366
|
+
// registry-driven `getEffectiveMaterial` path at the top of this function.
|
|
228
367
|
const target = selectedNode.type
|
|
229
368
|
return hasActivePaintMaterial({
|
|
230
369
|
material: selectedNode.material,
|
|
@@ -252,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: {
|
|
|
252
391
|
const selectedNode = nodes[selectedId]
|
|
253
392
|
if (!selectedNode) return null
|
|
254
393
|
|
|
394
|
+
const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
|
|
395
|
+
?.materialTarget
|
|
396
|
+
if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget
|
|
397
|
+
|
|
255
398
|
if (selectedNode.type === 'wall') {
|
|
256
399
|
return 'wall'
|
|
257
400
|
}
|
|
@@ -280,5 +423,41 @@ export function resolvePaintTargetFromSelection(params: {
|
|
|
280
423
|
return 'ceiling'
|
|
281
424
|
}
|
|
282
425
|
|
|
426
|
+
if (selectedNode.type === 'shelf') {
|
|
427
|
+
return 'shelf'
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (selectedNode.type === 'item') {
|
|
431
|
+
return 'item'
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (selectedNode.type === 'chimney') {
|
|
435
|
+
return 'chimney'
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (selectedNode.type === 'dormer') {
|
|
439
|
+
return 'dormer'
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (selectedNode.type === 'box-vent') {
|
|
443
|
+
return 'box-vent'
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (selectedNode.type === 'ridge-vent') {
|
|
447
|
+
return 'ridge-vent'
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (selectedNode.type === 'turbine-vent') {
|
|
451
|
+
return 'turbine-vent'
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (selectedNode.type === 'cupola') {
|
|
455
|
+
return 'cupola'
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (selectedNode.type === 'eyebrow-vent') {
|
|
459
|
+
return 'eyebrow-vent'
|
|
460
|
+
}
|
|
461
|
+
|
|
283
462
|
return null
|
|
284
463
|
}
|
|
@@ -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
|
+
}
|