@pascal-app/editor 1.0.0 → 1.0.3
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 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { initializeGpuRenderer } from '@pascal-app/viewer'
|
|
2
|
+
import type * as THREE from 'three'
|
|
3
|
+
import { texture as textureNode, uv } from 'three/tsl'
|
|
4
|
+
import { CanvasTexture, NodeMaterial, QuadMesh, WebGPURenderer } from 'three/webgpu'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GPU decompressor handed to three's GLTF/USDZ exporters for compressed (KTX2)
|
|
8
|
+
* textures. One instance lives for the duration of an export and is disposed
|
|
9
|
+
* with it.
|
|
10
|
+
*/
|
|
11
|
+
export type ExportTextureUtils = {
|
|
12
|
+
decompress(texture: THREE.Texture, maxTextureSize?: number): Promise<THREE.Texture>
|
|
13
|
+
dispose(): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The WebGL probe context initializeGpuRenderer opens on this canvas is never
|
|
17
|
+
// released, so one canvas serves every export instead of one per export.
|
|
18
|
+
let probeCanvas: HTMLCanvasElement | null | undefined
|
|
19
|
+
|
|
20
|
+
async function initializeDecompressRenderer(): Promise<WebGPURenderer> {
|
|
21
|
+
probeCanvas ??= typeof document === 'undefined' ? null : document.createElement('canvas')
|
|
22
|
+
const result = await initializeGpuRenderer({
|
|
23
|
+
probeCanvas,
|
|
24
|
+
createRenderer: (parameters) =>
|
|
25
|
+
new WebGPURenderer(parameters as ConstructorParameters<typeof WebGPURenderer>[0]),
|
|
26
|
+
})
|
|
27
|
+
if (result.status !== 'ready') {
|
|
28
|
+
throw new Error('Could not start a GPU renderer to decompress textures for export.', {
|
|
29
|
+
cause: result.error,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
return result.renderer
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function releaseDevice(renderer: WebGPURenderer) {
|
|
36
|
+
// initializeGpuRenderer hands three a caller-owned device, which
|
|
37
|
+
// WebGPUBackend.dispose() deliberately leaves alive.
|
|
38
|
+
const backend = renderer.backend as { device?: { destroy?: () => void } | null }
|
|
39
|
+
try {
|
|
40
|
+
backend.device?.destroy?.()
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mirrors three's `WebGPUTextureUtils.decompress` but keeps a single renderer
|
|
46
|
+
* for the whole export. The stock helper creates, initialises and disposes a
|
|
47
|
+
* WebGPURenderer — adapter and device request included, with no timeout — for
|
|
48
|
+
* every compressed texture it is handed, so a furnished scene paid that cost
|
|
49
|
+
* dozens of times and hung outright when one device request stalled. Handing
|
|
50
|
+
* it a renderer is not an option: it resizes whatever renderer it receives
|
|
51
|
+
* (the live viewer's canvas) and, in r186, throws on the first call that
|
|
52
|
+
* supplies one.
|
|
53
|
+
*/
|
|
54
|
+
export function createExportTextureUtils(
|
|
55
|
+
initialize: () => Promise<WebGPURenderer> = initializeDecompressRenderer,
|
|
56
|
+
): ExportTextureUtils {
|
|
57
|
+
let pending: Promise<WebGPURenderer> | null = null
|
|
58
|
+
let disposed = false
|
|
59
|
+
const quad = new QuadMesh()
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
async decompress(texture, maxTextureSize = Number.POSITIVE_INFINITY) {
|
|
63
|
+
if (disposed) {
|
|
64
|
+
throw new Error('The export was abandoned before this texture was decompressed.')
|
|
65
|
+
}
|
|
66
|
+
pending ??= initialize()
|
|
67
|
+
const renderer = await pending
|
|
68
|
+
|
|
69
|
+
// Everything below the await is synchronous, which is what lets
|
|
70
|
+
// overlapping calls share `quad` and the renderer's colour space.
|
|
71
|
+
const image = texture.image as { width: number; height: number }
|
|
72
|
+
const width = Math.min(image.width, maxTextureSize)
|
|
73
|
+
const height = Math.min(image.height, maxTextureSize)
|
|
74
|
+
|
|
75
|
+
const material = new NodeMaterial()
|
|
76
|
+
material.fragmentNode = textureNode(texture, uv().flipY())
|
|
77
|
+
const previousColorSpace = renderer.outputColorSpace
|
|
78
|
+
renderer.setSize(width, height)
|
|
79
|
+
renderer.outputColorSpace = texture.colorSpace
|
|
80
|
+
quad.material = material
|
|
81
|
+
try {
|
|
82
|
+
quad.render(renderer)
|
|
83
|
+
} finally {
|
|
84
|
+
renderer.outputColorSpace = previousColorSpace
|
|
85
|
+
material.dispose()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const canvas = document.createElement('canvas')
|
|
89
|
+
canvas.width = width
|
|
90
|
+
canvas.height = height
|
|
91
|
+
const context = canvas.getContext('2d')
|
|
92
|
+
if (!context) {
|
|
93
|
+
throw new Error('Could not create a 2D canvas to read back a decompressed texture.')
|
|
94
|
+
}
|
|
95
|
+
context.drawImage(renderer.domElement, 0, 0, width, height)
|
|
96
|
+
|
|
97
|
+
const readable = new CanvasTexture(canvas)
|
|
98
|
+
readable.minFilter = texture.minFilter
|
|
99
|
+
readable.magFilter = texture.magFilter
|
|
100
|
+
readable.wrapS = texture.wrapS
|
|
101
|
+
readable.wrapT = texture.wrapT
|
|
102
|
+
readable.colorSpace = texture.colorSpace
|
|
103
|
+
readable.name = texture.name
|
|
104
|
+
return readable
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async dispose() {
|
|
108
|
+
disposed = true
|
|
109
|
+
const started = pending
|
|
110
|
+
pending = null
|
|
111
|
+
if (!started) return
|
|
112
|
+
const renderer = await started.catch(() => null)
|
|
113
|
+
if (!renderer) return
|
|
114
|
+
try {
|
|
115
|
+
await renderer.dispose()
|
|
116
|
+
} finally {
|
|
117
|
+
releaseDevice(renderer)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { uniform } from 'three/tsl'
|
|
4
|
+
import { cloneExportUserData } from './export-user-data'
|
|
5
|
+
|
|
6
|
+
describe('cloneExportUserData', () => {
|
|
7
|
+
test('keeps plain data and deep-copies it', () => {
|
|
8
|
+
const source = {
|
|
9
|
+
pascalId: 'wall-1',
|
|
10
|
+
pascalSwingLeaf: { axis: 'y', openRotationY: 1.2 },
|
|
11
|
+
tags: ['a', 'b'],
|
|
12
|
+
offsets: new Float32Array([1, 2, 3]),
|
|
13
|
+
}
|
|
14
|
+
const copy = cloneExportUserData(source)
|
|
15
|
+
expect(copy).toEqual(source)
|
|
16
|
+
expect(copy.pascalSwingLeaf).not.toBe(source.pascalSwingLeaf)
|
|
17
|
+
expect(copy.tags).not.toBe(source.tags)
|
|
18
|
+
expect(copy.offsets).not.toBe(source.offsets)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('drops functions and live three resources instead of throwing', () => {
|
|
22
|
+
const material = new THREE.MeshStandardMaterial()
|
|
23
|
+
material.addEventListener('dispose', () => {})
|
|
24
|
+
const source = {
|
|
25
|
+
pascalId: 'pool-1',
|
|
26
|
+
waterEffect: { material, uniforms: { time: uniform(0) }, strength: 0.5 },
|
|
27
|
+
mesh: new THREE.Mesh(new THREE.BoxGeometry(), material),
|
|
28
|
+
geometry: new THREE.BufferGeometry(),
|
|
29
|
+
texture: new THREE.Texture(),
|
|
30
|
+
onDispose: () => {},
|
|
31
|
+
list: [material, 'kept'],
|
|
32
|
+
}
|
|
33
|
+
expect(() => structuredClone(source)).toThrow()
|
|
34
|
+
expect(cloneExportUserData(source)).toEqual({
|
|
35
|
+
pascalId: 'pool-1',
|
|
36
|
+
waterEffect: { uniforms: {}, strength: 0.5 },
|
|
37
|
+
list: [undefined, 'kept'],
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('tolerates cycles', () => {
|
|
42
|
+
const source: Record<string, unknown> = { pascalId: 'x' }
|
|
43
|
+
source.self = source
|
|
44
|
+
const copy = cloneExportUserData(source)
|
|
45
|
+
expect(copy.pascalId).toBe('x')
|
|
46
|
+
expect(copy.self).toBe(copy)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy `userData` for an export clone. `structuredClone` throws the moment it
|
|
3
|
+
* meets a function, and live scenes keep runtime resources in userData (a
|
|
4
|
+
* plugin's effect material, TSL uniforms, a mesh reference) whose dispose
|
|
5
|
+
* listeners are functions — so a plain `structuredClone(userData)` aborts the
|
|
6
|
+
* whole export. Exports only ever read plain data back out of userData, so
|
|
7
|
+
* runtime resources and functions are dropped instead of cloned.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type RuntimeResource = {
|
|
11
|
+
isObject3D?: boolean
|
|
12
|
+
isMaterial?: boolean
|
|
13
|
+
isTexture?: boolean
|
|
14
|
+
isBufferGeometry?: boolean
|
|
15
|
+
isNode?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isRuntimeResource(value: object): boolean {
|
|
19
|
+
const flags = value as RuntimeResource
|
|
20
|
+
return Boolean(
|
|
21
|
+
flags.isObject3D ||
|
|
22
|
+
flags.isMaterial ||
|
|
23
|
+
flags.isTexture ||
|
|
24
|
+
flags.isBufferGeometry ||
|
|
25
|
+
flags.isNode,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cloneValue(value: unknown, seen: Map<object, unknown>): unknown {
|
|
30
|
+
if (typeof value === 'function') return undefined
|
|
31
|
+
if (value === null || typeof value !== 'object') return value
|
|
32
|
+
if (seen.has(value)) return seen.get(value)
|
|
33
|
+
if (isRuntimeResource(value)) return undefined
|
|
34
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
35
|
+
const copy = structuredClone(value)
|
|
36
|
+
seen.set(value, copy)
|
|
37
|
+
return copy
|
|
38
|
+
}
|
|
39
|
+
if (value instanceof Date || value instanceof RegExp) return structuredClone(value)
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
const copy: unknown[] = []
|
|
42
|
+
seen.set(value, copy)
|
|
43
|
+
for (const entry of value) copy.push(cloneValue(entry, seen))
|
|
44
|
+
return copy
|
|
45
|
+
}
|
|
46
|
+
if (value instanceof Map) {
|
|
47
|
+
const copy = new Map<unknown, unknown>()
|
|
48
|
+
seen.set(value, copy)
|
|
49
|
+
for (const [key, entry] of value) copy.set(cloneValue(key, seen), cloneValue(entry, seen))
|
|
50
|
+
return copy
|
|
51
|
+
}
|
|
52
|
+
if (value instanceof Set) {
|
|
53
|
+
const copy = new Set<unknown>()
|
|
54
|
+
seen.set(value, copy)
|
|
55
|
+
for (const entry of value) copy.add(cloneValue(entry, seen))
|
|
56
|
+
return copy
|
|
57
|
+
}
|
|
58
|
+
const copy: Record<string, unknown> = {}
|
|
59
|
+
seen.set(value, copy)
|
|
60
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
61
|
+
const cloned = cloneValue(entry, seen)
|
|
62
|
+
if (cloned !== undefined) copy[key] = cloned
|
|
63
|
+
}
|
|
64
|
+
return copy
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cloneExportUserData(userData: Record<string, unknown>): Record<string, unknown> {
|
|
68
|
+
return cloneValue(userData, new Map()) as Record<string, unknown>
|
|
69
|
+
}
|
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeDefinition,
|
|
5
|
+
BuildingNode,
|
|
5
6
|
type FloorplanGeometry,
|
|
6
7
|
type GeometryContext,
|
|
8
|
+
LevelNode,
|
|
7
9
|
loadPlugin,
|
|
8
10
|
type NodeCategory,
|
|
9
11
|
nodeRegistry,
|
|
10
12
|
registerNode,
|
|
11
13
|
} from '@pascal-app/core'
|
|
14
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
12
15
|
import PDFDocument from 'pdfkit'
|
|
13
16
|
import { z } from 'zod'
|
|
14
17
|
import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
|
15
18
|
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
|
|
16
19
|
import {
|
|
17
20
|
collectFloorplanGeometry,
|
|
21
|
+
collectFloorplanSchedules,
|
|
18
22
|
filterFloorplanExportOverlay,
|
|
19
23
|
fitPlanToBox,
|
|
20
24
|
isFloorplanExportAnnotationGeometry,
|
|
21
25
|
isFloorplanNodeInExportScope,
|
|
22
26
|
partitionFloorplanExportOverlay,
|
|
27
|
+
resolveExportLevels,
|
|
23
28
|
resolveFloorplanExportAnnotationVisibility,
|
|
24
29
|
resolveFloorplanExportNodeGeometry,
|
|
25
30
|
resolveFloorplanExportPlacement,
|
|
@@ -424,6 +429,81 @@ describe('isFloorplanNodeInExportScope', () => {
|
|
|
424
429
|
})
|
|
425
430
|
})
|
|
426
431
|
|
|
432
|
+
describe('collectFloorplanSchedules', () => {
|
|
433
|
+
test('omits non-structure schedule contributors under structure scope', () => {
|
|
434
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
435
|
+
const structureKind = 'test:structure-schedule'
|
|
436
|
+
const siteKind = 'test:site-schedule'
|
|
437
|
+
const levelId = 'level_schedules' as AnyNode['id']
|
|
438
|
+
const structureNodeId = 'structure_scheduled' as AnyNode['id']
|
|
439
|
+
const siteNodeId = 'site_scheduled' as AnyNode['id']
|
|
440
|
+
|
|
441
|
+
const scheduleFor = (title: string) => ({
|
|
442
|
+
id: title.toLowerCase(),
|
|
443
|
+
title,
|
|
444
|
+
columns: [{ key: 'id', label: 'ID' }],
|
|
445
|
+
rows: [{ id: 'row', cells: { id: '1' } }],
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
nodeRegistry._reset()
|
|
450
|
+
registerNode({
|
|
451
|
+
kind: structureKind,
|
|
452
|
+
schemaVersion: 1,
|
|
453
|
+
schema: z.object({ type: z.literal(structureKind) }) as never,
|
|
454
|
+
category: 'structure',
|
|
455
|
+
defaults: () => ({}) as never,
|
|
456
|
+
capabilities: {},
|
|
457
|
+
extensions: {
|
|
458
|
+
'pascal:editor/floorplan': {
|
|
459
|
+
schedule: () => scheduleFor('Doors'),
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
} as AnyNodeDefinition)
|
|
463
|
+
registerNode({
|
|
464
|
+
kind: siteKind,
|
|
465
|
+
schemaVersion: 1,
|
|
466
|
+
schema: z.object({ type: z.literal(siteKind) }) as never,
|
|
467
|
+
category: 'site',
|
|
468
|
+
defaults: () => ({}) as never,
|
|
469
|
+
capabilities: {},
|
|
470
|
+
extensions: {
|
|
471
|
+
'pascal:editor/floorplan': {
|
|
472
|
+
schedule: () => scheduleFor('Rooms'),
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
} as AnyNodeDefinition)
|
|
476
|
+
|
|
477
|
+
const nodes = {
|
|
478
|
+
[levelId]: {
|
|
479
|
+
id: levelId,
|
|
480
|
+
type: 'level',
|
|
481
|
+
visible: true,
|
|
482
|
+
children: [structureNodeId, siteNodeId],
|
|
483
|
+
},
|
|
484
|
+
[structureNodeId]: {
|
|
485
|
+
id: structureNodeId,
|
|
486
|
+
type: structureKind,
|
|
487
|
+
visible: true,
|
|
488
|
+
},
|
|
489
|
+
[siteNodeId]: {
|
|
490
|
+
id: siteNodeId,
|
|
491
|
+
type: siteKind,
|
|
492
|
+
visible: true,
|
|
493
|
+
},
|
|
494
|
+
} as unknown as Record<string, AnyNode>
|
|
495
|
+
|
|
496
|
+
const full = collectFloorplanSchedules(nodes, levelId, 'metric', 'full')
|
|
497
|
+
expect(full.map((schedule) => schedule.title).sort()).toEqual(['Doors', 'Rooms'])
|
|
498
|
+
|
|
499
|
+
const structure = collectFloorplanSchedules(nodes, levelId, 'metric', 'structure')
|
|
500
|
+
expect(structure.map((schedule) => schedule.title)).toEqual(['Doors'])
|
|
501
|
+
} finally {
|
|
502
|
+
restoreRegistry()
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
427
507
|
describe('collectFloorplanGeometry', () => {
|
|
428
508
|
test('collects the active Site once below architecture with semantic context and projection', async () => {
|
|
429
509
|
const restoreRegistry = nodeRegistry._snapshot()
|
|
@@ -694,7 +774,9 @@ describe('collectFloorplanGeometry', () => {
|
|
|
694
774
|
'finished-faces',
|
|
695
775
|
[enabledPluginId],
|
|
696
776
|
)
|
|
697
|
-
|
|
777
|
+
// A hidden Site hides only its own ground and boundary; the site-scoped
|
|
778
|
+
// nodes on it keep their own flag (see `hidesDescendants`).
|
|
779
|
+
expect(hiddenSite.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture'])
|
|
698
780
|
|
|
699
781
|
const structure = collectFloorplanGeometry(
|
|
700
782
|
nodes,
|
|
@@ -713,3 +795,61 @@ describe('collectFloorplanGeometry', () => {
|
|
|
713
795
|
}
|
|
714
796
|
})
|
|
715
797
|
})
|
|
798
|
+
|
|
799
|
+
describe('resolveExportLevels', () => {
|
|
800
|
+
const ground = LevelNode.parse({ id: 'level_ground', parentId: 'building_a', level: 0 })
|
|
801
|
+
const upper = LevelNode.parse({ id: 'level_upper', parentId: 'building_a', level: 1 })
|
|
802
|
+
const roof = LevelNode.parse({
|
|
803
|
+
id: 'level_roof',
|
|
804
|
+
parentId: 'building_a',
|
|
805
|
+
level: 2,
|
|
806
|
+
metadata: { role: 'roof', referenceLevelId: upper.id },
|
|
807
|
+
})
|
|
808
|
+
const attic = LevelNode.parse({
|
|
809
|
+
id: 'level_attic',
|
|
810
|
+
parentId: 'building_a',
|
|
811
|
+
level: 3,
|
|
812
|
+
metadata: { role: 'attic' },
|
|
813
|
+
})
|
|
814
|
+
const building = BuildingNode.parse({
|
|
815
|
+
id: 'building_a',
|
|
816
|
+
children: [ground.id, upper.id, roof.id, attic.id],
|
|
817
|
+
})
|
|
818
|
+
const nodes: Record<string, AnyNode> = Object.fromEntries(
|
|
819
|
+
[building, ground, upper, roof, attic].map((node) => [node.id, node]),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
// The viewer store is a process-wide singleton, so an earlier test file can
|
|
823
|
+
// leak a selection into these tests; restore it instead of leaving ours.
|
|
824
|
+
const previousSelection = useViewer.getState().selection
|
|
825
|
+
|
|
826
|
+
const selectLevel = (levelId: string | null) => {
|
|
827
|
+
useViewer.setState({
|
|
828
|
+
selection: { ...previousSelection, buildingId: building.id, levelId },
|
|
829
|
+
} as never)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
afterEach(() => {
|
|
833
|
+
useViewer.setState({ selection: previousSelection } as never)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
test('skips a dedicated roof support level', () => {
|
|
837
|
+
selectLevel(ground.id)
|
|
838
|
+
|
|
839
|
+
expect(resolveExportLevels(nodes)).toEqual([
|
|
840
|
+
{ id: ground.id, label: 'Level 0' },
|
|
841
|
+
{ id: upper.id, label: 'Level 1' },
|
|
842
|
+
{ id: attic.id, label: 'Level 3' },
|
|
843
|
+
])
|
|
844
|
+
})
|
|
845
|
+
|
|
846
|
+
test('skips the roof level when it is the selected level', () => {
|
|
847
|
+
selectLevel(roof.id)
|
|
848
|
+
|
|
849
|
+
expect(resolveExportLevels(nodes)).toEqual([
|
|
850
|
+
{ id: ground.id, label: 'Level 0' },
|
|
851
|
+
{ id: upper.id, label: 'Level 1' },
|
|
852
|
+
{ id: attic.id, label: 'Level 3' },
|
|
853
|
+
])
|
|
854
|
+
})
|
|
855
|
+
})
|
|
@@ -219,7 +219,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
|
|
219
219
|
wallDimensionReference,
|
|
220
220
|
installedPlugins,
|
|
221
221
|
)
|
|
222
|
-
const schedules = collectFloorplanSchedules(nodes, level.id, unit)
|
|
222
|
+
const schedules = collectFloorplanSchedules(nodes, level.id, unit, scope)
|
|
223
223
|
if (geometries.length === 0 && schedules.length === 0) continue
|
|
224
224
|
const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
|
|
225
225
|
|
|
@@ -305,6 +305,7 @@ export function collectFloorplanSchedules(
|
|
|
305
305
|
nodes: Record<string, AnyNode>,
|
|
306
306
|
levelId: AnyNodeId,
|
|
307
307
|
unit: 'metric' | 'imperial',
|
|
308
|
+
scope: FloorplanExportScope = 'full',
|
|
308
309
|
): FloorplanSchedule[] {
|
|
309
310
|
const siblingsByType = new Map<string, AnyNode[]>()
|
|
310
311
|
const visit = (id: AnyNodeId) => {
|
|
@@ -324,6 +325,9 @@ export function collectFloorplanSchedules(
|
|
|
324
325
|
for (const [kind, definition] of nodeRegistry.entries()) {
|
|
325
326
|
const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule
|
|
326
327
|
if (!scheduleContribution) continue
|
|
328
|
+
// Same scope gate as geometry: a structure-only PDF must not list rooms
|
|
329
|
+
// or other site-category contributors whose plan geometry was excluded.
|
|
330
|
+
if (!isFloorplanNodeInExportScope(definition, scope)) continue
|
|
327
331
|
const siblings = siblingsByType.get(kind) ?? []
|
|
328
332
|
const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
|
|
329
333
|
if (schedule && schedule.rows.length > 0) schedules.push(schedule)
|
|
@@ -1021,9 +1025,10 @@ function combineGeometryList(
|
|
|
1021
1025
|
* Levels to export, ordered bottom-to-top. The active building (the building
|
|
1022
1026
|
* owning the selected level, or the first one found) contributes all of its
|
|
1023
1027
|
* level children; if there is no building wrapper we fall back to the single
|
|
1024
|
-
* resolved level.
|
|
1028
|
+
* resolved level. Roof support levels are excluded: they are not occupied
|
|
1029
|
+
* stories, so they do not get a floor-plan page.
|
|
1025
1030
|
*/
|
|
1026
|
-
function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
|
1031
|
+
export function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
|
1027
1032
|
const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
|
|
1028
1033
|
const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
|
|
1029
1034
|
if (!activeLevelId) return []
|
|
@@ -1038,6 +1043,7 @@ function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
|
|
1038
1043
|
levelNodes = node ? [node] : []
|
|
1039
1044
|
}
|
|
1040
1045
|
|
|
1046
|
+
levelNodes = levelNodes.filter((n) => n.metadata.role !== 'roof')
|
|
1041
1047
|
levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
|
|
1042
1048
|
return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
|
|
1043
1049
|
}
|
|
@@ -57,13 +57,29 @@ export type FloorplanToolContext = {
|
|
|
57
57
|
|
|
58
58
|
export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
|
|
59
59
|
tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
|
|
60
|
+
directDrag?: boolean
|
|
60
61
|
availableModes?: readonly FloorplanToolMode[]
|
|
61
62
|
preferredView?: '2d' | '3d'
|
|
62
63
|
referencedSelectionAnnotationRole?: FloorplanAnnotationRole
|
|
63
64
|
contextualDimensions?: (node: N, ctx: GeometryContext) => FloorplanGeometry | null
|
|
64
65
|
actionMenu?: {
|
|
65
66
|
canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
|
|
67
|
+
/** Buttons the kind adds to the selection's action menu (2D and 3D); each decides its own visibility. */
|
|
68
|
+
actions?: () => Promise<{ default: ComponentType }>
|
|
66
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Plan layers mounted while a node of this kind is in a `reshaping` scope of
|
|
72
|
+
* that name (the 2D sibling of `def.affordanceTools[reshape]`).
|
|
73
|
+
*/
|
|
74
|
+
reshapeLayers?: Record<string, () => Promise<{ default: ComponentType<FloorplanToolContext> }>>
|
|
75
|
+
/**
|
|
76
|
+
* Nodes the plan treats as one with this node when toggling it out of a
|
|
77
|
+
* selection (a slab and its ceiling share an outline and a click).
|
|
78
|
+
*/
|
|
79
|
+
selectionCounterparts?: (args: {
|
|
80
|
+
node: N
|
|
81
|
+
nodes: Readonly<Record<AnyNodeId, AnyNode>>
|
|
82
|
+
}) => AnyNodeId[]
|
|
67
83
|
schedule?: (args: {
|
|
68
84
|
siblings: ReadonlyArray<N>
|
|
69
85
|
nodes: Readonly<Record<string, AnyNode>>
|
|
@@ -14,6 +14,8 @@ export type FloorplanViewState = {
|
|
|
14
14
|
highlighted: boolean
|
|
15
15
|
hovered: boolean
|
|
16
16
|
moving: boolean
|
|
17
|
+
focusedUnitId?: string
|
|
18
|
+
focusedUnitMemberIds?: readonly string[]
|
|
17
19
|
palette: FloorplanPalette | undefined
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -66,6 +68,8 @@ export function buildFloorplanContext(
|
|
|
66
68
|
highlighted: viewState.highlighted,
|
|
67
69
|
hovered: viewState.hovered,
|
|
68
70
|
moving: viewState.moving,
|
|
71
|
+
focusedUnitId: viewState.focusedUnitId,
|
|
72
|
+
focusedUnitMemberIds: viewState.focusedUnitMemberIds,
|
|
69
73
|
palette: viewState.palette,
|
|
70
74
|
}
|
|
71
75
|
: undefined,
|