@pascal-app/editor 1.0.0-beta.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +63 -8
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +12 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +23 -6
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type PaintPreviewCleanup = (() => void) & { commit?: () => void }
|
|
2
|
+
|
|
3
|
+
type Interaction = {
|
|
4
|
+
key: string
|
|
5
|
+
apply: (() => void) | null
|
|
6
|
+
preview: (() => PaintPreviewCleanup | null) | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function combinePaintPreviews(previews: PaintPreviewCleanup[]): PaintPreviewCleanup {
|
|
10
|
+
const finish = (committed: boolean) => {
|
|
11
|
+
const pending = previews.splice(0).reverse()
|
|
12
|
+
let failure: unknown
|
|
13
|
+
for (const cleanup of pending) {
|
|
14
|
+
try {
|
|
15
|
+
if (committed) cleanup.commit?.()
|
|
16
|
+
else cleanup()
|
|
17
|
+
} catch (error) {
|
|
18
|
+
failure ??= error
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (failure) throw failure
|
|
22
|
+
}
|
|
23
|
+
return Object.assign(() => finish(false), { commit: () => finish(true) })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createPaintPreviewOwner() {
|
|
27
|
+
let active: { key: string; cleanup: PaintPreviewCleanup } | null = null
|
|
28
|
+
const end = (committed = false) => {
|
|
29
|
+
const previous = active
|
|
30
|
+
active = null
|
|
31
|
+
if (committed) previous?.cleanup.commit?.()
|
|
32
|
+
else previous?.cleanup()
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
wrap<T extends Interaction>(interaction: T | null): T | null {
|
|
36
|
+
if (!interaction) return null
|
|
37
|
+
return {
|
|
38
|
+
...interaction,
|
|
39
|
+
preview: interaction.preview
|
|
40
|
+
? () => {
|
|
41
|
+
end()
|
|
42
|
+
const cleanup = interaction.preview!()
|
|
43
|
+
if (!cleanup) return null
|
|
44
|
+
const owned = { key: interaction.key, cleanup }
|
|
45
|
+
active = owned
|
|
46
|
+
return () => {
|
|
47
|
+
if (active === owned) end()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
: null,
|
|
51
|
+
apply: interaction.apply
|
|
52
|
+
? () => {
|
|
53
|
+
if (active && active.key !== interaction.key) end()
|
|
54
|
+
try {
|
|
55
|
+
interaction.apply!()
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// A subscriber can throw after the scene write has already been published.
|
|
58
|
+
try {
|
|
59
|
+
end(true)
|
|
60
|
+
} catch {
|
|
61
|
+
// Preserve the apply error even if a hold listener also throws.
|
|
62
|
+
}
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
end(true)
|
|
66
|
+
}
|
|
67
|
+
: null,
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -81,8 +81,8 @@ describe('paintScopeLabel', () => {
|
|
|
81
81
|
function item(id: string, assetId: string): ItemNode {
|
|
82
82
|
return { id, type: 'item', asset: { id: assetId } } as unknown as ItemNode
|
|
83
83
|
}
|
|
84
|
-
function slab(id: string, polygon: Array<[number, number]
|
|
85
|
-
return { id, type: 'slab', polygon } as unknown as SlabNode
|
|
84
|
+
function slab(id: string, polygon: Array<[number, number]>, levelId = 'l1'): SlabNode {
|
|
85
|
+
return { id, type: 'slab', polygon, parentId: levelId } as unknown as SlabNode
|
|
86
86
|
}
|
|
87
87
|
function wall(
|
|
88
88
|
id: string,
|
|
@@ -451,4 +451,60 @@ describe('resolvePaintScopeTargets', () => {
|
|
|
451
451
|
})
|
|
452
452
|
expect(keys(result).sort()).toEqual(['inA:surface', 'inB:surface'])
|
|
453
453
|
})
|
|
454
|
+
|
|
455
|
+
it('slab room stops at the level boundary', () => {
|
|
456
|
+
// Stacked storeys share a footprint, so the upper slab's centroid sits inside
|
|
457
|
+
// the ground floor's space polygon. Painting downstairs must not reach it.
|
|
458
|
+
const ground = slab(
|
|
459
|
+
'ground',
|
|
460
|
+
[
|
|
461
|
+
[1, 1],
|
|
462
|
+
[3, 1],
|
|
463
|
+
[3, 3],
|
|
464
|
+
[1, 3],
|
|
465
|
+
],
|
|
466
|
+
'l1',
|
|
467
|
+
)
|
|
468
|
+
const upstairs = slab(
|
|
469
|
+
'upstairs',
|
|
470
|
+
[
|
|
471
|
+
[1, 1],
|
|
472
|
+
[3, 1],
|
|
473
|
+
[3, 3],
|
|
474
|
+
[1, 3],
|
|
475
|
+
],
|
|
476
|
+
'l2',
|
|
477
|
+
)
|
|
478
|
+
const footprint: Array<[number, number]> = [
|
|
479
|
+
[0, 0],
|
|
480
|
+
[10, 0],
|
|
481
|
+
[10, 10],
|
|
482
|
+
[0, 10],
|
|
483
|
+
]
|
|
484
|
+
const result = resolve({
|
|
485
|
+
node: ground,
|
|
486
|
+
role: 'surface',
|
|
487
|
+
scope: 'room',
|
|
488
|
+
nodes: [ground, upstairs],
|
|
489
|
+
spaces: [
|
|
490
|
+
{
|
|
491
|
+
id: 's1',
|
|
492
|
+
levelId: 'l1',
|
|
493
|
+
polygon: footprint,
|
|
494
|
+
wallIds: [],
|
|
495
|
+
boundaryFaces: [],
|
|
496
|
+
isExterior: false,
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
id: 's2',
|
|
500
|
+
levelId: 'l2',
|
|
501
|
+
polygon: footprint,
|
|
502
|
+
wallIds: [],
|
|
503
|
+
boundaryFaces: [],
|
|
504
|
+
isExterior: false,
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
})
|
|
508
|
+
expect(keys(result)).toEqual(['ground:surface'])
|
|
509
|
+
})
|
|
454
510
|
})
|
package/src/lib/paint-scope.ts
CHANGED
|
@@ -355,13 +355,19 @@ export function resolvePaintScopeTargets(args: {
|
|
|
355
355
|
if (node.type === 'slab' && scope === 'room') {
|
|
356
356
|
const centroid = polygonCentroid((node as SlabNode).polygon)
|
|
357
357
|
if (!centroid) return single
|
|
358
|
-
|
|
359
|
-
|
|
358
|
+
// Space polygons are per-level footprints, and stacked storeys share a
|
|
359
|
+
// footprint — so the level has to gate both the space lookup and the fan-out
|
|
360
|
+
// or one click paints the floor above too.
|
|
361
|
+
const levelId = node.parentId ?? resolveLevelId(node, nodes)
|
|
362
|
+
if (!levelId) return single
|
|
363
|
+
const space = Object.values(spaces).find(
|
|
364
|
+
(candidate) => candidate.levelId === levelId && pointInPolygon2D(centroid, candidate.polygon),
|
|
360
365
|
)
|
|
361
366
|
if (!space) return single
|
|
362
367
|
return Object.values(nodes)
|
|
363
368
|
.filter((other) => {
|
|
364
369
|
if (other.type !== 'slab') return false
|
|
370
|
+
if ((other.parentId ?? resolveLevelId(other, nodes)) !== levelId) return false
|
|
365
371
|
const otherCentroid = polygonCentroid((other as SlabNode).polygon)
|
|
366
372
|
return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
|
|
367
373
|
})
|
|
@@ -1,9 +1,90 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
offsetPlanPositionByLocalCenter,
|
|
4
|
+
resolvePlanarCursorPosition,
|
|
5
|
+
resolvePrioritizedPlanarCursorPosition,
|
|
6
|
+
} from './planar-cursor-placement'
|
|
3
7
|
|
|
4
8
|
const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5
|
|
5
9
|
|
|
6
10
|
describe('resolvePlanarCursorPosition', () => {
|
|
11
|
+
test('absolute mode puts the unrotated footprint centre at the snapped cursor', () => {
|
|
12
|
+
const result = resolvePlanarCursorPosition({
|
|
13
|
+
cursor: [4.24, 2.26],
|
|
14
|
+
original: [0, 0],
|
|
15
|
+
anchor: null,
|
|
16
|
+
mode: 'absolute',
|
|
17
|
+
localCenter: [1.3, 0.5, 0.2],
|
|
18
|
+
snap: snapHalf,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(result.point).toEqual([2.7, 2.3])
|
|
22
|
+
expect(result.anchor).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('absolute mode snaps the centre before deriving the rotated origin', () => {
|
|
26
|
+
const proposals: [number, number][] = []
|
|
27
|
+
const localCenter: [number, number, number] = [1.3, 0.5, 0.2]
|
|
28
|
+
const result = resolvePlanarCursorPosition({
|
|
29
|
+
cursor: [4.24, 2.26],
|
|
30
|
+
original: [0, 0],
|
|
31
|
+
anchor: null,
|
|
32
|
+
mode: 'absolute',
|
|
33
|
+
localCenter,
|
|
34
|
+
rotationY: Math.PI / 2,
|
|
35
|
+
snapPoint: (point) => {
|
|
36
|
+
proposals.push(point)
|
|
37
|
+
return [snapHalf(point[0]), snapHalf(point[1])]
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(proposals).toEqual([[4.24, 2.26]])
|
|
42
|
+
expect(result.point[0]).toBeCloseTo(3.8)
|
|
43
|
+
expect(result.point[1]).toBeCloseTo(3.8)
|
|
44
|
+
const centre = offsetPlanPositionByLocalCenter(
|
|
45
|
+
[result.point[0], 0, result.point[1]],
|
|
46
|
+
localCenter,
|
|
47
|
+
Math.PI / 2,
|
|
48
|
+
)
|
|
49
|
+
expect(centre[0]).toBeCloseTo(4)
|
|
50
|
+
expect(centre[2]).toBeCloseTo(2.5)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('absolute mode follows the unsnapped cursor at an oblique rotation', () => {
|
|
54
|
+
const localCenter: [number, number, number] = [1.5, 0.5, -0.3]
|
|
55
|
+
const result = resolvePlanarCursorPosition({
|
|
56
|
+
cursor: [-2.13, 6.27],
|
|
57
|
+
original: [0, 0],
|
|
58
|
+
anchor: null,
|
|
59
|
+
mode: 'absolute',
|
|
60
|
+
localCenter,
|
|
61
|
+
rotationY: -Math.PI / 4,
|
|
62
|
+
})
|
|
63
|
+
const centre = offsetPlanPositionByLocalCenter(
|
|
64
|
+
[result.point[0], 0, result.point[1]],
|
|
65
|
+
localCenter,
|
|
66
|
+
-Math.PI / 4,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
expect(centre[0]).toBeCloseTo(-2.13)
|
|
70
|
+
expect(centre[2]).toBeCloseTo(6.27)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('relative mode ignores the footprint centre and rotation', () => {
|
|
74
|
+
const result = resolvePlanarCursorPosition({
|
|
75
|
+
cursor: [4.9, 5.2],
|
|
76
|
+
original: [10, 20],
|
|
77
|
+
anchor: [4.1, 6.1],
|
|
78
|
+
mode: 'relative',
|
|
79
|
+
localCenter: [1.3, 0.5, 0.2],
|
|
80
|
+
rotationY: Math.PI / 2,
|
|
81
|
+
snap: snapHalf,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(result.point).toEqual([11, 19])
|
|
85
|
+
expect(result.anchor).toEqual([4.1, 6.1])
|
|
86
|
+
})
|
|
87
|
+
|
|
7
88
|
test('absolute mode places the point directly at the snapped cursor', () => {
|
|
8
89
|
const result = resolvePlanarCursorPosition({
|
|
9
90
|
cursor: [1.24, -2.26],
|
|
@@ -98,3 +179,99 @@ describe('resolvePlanarCursorPosition', () => {
|
|
|
98
179
|
expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
|
|
99
180
|
})
|
|
100
181
|
})
|
|
182
|
+
|
|
183
|
+
describe('resolvePrioritizedPlanarCursorPosition', () => {
|
|
184
|
+
test('attachment receives the corrected raw origin and returns the final origin', () => {
|
|
185
|
+
const proposals: [number, number][] = []
|
|
186
|
+
const result = resolvePrioritizedPlanarCursorPosition({
|
|
187
|
+
cursor: [4.24, 2.26],
|
|
188
|
+
original: [0, 0],
|
|
189
|
+
anchor: null,
|
|
190
|
+
mode: 'absolute',
|
|
191
|
+
localCenter: [1.3, 0.5, 0.2],
|
|
192
|
+
rotationY: Math.PI / 2,
|
|
193
|
+
snapPoint: () => {
|
|
194
|
+
throw new Error('Grid snapping must not run after attachment')
|
|
195
|
+
},
|
|
196
|
+
resolveAttachment: (proposal) => {
|
|
197
|
+
proposals.push(proposal)
|
|
198
|
+
return [3, 5]
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(proposals).toHaveLength(1)
|
|
203
|
+
expect(proposals[0]![0]).toBeCloseTo(4.04)
|
|
204
|
+
expect(proposals[0]![1]).toBeCloseTo(3.56)
|
|
205
|
+
expect(result.point).toEqual([3, 5])
|
|
206
|
+
expect(result.attachmentSnapped).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('snaps the footprint centre when attachment declines the corrected origin', () => {
|
|
210
|
+
const result = resolvePrioritizedPlanarCursorPosition({
|
|
211
|
+
cursor: [4.24, 2.26],
|
|
212
|
+
original: [0, 0],
|
|
213
|
+
anchor: null,
|
|
214
|
+
mode: 'absolute',
|
|
215
|
+
localCenter: [1.3, 0.5, 0.2],
|
|
216
|
+
snapPoint: ([x, z]) => [snapHalf(x), snapHalf(z)],
|
|
217
|
+
resolveAttachment: () => null,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(result.point).toEqual([2.7, 2.3])
|
|
221
|
+
expect(result.attachmentSnapped).toBe(false)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('wall attachment receives the raw proposal and wins over grid snapping', () => {
|
|
225
|
+
const attachmentProposals: [number, number][] = []
|
|
226
|
+
const result = resolvePrioritizedPlanarCursorPosition({
|
|
227
|
+
cursor: [0.73, 0.32],
|
|
228
|
+
original: [0, 0],
|
|
229
|
+
anchor: null,
|
|
230
|
+
mode: 'absolute',
|
|
231
|
+
snap: snapHalf,
|
|
232
|
+
resolveAttachment: (proposal) => {
|
|
233
|
+
attachmentProposals.push(proposal)
|
|
234
|
+
return [proposal[0], 0.39]
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
expect(attachmentProposals).toEqual([[0.73, 0.32]])
|
|
239
|
+
expect(result.point).toEqual([0.73, 0.39])
|
|
240
|
+
expect(result.attachmentSnapped).toBe(true)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('falls back to the grid proposal when there is no attachment', () => {
|
|
244
|
+
const result = resolvePrioritizedPlanarCursorPosition({
|
|
245
|
+
cursor: [0.73, 0.32],
|
|
246
|
+
original: [0, 0],
|
|
247
|
+
anchor: null,
|
|
248
|
+
mode: 'absolute',
|
|
249
|
+
snap: snapHalf,
|
|
250
|
+
resolveAttachment: () => null,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
expect(result.point).toEqual([0.5, 0.5])
|
|
254
|
+
expect(result.attachmentSnapped).toBe(false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('supports footprint-aware point snapping after attachment resolution', () => {
|
|
258
|
+
const pointProposals: [number, number][] = []
|
|
259
|
+
const result = resolvePrioritizedPlanarCursorPosition({
|
|
260
|
+
cursor: [1.03, 2.04],
|
|
261
|
+
original: [0.8, 1.8],
|
|
262
|
+
anchor: [0.9, 1.9],
|
|
263
|
+
mode: 'relative',
|
|
264
|
+
snapPoint: (proposal) => {
|
|
265
|
+
pointProposals.push(proposal)
|
|
266
|
+
return [0.8, 2.29]
|
|
267
|
+
},
|
|
268
|
+
resolveAttachment: () => null,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
expect(pointProposals).toHaveLength(1)
|
|
272
|
+
expect(pointProposals[0]![0]).toBeCloseTo(0.93)
|
|
273
|
+
expect(pointProposals[0]![1]).toBeCloseTo(1.94)
|
|
274
|
+
expect(result.point).toEqual([0.8, 2.29])
|
|
275
|
+
expect(result.attachmentSnapped).toBe(false)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -7,7 +7,10 @@ type ResolvePlanarCursorPositionArgs = {
|
|
|
7
7
|
original: PlanarPoint
|
|
8
8
|
anchor: PlanarPoint | null
|
|
9
9
|
mode: PlanarCursorPlacementMode
|
|
10
|
+
localCenter?: [number, number, number]
|
|
11
|
+
rotationY?: number
|
|
10
12
|
snap?: (value: number) => number
|
|
13
|
+
snapPoint?: (point: PlanarPoint) => PlanarPoint
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
type ResolvePlanarCursorPositionResult = {
|
|
@@ -15,28 +18,81 @@ type ResolvePlanarCursorPositionResult = {
|
|
|
15
18
|
anchor: PlanarPoint | null
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
type ResolvePrioritizedPlanarCursorPositionArgs = ResolvePlanarCursorPositionArgs & {
|
|
22
|
+
resolveAttachment?: (proposal: PlanarPoint) => PlanarPoint | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ResolvePrioritizedPlanarCursorPositionResult = ResolvePlanarCursorPositionResult & {
|
|
26
|
+
attachmentSnapped: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
const identity = (value: number) => value
|
|
19
30
|
|
|
31
|
+
export function offsetPlanPositionByLocalCenter(
|
|
32
|
+
position: [number, number, number],
|
|
33
|
+
center: [number, number, number],
|
|
34
|
+
rotationY: number,
|
|
35
|
+
): [number, number, number] {
|
|
36
|
+
const cos = Math.cos(rotationY)
|
|
37
|
+
const sin = Math.sin(rotationY)
|
|
38
|
+
return [
|
|
39
|
+
position[0] + center[0] * cos + center[2] * sin,
|
|
40
|
+
position[1] + center[1],
|
|
41
|
+
position[2] - center[0] * sin + center[2] * cos,
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
export function resolvePlanarCursorPosition({
|
|
21
46
|
cursor,
|
|
22
47
|
original,
|
|
23
48
|
anchor,
|
|
24
49
|
mode,
|
|
50
|
+
localCenter,
|
|
51
|
+
rotationY = 0,
|
|
25
52
|
snap = identity,
|
|
53
|
+
snapPoint,
|
|
26
54
|
}: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult {
|
|
27
55
|
if (mode === 'absolute') {
|
|
56
|
+
const proposal: PlanarPoint = [cursor[0], cursor[1]]
|
|
57
|
+
const snapped: PlanarPoint = snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])]
|
|
58
|
+
const origin: [number, number, number] = localCenter
|
|
59
|
+
? offsetPlanPositionByLocalCenter(
|
|
60
|
+
[snapped[0], 0, snapped[1]],
|
|
61
|
+
[-localCenter[0], 0, -localCenter[2]],
|
|
62
|
+
rotationY,
|
|
63
|
+
)
|
|
64
|
+
: [snapped[0], 0, snapped[1]]
|
|
28
65
|
return {
|
|
29
|
-
point: [
|
|
66
|
+
point: [origin[0], origin[2]],
|
|
30
67
|
anchor,
|
|
31
68
|
}
|
|
32
69
|
}
|
|
33
70
|
|
|
34
71
|
const resolvedAnchor = anchor ?? cursor
|
|
72
|
+
const delta: PlanarPoint = [cursor[0] - resolvedAnchor[0], cursor[1] - resolvedAnchor[1]]
|
|
73
|
+
const proposal: PlanarPoint = [original[0] + delta[0], original[1] + delta[1]]
|
|
35
74
|
return {
|
|
36
|
-
point: [
|
|
37
|
-
original[0] + snap(cursor[0] - resolvedAnchor[0]),
|
|
38
|
-
original[1] + snap(cursor[1] - resolvedAnchor[1]),
|
|
39
|
-
],
|
|
75
|
+
point: snapPoint?.(proposal) ?? [original[0] + snap(delta[0]), original[1] + snap(delta[1])],
|
|
40
76
|
anchor: resolvedAnchor,
|
|
41
77
|
}
|
|
42
78
|
}
|
|
79
|
+
|
|
80
|
+
export function resolvePrioritizedPlanarCursorPosition({
|
|
81
|
+
resolveAttachment,
|
|
82
|
+
...args
|
|
83
|
+
}: ResolvePrioritizedPlanarCursorPositionArgs): ResolvePrioritizedPlanarCursorPositionResult {
|
|
84
|
+
const raw = resolvePlanarCursorPosition({ ...args, snap: identity, snapPoint: undefined })
|
|
85
|
+
const attached = resolveAttachment?.(raw.point) ?? null
|
|
86
|
+
if (attached) {
|
|
87
|
+
return {
|
|
88
|
+
point: attached,
|
|
89
|
+
anchor: raw.anchor,
|
|
90
|
+
attachmentSnapped: true,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...resolvePlanarCursorPosition(args),
|
|
96
|
+
attachmentSnapped: false,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type EditorHostPanel,
|
|
4
|
+
editorHostPanelRegistry,
|
|
5
|
+
managedPluginIds,
|
|
6
|
+
registerEditorHostPanel,
|
|
7
|
+
showsPluginManager,
|
|
8
|
+
} from './plugin-panels'
|
|
3
9
|
|
|
4
10
|
describe('editorHostPanelRegistry', () => {
|
|
5
11
|
afterEach(() => editorHostPanelRegistry.reset())
|
|
@@ -17,3 +23,88 @@ describe('editorHostPanelRegistry', () => {
|
|
|
17
23
|
expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
|
|
18
24
|
})
|
|
19
25
|
})
|
|
26
|
+
|
|
27
|
+
const panel = (id: string, pluginId?: string): EditorHostPanel => ({
|
|
28
|
+
component: async () => ({ default: () => null }),
|
|
29
|
+
icon: { kind: 'url', src: '/x.webp' },
|
|
30
|
+
id,
|
|
31
|
+
label: id,
|
|
32
|
+
...(pluginId ? { pluginId } : {}),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('managedPluginIds', () => {
|
|
36
|
+
test('counts plugins, not panels — one plugin with two panels is one plugin', () => {
|
|
37
|
+
expect(
|
|
38
|
+
managedPluginIds([
|
|
39
|
+
panel('pascal:boots:game', 'pascal:boots'),
|
|
40
|
+
panel('pascal:boots:keep', 'pascal:boots'),
|
|
41
|
+
panel('pascal:trees:nature', 'pascal:trees'),
|
|
42
|
+
]),
|
|
43
|
+
).toEqual(['pascal:boots', 'pascal:trees'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("the editor's own panels are not plugins", () => {
|
|
47
|
+
// No `pluginId` means it came from the host app, not from a plugin, and
|
|
48
|
+
// there is nothing to install or uninstall.
|
|
49
|
+
expect(managedPluginIds([panel('site'), panel('settings')])).toEqual([])
|
|
50
|
+
expect(managedPluginIds([])).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* THE EMPTY LOBBY PANEL (owner report 2026-08-31). `/play/<id>` mounts the
|
|
56
|
+
* editor read-only and registers no host panels, so the plugin *manager* was
|
|
57
|
+
* the only tab in the rail: it opened by default onto a bare "Plugins" heading
|
|
58
|
+
* eating ~40% of a visitor's window. Dropping the last tab makes the v2 layout
|
|
59
|
+
* drop the left column entirely, which is the lobby as designed.
|
|
60
|
+
*
|
|
61
|
+
* The line to hold is that this hides an EMPTY manager and never a populated
|
|
62
|
+
* one — a read-only viewer in the real editor can still see what a project uses.
|
|
63
|
+
*/
|
|
64
|
+
describe('showsPluginManager', () => {
|
|
65
|
+
test('the open lobby — read-only with nothing registered — gets no rail at all', () => {
|
|
66
|
+
expect(
|
|
67
|
+
showsPluginManager({ managedPluginCount: 0, readOnly: true, workspaceMode: 'edit' }),
|
|
68
|
+
).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a read-only editor keeps the manager as soon as a plugin is registered', () => {
|
|
72
|
+
// Browsing what a project uses is a read, and the install button is
|
|
73
|
+
// already disabled on its own.
|
|
74
|
+
expect(
|
|
75
|
+
showsPluginManager({ managedPluginCount: 1, readOnly: true, workspaceMode: 'edit' }),
|
|
76
|
+
).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('a writable scene always keeps it, even with zero plugins', () => {
|
|
80
|
+
// The empty state is still useful to an owner: it is where "Create a
|
|
81
|
+
// Pascal plugin" lives.
|
|
82
|
+
expect(
|
|
83
|
+
showsPluginManager({ managedPluginCount: 0, readOnly: false, workspaceMode: 'edit' }),
|
|
84
|
+
).toBe(true)
|
|
85
|
+
expect(
|
|
86
|
+
showsPluginManager({ managedPluginCount: 3, readOnly: false, workspaceMode: 'edit' }),
|
|
87
|
+
).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('never outside the edit workspace — studio has its own rail', () => {
|
|
91
|
+
for (const readOnly of [false, true]) {
|
|
92
|
+
for (const managedPluginCount of [0, 2]) {
|
|
93
|
+
expect(
|
|
94
|
+
showsPluginManager({ managedPluginCount, readOnly, workspaceMode: 'studio' }),
|
|
95
|
+
`readOnly=${readOnly} count=${managedPluginCount}`,
|
|
96
|
+
).toBe(false)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('the pre-existing behaviour is unchanged for the normal editor', () => {
|
|
102
|
+
// Regression fence: before this gate the rule was `workspaceMode === 'edit'`
|
|
103
|
+
// alone. Every writable edit-workspace case must still answer the same.
|
|
104
|
+
for (const managedPluginCount of [0, 1, 5]) {
|
|
105
|
+
expect(
|
|
106
|
+
showsPluginManager({ managedPluginCount, readOnly: false, workspaceMode: 'edit' }),
|
|
107
|
+
).toBe(true)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
package/src/lib/plugin-panels.ts
CHANGED
|
@@ -89,3 +89,51 @@ export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl()
|
|
|
89
89
|
export function registerEditorHostPanel(panel: EditorHostPanel): void {
|
|
90
90
|
editorHostPanelRegistry.registerPanel(panel)
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The distinct plugins the manager can act on — every registered panel that
|
|
95
|
+
* declares a `pluginId`, deduplicated, because one plugin may contribute
|
|
96
|
+
* several panels and the manager lists plugins, not panels.
|
|
97
|
+
*
|
|
98
|
+
* Registration is what makes a plugin *manageable*, not installation: an
|
|
99
|
+
* uninstalled plugin still has to appear so it can be installed.
|
|
100
|
+
*/
|
|
101
|
+
export function managedPluginIds(panels: readonly EditorHostPanel[]): string[] {
|
|
102
|
+
return Array.from(
|
|
103
|
+
new Set(panels.filter((panel) => panel.pluginId).map((panel) => panel.pluginId as string)),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Does the plugin *manager* tab belong in the rail?
|
|
109
|
+
*
|
|
110
|
+
* It is a management surface — it installs and uninstalls plugins into the
|
|
111
|
+
* scene — so it earns a slot when there is something to manage, or when the
|
|
112
|
+
* scene is writable and the "create a plugin" path is still worth offering to
|
|
113
|
+
* whoever owns it.
|
|
114
|
+
*
|
|
115
|
+
* That leaves exactly one case out, and it is a real screen rather than a
|
|
116
|
+
* hypothetical: the open lobby (`/play/<id>`) mounts the editor under a
|
|
117
|
+
* read-only lease and registers NO host panels, so the manager was the only
|
|
118
|
+
* tab in the rail. The rail therefore opened by default onto a "Plugins"
|
|
119
|
+
* heading with nothing under it, covering roughly 40% of a visitor's window
|
|
120
|
+
* over the world they had come to play in (owner report 2026-08-31). With no
|
|
121
|
+
* tabs at all the v2 layout drops the whole left column, which is the lobby as
|
|
122
|
+
* intended: the canvas, and nothing else.
|
|
123
|
+
*
|
|
124
|
+
* A read-only *editor* keeps the tab as long as plugins are registered — a
|
|
125
|
+
* viewer can still read what a project uses; only the install button is
|
|
126
|
+
* disabled. So this hides an empty panel, never a populated one.
|
|
127
|
+
*/
|
|
128
|
+
export function showsPluginManager({
|
|
129
|
+
managedPluginCount,
|
|
130
|
+
readOnly,
|
|
131
|
+
workspaceMode,
|
|
132
|
+
}: {
|
|
133
|
+
managedPluginCount: number
|
|
134
|
+
readOnly: boolean
|
|
135
|
+
workspaceMode: string
|
|
136
|
+
}): boolean {
|
|
137
|
+
if (workspaceMode !== 'edit') return false
|
|
138
|
+
return managedPluginCount > 0 || !readOnly
|
|
139
|
+
}
|