@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
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
generateSceneMaterialId,
|
|
5
|
+
type ItemNode,
|
|
6
|
+
type MaterialSchema,
|
|
7
|
+
nodeRegistry,
|
|
8
|
+
pointInPolygon2D,
|
|
9
|
+
resolveLevelId,
|
|
10
|
+
type SceneMaterial,
|
|
11
|
+
type SceneMaterialId,
|
|
12
|
+
type SlabNode,
|
|
13
|
+
type Space,
|
|
14
|
+
slotLabelFromId,
|
|
15
|
+
toSceneMaterialRef,
|
|
16
|
+
useScene,
|
|
17
|
+
type WallNode,
|
|
18
|
+
} from '@pascal-app/core'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Painter application scope — how far one paint click spreads. The scope set is
|
|
22
|
+
* DERIVED from the hovered node, not a per-kind table: any slot-model node with
|
|
23
|
+
* more than one slot offers `object` (whole node); a node with an `asset` offers
|
|
24
|
+
* `matching` (every instance of that asset); a kind that declares
|
|
25
|
+
* `capabilities.paint.roomScope` offers `room`. One global mode (not per-tool),
|
|
26
|
+
* defaulting to the narrowest `'single'`; the active interaction's HUD shows +
|
|
27
|
+
* cycles it within the hovered node's set.
|
|
28
|
+
*/
|
|
29
|
+
export type PaintScope = 'single' | 'object' | 'matching' | 'room'
|
|
30
|
+
|
|
31
|
+
/** What the paint HUD needs to render + cycle the scope chip for a hover. */
|
|
32
|
+
export type PaintHoverInfo = {
|
|
33
|
+
/** The scopes available for the hovered node, in cycle order (always ≥ 1). */
|
|
34
|
+
scopes: PaintScope[]
|
|
35
|
+
/** Display name of the hovered slot — the label for the `'single'` scope. */
|
|
36
|
+
slotLabel: string
|
|
37
|
+
/** Kind noun for the `'object'` label (e.g. "Whole shelf"). */
|
|
38
|
+
nodeNoun: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function nodeHasAsset(node: AnyNode): boolean {
|
|
42
|
+
return Boolean((node as { asset?: { id?: string } }).asset?.id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nodeOffersRoomScope(node: AnyNode): boolean {
|
|
46
|
+
return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The scopes a hovered node offers, derived from the node itself: every node
|
|
51
|
+
* paints `single`; > 1 slot adds `object`; an `asset` adds `matching`; a
|
|
52
|
+
* `roomScope`-declaring kind adds `room`. `slotRoles` is the node's full slot set
|
|
53
|
+
* (declared or mesh-derived), passed in by the caller.
|
|
54
|
+
*/
|
|
55
|
+
export function availablePaintScopes(args: { node: AnyNode; slotRoles: string[] }): PaintScope[] {
|
|
56
|
+
const scopes: PaintScope[] = ['single']
|
|
57
|
+
if (args.slotRoles.length > 1) scopes.push('object')
|
|
58
|
+
if (nodeHasAsset(args.node)) scopes.push('matching')
|
|
59
|
+
if (nodeOffersRoomScope(args.node)) scopes.push('room')
|
|
60
|
+
return scopes
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintScope {
|
|
64
|
+
const list = scopes.length > 0 ? scopes : (['single'] as PaintScope[])
|
|
65
|
+
const index = list.indexOf(scope)
|
|
66
|
+
return list[(index + 1) % list.length] ?? 'single'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string {
|
|
70
|
+
switch (scope) {
|
|
71
|
+
case 'object':
|
|
72
|
+
return `Whole ${info.nodeNoun}`
|
|
73
|
+
case 'matching':
|
|
74
|
+
return 'All matching'
|
|
75
|
+
case 'room':
|
|
76
|
+
return 'Room'
|
|
77
|
+
default:
|
|
78
|
+
return info.slotLabel || 'This surface'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* All paintable slot roles of a node. Prefers the kind's declared
|
|
84
|
+
* `capabilities.slots` (node-authored, stable); falls back to the runtime mesh
|
|
85
|
+
* tags via the injected `meshSlotRoles` for kinds whose slots come from a GLB
|
|
86
|
+
* (items) rather than a declaration.
|
|
87
|
+
*/
|
|
88
|
+
export function nodeSlotRoles(node: AnyNode, meshSlotRoles: (node: AnyNode) => string[]): string[] {
|
|
89
|
+
const declared = nodeRegistry.get(node.type)?.capabilities?.slots?.(node)
|
|
90
|
+
if (declared && declared.length > 0) return declared.map((slot) => slot.slotId)
|
|
91
|
+
return meshSlotRoles(node)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Display label for the hovered slot — declared label wins, else derived from the id. */
|
|
95
|
+
export function slotDisplayLabel(node: AnyNode, role: string): string {
|
|
96
|
+
const declared = nodeRegistry
|
|
97
|
+
.get(node.type)
|
|
98
|
+
?.capabilities?.slots?.(node)
|
|
99
|
+
?.find((slot) => slot.slotId === role)
|
|
100
|
+
return declared?.label ?? slotLabelFromId(role)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Fan-out resolution ──────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
type SlotsNode = AnyNode & { slots?: Record<string, string> }
|
|
106
|
+
|
|
107
|
+
export type WallPaintHit = {
|
|
108
|
+
face: 'front' | 'back'
|
|
109
|
+
point: [number, number]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type WallBoundaryFace = Space['boundaryFaces'][number]
|
|
113
|
+
|
|
114
|
+
function distanceToSegment(
|
|
115
|
+
point: readonly [number, number],
|
|
116
|
+
start: readonly [number, number],
|
|
117
|
+
end: readonly [number, number],
|
|
118
|
+
): number {
|
|
119
|
+
const dx = end[0] - start[0]
|
|
120
|
+
const dz = end[1] - start[1]
|
|
121
|
+
const lengthSquared = dx * dx + dz * dz
|
|
122
|
+
if (lengthSquared < 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1])
|
|
123
|
+
const t = Math.max(
|
|
124
|
+
0,
|
|
125
|
+
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
|
|
126
|
+
)
|
|
127
|
+
return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dz * t))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function distanceToPolyline(
|
|
131
|
+
point: readonly [number, number],
|
|
132
|
+
points: ReadonlyArray<readonly [number, number]>,
|
|
133
|
+
): number {
|
|
134
|
+
let distance = Number.POSITIVE_INFINITY
|
|
135
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
136
|
+
const start = points[index]
|
|
137
|
+
const end = points[index + 1]
|
|
138
|
+
if (!(start && end)) continue
|
|
139
|
+
distance = Math.min(distance, distanceToSegment(point, start, end))
|
|
140
|
+
}
|
|
141
|
+
return distance
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function wallRoleForRoomFace(role: string, wall: WallNode, face: 'front' | 'back'): string | null {
|
|
145
|
+
const semantic = face === 'front' ? wall.frontSide : wall.backSide
|
|
146
|
+
const fallback = face === 'front' ? 'interior' : 'exterior'
|
|
147
|
+
const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback
|
|
148
|
+
|
|
149
|
+
if (role === 'interior' || role === 'exterior') return side
|
|
150
|
+
if (role.endsWith('Interior'))
|
|
151
|
+
return `${role.slice(0, -'Interior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
|
|
152
|
+
if (role.endsWith('Exterior'))
|
|
153
|
+
return `${role.slice(0, -'Exterior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveWallPaintSpace(args: {
|
|
158
|
+
wall: WallNode
|
|
159
|
+
wallHit: WallPaintHit
|
|
160
|
+
nodes: Record<string, AnyNode>
|
|
161
|
+
spaces: Record<string, Space>
|
|
162
|
+
}): Space | null {
|
|
163
|
+
const { wall, wallHit, nodes, spaces } = args
|
|
164
|
+
const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
|
|
165
|
+
const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
|
|
166
|
+
let best: { space: Space; distance: number } | null = null
|
|
167
|
+
|
|
168
|
+
for (const space of Object.values(spaces)) {
|
|
169
|
+
if (space.levelId !== levelId) continue
|
|
170
|
+
for (const boundary of space.boundaryFaces) {
|
|
171
|
+
if (boundary.wallId !== wall.id || boundary.face !== wallHit.face) continue
|
|
172
|
+
const distance = distanceToPolyline(wallHit.point, boundary.points)
|
|
173
|
+
if (distance > tolerance || (best && distance >= best.distance)) continue
|
|
174
|
+
best = { space, distance }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return best?.space ?? null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function boundaryPointKey(point: readonly [number, number]): string {
|
|
182
|
+
return `${point[0].toFixed(3)},${point[1].toFixed(3)}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function boundarySegmentKey(boundary: WallBoundaryFace): string {
|
|
186
|
+
const forward = boundary.points.map(boundaryPointKey).join('|')
|
|
187
|
+
const reverse = [...boundary.points].reverse().map(boundaryPointKey).join('|')
|
|
188
|
+
return `${boundary.wallId}:${forward < reverse ? forward : reverse}`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function oppositeWallFace(face: 'front' | 'back'): 'front' | 'back' {
|
|
192
|
+
return face === 'front' ? 'back' : 'front'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function connectedExteriorBoundaries(args: {
|
|
196
|
+
wall: WallNode
|
|
197
|
+
wallHit: WallPaintHit
|
|
198
|
+
levelId: string
|
|
199
|
+
spaces: Record<string, Space>
|
|
200
|
+
}): WallBoundaryFace[] {
|
|
201
|
+
const { wall, wallHit, levelId, spaces } = args
|
|
202
|
+
const occurrences = new Map<string, WallBoundaryFace[]>()
|
|
203
|
+
|
|
204
|
+
for (const space of Object.values(spaces)) {
|
|
205
|
+
if (space.levelId !== levelId) continue
|
|
206
|
+
for (const boundary of space.boundaryFaces) {
|
|
207
|
+
const key = boundarySegmentKey(boundary)
|
|
208
|
+
const entries = occurrences.get(key) ?? []
|
|
209
|
+
entries.push(boundary)
|
|
210
|
+
occurrences.set(key, entries)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const exterior = [...occurrences.values()].flatMap((entries) => {
|
|
215
|
+
const boundary = entries.length === 1 ? entries[0] : undefined
|
|
216
|
+
if (!boundary) return []
|
|
217
|
+
return [{ ...boundary, face: oppositeWallFace(boundary.face) }]
|
|
218
|
+
})
|
|
219
|
+
const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
|
|
220
|
+
const seed = exterior
|
|
221
|
+
.filter((boundary) => boundary.wallId === wall.id && boundary.face === wallHit.face)
|
|
222
|
+
.map((boundary) => ({ boundary, distance: distanceToPolyline(wallHit.point, boundary.points) }))
|
|
223
|
+
.filter((candidate) => candidate.distance <= tolerance)
|
|
224
|
+
.sort((a, b) => a.distance - b.distance)[0]?.boundary
|
|
225
|
+
if (!seed) return []
|
|
226
|
+
|
|
227
|
+
const boundariesByEndpoint = new Map<string, WallBoundaryFace[]>()
|
|
228
|
+
for (const boundary of exterior) {
|
|
229
|
+
const first = boundary.points[0]
|
|
230
|
+
const last = boundary.points[boundary.points.length - 1]
|
|
231
|
+
for (const point of [first, last]) {
|
|
232
|
+
if (!point) continue
|
|
233
|
+
const key = boundaryPointKey(point)
|
|
234
|
+
const entries = boundariesByEndpoint.get(key) ?? []
|
|
235
|
+
entries.push(boundary)
|
|
236
|
+
boundariesByEndpoint.set(key, entries)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const connected: WallBoundaryFace[] = []
|
|
241
|
+
const visited = new Set<string>()
|
|
242
|
+
const queue = [seed]
|
|
243
|
+
while (queue.length > 0) {
|
|
244
|
+
const boundary = queue.shift()
|
|
245
|
+
if (!boundary) continue
|
|
246
|
+
const key = `${boundarySegmentKey(boundary)}:${boundary.face}`
|
|
247
|
+
if (visited.has(key)) continue
|
|
248
|
+
visited.add(key)
|
|
249
|
+
connected.push(boundary)
|
|
250
|
+
|
|
251
|
+
const first = boundary.points[0]
|
|
252
|
+
const last = boundary.points[boundary.points.length - 1]
|
|
253
|
+
for (const point of [first, last]) {
|
|
254
|
+
if (!point) continue
|
|
255
|
+
for (const neighbour of boundariesByEndpoint.get(boundaryPointKey(point)) ?? []) {
|
|
256
|
+
queue.push(neighbour)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return connected
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function wallTargetsForBoundaries(args: {
|
|
265
|
+
boundaries: WallBoundaryFace[]
|
|
266
|
+
role: string
|
|
267
|
+
levelId: string
|
|
268
|
+
nodes: Record<string, AnyNode>
|
|
269
|
+
}): Array<{ nodeId: AnyNodeId; role: string }> {
|
|
270
|
+
const { boundaries, role, levelId, nodes } = args
|
|
271
|
+
const targets = new Map<string, { nodeId: AnyNodeId; role: string }>()
|
|
272
|
+
for (const boundary of boundaries) {
|
|
273
|
+
const targetWall = nodes[boundary.wallId]
|
|
274
|
+
if (
|
|
275
|
+
targetWall?.type !== 'wall' ||
|
|
276
|
+
(targetWall.parentId ?? resolveLevelId(targetWall, nodes)) !== levelId
|
|
277
|
+
) {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
const targetRole = wallRoleForRoomFace(role, targetWall, boundary.face)
|
|
281
|
+
if (!targetRole) continue
|
|
282
|
+
const key = `${targetWall.id}:${targetRole}`
|
|
283
|
+
targets.set(key, { nodeId: targetWall.id as AnyNodeId, role: targetRole })
|
|
284
|
+
}
|
|
285
|
+
return [...targets.values()]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function polygonCentroid(
|
|
289
|
+
points: ReadonlyArray<readonly [number, number]>,
|
|
290
|
+
): [number, number] | null {
|
|
291
|
+
if (points.length === 0) return null
|
|
292
|
+
let x = 0
|
|
293
|
+
let z = 0
|
|
294
|
+
for (const point of points) {
|
|
295
|
+
x += point[0]
|
|
296
|
+
z += point[1]
|
|
297
|
+
}
|
|
298
|
+
return [x / points.length, z / points.length]
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Expand one paint hit (`node` + resolved `role`) into the full list of
|
|
303
|
+
* (node, role) targets the current `scope` should paint. Returns just the
|
|
304
|
+
* clicked surface for `'single'`, for any target whose scope set doesn't
|
|
305
|
+
* include the current scope, and whenever the spread resolves to a single
|
|
306
|
+
* element — so callers can keep the kind-specific single-node commit for that
|
|
307
|
+
* case and only batch when there's genuinely more than one target.
|
|
308
|
+
*
|
|
309
|
+
* `slotRolesOf` enumerates the node's full slot set (declared or mesh-derived,
|
|
310
|
+
* injected by the caller) for the whole-object scope.
|
|
311
|
+
*/
|
|
312
|
+
export function resolvePaintScopeTargets(args: {
|
|
313
|
+
node: AnyNode
|
|
314
|
+
role: string
|
|
315
|
+
scope: PaintScope
|
|
316
|
+
nodes: Record<string, AnyNode>
|
|
317
|
+
spaces: Record<string, Space>
|
|
318
|
+
slotRolesOf: (node: AnyNode) => string[]
|
|
319
|
+
wallHit?: WallPaintHit
|
|
320
|
+
}): Array<{ nodeId: AnyNodeId; role: string }> {
|
|
321
|
+
const { node, role, scope, nodes, spaces, slotRolesOf, wallHit } = args
|
|
322
|
+
const single = [{ nodeId: node.id as AnyNodeId, role }]
|
|
323
|
+
if (scope === 'single') return single
|
|
324
|
+
|
|
325
|
+
// Whole object: paint every slot of the clicked node. Generic across any
|
|
326
|
+
// slot-model kind (item, shelf, door, …) — not item-specific.
|
|
327
|
+
if (scope === 'object') {
|
|
328
|
+
const roles = slotRolesOf(node)
|
|
329
|
+
const set = roles.length > 0 ? roles : [role]
|
|
330
|
+
return set.map((slotRole) => ({ nodeId: node.id as AnyNodeId, role: slotRole }))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// All matching: same slot across every instance of the node's asset (items).
|
|
334
|
+
if (scope === 'matching') {
|
|
335
|
+
const assetId = (node as ItemNode).asset?.id
|
|
336
|
+
if (!assetId) return single
|
|
337
|
+
return Object.values(nodes)
|
|
338
|
+
.filter((other) => other.type === 'item' && (other as ItemNode).asset?.id === assetId)
|
|
339
|
+
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (node.type === 'wall' && scope === 'room') {
|
|
343
|
+
const wall = node as WallNode
|
|
344
|
+
if (!wallHit) return single
|
|
345
|
+
const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
|
|
346
|
+
if (!levelId) return single
|
|
347
|
+
const space = resolveWallPaintSpace({ wall, wallHit, nodes, spaces })
|
|
348
|
+
const boundaries = space
|
|
349
|
+
? space.boundaryFaces
|
|
350
|
+
: connectedExteriorBoundaries({ wall, wallHit, levelId, spaces })
|
|
351
|
+
const targets = wallTargetsForBoundaries({ boundaries, role, levelId, nodes })
|
|
352
|
+
return targets.length > 0 ? targets : single
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (node.type === 'slab' && scope === 'room') {
|
|
356
|
+
const centroid = polygonCentroid((node as SlabNode).polygon)
|
|
357
|
+
if (!centroid) return single
|
|
358
|
+
const space = Object.values(spaces).find((candidate) =>
|
|
359
|
+
pointInPolygon2D(centroid, candidate.polygon),
|
|
360
|
+
)
|
|
361
|
+
if (!space) return single
|
|
362
|
+
return Object.values(nodes)
|
|
363
|
+
.filter((other) => {
|
|
364
|
+
if (other.type !== 'slab') return false
|
|
365
|
+
const otherCentroid = polygonCentroid((other as SlabNode).polygon)
|
|
366
|
+
return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
|
|
367
|
+
})
|
|
368
|
+
.map((other) => ({ nodeId: other.id as AnyNodeId, role }))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return single
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Batched commit ──────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
// Structural equality for the one-off-colour dedup below. The slot model is
|
|
377
|
+
// uniform across item / wall / slab (`node.slots[role] = ref`), so the same
|
|
378
|
+
// matcher the per-kind commits use applies to the whole fan-out.
|
|
379
|
+
function materialsEqual(a: unknown, b: unknown): boolean {
|
|
380
|
+
if (Object.is(a, b)) return true
|
|
381
|
+
if (typeof a !== typeof b || a === null || b === null) return false
|
|
382
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
383
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
|
384
|
+
return a.every((value, index) => materialsEqual(value, b[index]))
|
|
385
|
+
}
|
|
386
|
+
if (typeof a === 'object') {
|
|
387
|
+
const aRecord = a as Record<string, unknown>
|
|
388
|
+
const bRecord = b as Record<string, unknown>
|
|
389
|
+
const aKeys = Object.keys(aRecord)
|
|
390
|
+
if (aKeys.length !== Object.keys(bRecord).length) return false
|
|
391
|
+
return aKeys.every(
|
|
392
|
+
(key) => Object.hasOwn(bRecord, key) && materialsEqual(aRecord[key], bRecord[key]),
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
return false
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Apply one paint to many slot-model targets in a single undo step. Resolves
|
|
400
|
+
* the slot ref ONCE — a one-off colour creates a single shared scene material
|
|
401
|
+
* for the whole fan-out, not one per node — then writes every `node.slots[role]`
|
|
402
|
+
* (or deletes it, for the eraser) in one `useScene.setState`. Only ever called
|
|
403
|
+
* for item / wall / slab fan-outs, all of which use the unified slot model.
|
|
404
|
+
*/
|
|
405
|
+
export function commitPaintScopeFanout(
|
|
406
|
+
targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>,
|
|
407
|
+
material: MaterialSchema | undefined,
|
|
408
|
+
materialPreset: string | undefined,
|
|
409
|
+
): void {
|
|
410
|
+
if (targets.length === 0) return
|
|
411
|
+
const state = useScene.getState()
|
|
412
|
+
|
|
413
|
+
let ref: string | undefined
|
|
414
|
+
let newSceneMaterial: SceneMaterial | null = null
|
|
415
|
+
if (material === undefined && materialPreset === undefined) {
|
|
416
|
+
ref = undefined // eraser → clear the slot back to its default
|
|
417
|
+
} else if (materialPreset) {
|
|
418
|
+
ref = materialPreset
|
|
419
|
+
} else if (material) {
|
|
420
|
+
const existing = Object.values(state.materials).find((scene) =>
|
|
421
|
+
materialsEqual(scene.material, material),
|
|
422
|
+
)
|
|
423
|
+
if (existing) {
|
|
424
|
+
ref = toSceneMaterialRef(existing.id)
|
|
425
|
+
} else {
|
|
426
|
+
const id = generateSceneMaterialId()
|
|
427
|
+
newSceneMaterial = {
|
|
428
|
+
id,
|
|
429
|
+
name: `Material ${Object.keys(state.materials).length + 1}`,
|
|
430
|
+
material,
|
|
431
|
+
}
|
|
432
|
+
ref = toSceneMaterialRef(id)
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
useScene.setState((current) => {
|
|
439
|
+
if (current.readOnly) return current
|
|
440
|
+
const nextNodes = { ...current.nodes }
|
|
441
|
+
let changed = false
|
|
442
|
+
for (const { nodeId, role } of targets) {
|
|
443
|
+
const node = nextNodes[nodeId] as SlotsNode | undefined
|
|
444
|
+
if (!node) continue
|
|
445
|
+
const nextSlots = { ...(node.slots ?? {}) }
|
|
446
|
+
if (ref) nextSlots[role] = ref
|
|
447
|
+
else delete nextSlots[role]
|
|
448
|
+
nextNodes[nodeId] = { ...node, slots: nextSlots } as AnyNode
|
|
449
|
+
changed = true
|
|
450
|
+
}
|
|
451
|
+
if (!changed) return current
|
|
452
|
+
return {
|
|
453
|
+
nodes: nextNodes,
|
|
454
|
+
materials: newSceneMaterial
|
|
455
|
+
? { ...current.materials, [newSceneMaterial.id as SceneMaterialId]: newSceneMaterial }
|
|
456
|
+
: current.materials,
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
for (const { nodeId } of targets) state.markDirty(nodeId)
|
|
461
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import useEditor from '../store/use-editor'
|
|
4
|
+
|
|
5
|
+
function swallowNextClick(timeoutMs = 300) {
|
|
6
|
+
const swallow = (event: Event) => {
|
|
7
|
+
event.stopPropagation()
|
|
8
|
+
event.preventDefault()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
window.addEventListener('click', swallow, { capture: true, once: true })
|
|
12
|
+
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), timeoutMs)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function consumePlacementDragRelease(event: PointerEvent): boolean {
|
|
16
|
+
if (!useEditor.getState().placementDragMode) return false
|
|
17
|
+
if (event.button !== 0) return false
|
|
18
|
+
|
|
19
|
+
event.stopPropagation()
|
|
20
|
+
event.preventDefault()
|
|
21
|
+
swallowNextClick()
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
@@ -40,4 +40,61 @@ describe('resolvePlanarCursorPosition', () => {
|
|
|
40
40
|
expect(moved.point).toEqual([11, 19])
|
|
41
41
|
expect(moved.anchor).toEqual([4.1, 6.1])
|
|
42
42
|
})
|
|
43
|
+
|
|
44
|
+
// Track B regression: "off-slab cursor, on-slab footprint stays at center".
|
|
45
|
+
// When the gizmo is grabbed off the footprint center (e.g. near a slab edge),
|
|
46
|
+
// the resolved center must track original + cursorDelta and be independent of
|
|
47
|
+
// the initial grab offset — so a footprint fully inside a slab cannot be
|
|
48
|
+
// pushed off the edge just because the cursor sample landed off-center.
|
|
49
|
+
test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => {
|
|
50
|
+
const original: [number, number] = [2, 2]
|
|
51
|
+
const firstSample: [number, number] = [2.3, 2.3]
|
|
52
|
+
const cursor: [number, number] = [3.1, 1.6]
|
|
53
|
+
|
|
54
|
+
const start = resolvePlanarCursorPosition({
|
|
55
|
+
cursor: firstSample,
|
|
56
|
+
original,
|
|
57
|
+
anchor: null,
|
|
58
|
+
mode: 'relative',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// First sample absorbs the off-center grab: the footprint stays put.
|
|
62
|
+
expect(start.point).toEqual(original)
|
|
63
|
+
expect(start.anchor).toEqual(firstSample)
|
|
64
|
+
|
|
65
|
+
const moved = resolvePlanarCursorPosition({
|
|
66
|
+
cursor,
|
|
67
|
+
original,
|
|
68
|
+
anchor: start.anchor,
|
|
69
|
+
mode: 'relative',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Committed center = original + (cursor - firstSample), i.e. the gizmo
|
|
73
|
+
// offset is cancelled regardless of where on the footprint it was grabbed.
|
|
74
|
+
const expected: [number, number] = [
|
|
75
|
+
original[0] + (cursor[0] - firstSample[0]),
|
|
76
|
+
original[1] + (cursor[1] - firstSample[1]),
|
|
77
|
+
]
|
|
78
|
+
expect(moved.point[0]).toBeCloseTo(expected[0])
|
|
79
|
+
expect(moved.point[1]).toBeCloseTo(expected[1])
|
|
80
|
+
|
|
81
|
+
// The result must not depend on the absolute grab offset: grabbing the same
|
|
82
|
+
// footprint dead-center and moving by the same delta yields the same center.
|
|
83
|
+
const centerStart = resolvePlanarCursorPosition({
|
|
84
|
+
cursor: original,
|
|
85
|
+
original,
|
|
86
|
+
anchor: null,
|
|
87
|
+
mode: 'relative',
|
|
88
|
+
})
|
|
89
|
+
const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]]
|
|
90
|
+
const centerMoved = resolvePlanarCursorPosition({
|
|
91
|
+
cursor: [original[0] + delta[0], original[1] + delta[1]],
|
|
92
|
+
original,
|
|
93
|
+
anchor: centerStart.anchor,
|
|
94
|
+
mode: 'relative',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(centerMoved.point[0]).toBeCloseTo(moved.point[0])
|
|
98
|
+
expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
|
|
99
|
+
})
|
|
43
100
|
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels'
|
|
3
|
+
|
|
4
|
+
describe('editorHostPanelRegistry', () => {
|
|
5
|
+
afterEach(() => editorHostPanelRegistry.reset())
|
|
6
|
+
|
|
7
|
+
test('maps registered node kinds back to their owning host panel', () => {
|
|
8
|
+
registerEditorHostPanel({
|
|
9
|
+
id: 'pascal:trees:trees',
|
|
10
|
+
label: 'Nature',
|
|
11
|
+
icon: { kind: 'url', src: '/nature.webp' },
|
|
12
|
+
component: async () => ({ default: () => null }),
|
|
13
|
+
kinds: ['trees:tree', 'trees:flower', 'trees:grass'],
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(editorHostPanelRegistry.panelForKind('trees:flower')).toBe('pascal:trees:trees')
|
|
17
|
+
expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { IconRef, LazyComponent } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type EditorHostPanelWorkspace = string & {}
|
|
4
|
+
|
|
5
|
+
export type EditorHostPanel = {
|
|
6
|
+
id: string
|
|
7
|
+
label: string
|
|
8
|
+
icon: IconRef
|
|
9
|
+
component: LazyComponent
|
|
10
|
+
kinds?: readonly string[]
|
|
11
|
+
workspaces?: readonly EditorHostPanelWorkspace[]
|
|
12
|
+
pluginId?: string
|
|
13
|
+
description?: string
|
|
14
|
+
creator?: {
|
|
15
|
+
name: string
|
|
16
|
+
url?: string
|
|
17
|
+
}
|
|
18
|
+
pluginUrl?: string
|
|
19
|
+
defaultInstalled?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isDevMode(): boolean {
|
|
23
|
+
try {
|
|
24
|
+
const meta = import.meta as { env?: { DEV?: boolean } }
|
|
25
|
+
if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
|
|
26
|
+
} catch {
|
|
27
|
+
// import.meta unavailable in some CJS contexts — fall through.
|
|
28
|
+
}
|
|
29
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
|
|
30
|
+
return process.env.NODE_ENV !== 'production'
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class EditorHostPanelRegistryImpl {
|
|
36
|
+
private readonly panels = new Map<string, EditorHostPanel>()
|
|
37
|
+
private readonly listeners = new Set<() => void>()
|
|
38
|
+
private cached: EditorHostPanel[] = []
|
|
39
|
+
|
|
40
|
+
subscribe = (onChange: () => void): (() => void) => {
|
|
41
|
+
this.listeners.add(onChange)
|
|
42
|
+
return () => {
|
|
43
|
+
this.listeners.delete(onChange)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getSnapshot = (): EditorHostPanel[] => this.cached
|
|
48
|
+
|
|
49
|
+
panelForKind = (kind: string): string | undefined =>
|
|
50
|
+
this.cached.find((panel) => panel.kinds?.includes(kind))?.id
|
|
51
|
+
|
|
52
|
+
getDefaultInstalledPluginIds = (): string[] =>
|
|
53
|
+
Array.from(
|
|
54
|
+
new Set(
|
|
55
|
+
this.cached
|
|
56
|
+
.filter((panel) => panel.pluginId && panel.defaultInstalled)
|
|
57
|
+
.map((panel) => panel.pluginId as string),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
reset(): void {
|
|
62
|
+
this.panels.clear()
|
|
63
|
+
this.emit()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
registerPanel(panel: EditorHostPanel): void {
|
|
67
|
+
if (typeof panel.id !== 'string' || panel.id.length === 0) {
|
|
68
|
+
throw new Error('[editor:host-panels] panel id must be a non-empty string')
|
|
69
|
+
}
|
|
70
|
+
if (this.panels.has(panel.id)) {
|
|
71
|
+
if (isDevMode()) {
|
|
72
|
+
console.warn(`[editor:host-panels] re-registering panel "${panel.id}" (HMR)`)
|
|
73
|
+
} else {
|
|
74
|
+
throw new Error(`[editor:host-panels] duplicate panel id: "${panel.id}" already registered`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
this.panels.set(panel.id, panel)
|
|
78
|
+
this.emit()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private emit(): void {
|
|
82
|
+
this.cached = Array.from(this.panels.values())
|
|
83
|
+
for (const listener of this.listeners) listener()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl()
|
|
88
|
+
|
|
89
|
+
export function registerEditorHostPanel(panel: EditorHostPanel): void {
|
|
90
|
+
editorHostPanelRegistry.registerPanel(panel)
|
|
91
|
+
}
|