@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
|
|
2
|
-
import type
|
|
2
|
+
import { type AnyNode, type AnyNodeId, type LevelNode, UnitNode } from '@pascal-app/core/schema'
|
|
3
3
|
|
|
4
4
|
export type LevelDuplicatePreset =
|
|
5
5
|
| 'everything'
|
|
@@ -117,7 +117,7 @@ export function buildLevelDuplicateCreateOps({
|
|
|
117
117
|
levels: LevelNode[]
|
|
118
118
|
preset: LevelDuplicatePreset
|
|
119
119
|
}) {
|
|
120
|
-
const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
|
|
120
|
+
const { clonedNodes, newLevelId, idMap } = cloneLevelSubtree(nodes, level.id)
|
|
121
121
|
const parentBuildingId =
|
|
122
122
|
(level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id)
|
|
123
123
|
const nextLevelNumber = level.level + 1
|
|
@@ -145,6 +145,36 @@ export function buildLevelDuplicateCreateOps({
|
|
|
145
145
|
} as AnyNode
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
+
if (parentBuildingId && nodes[parentBuildingId]?.type === 'building') {
|
|
149
|
+
for (const unit of Object.values(nodes)) {
|
|
150
|
+
if (
|
|
151
|
+
unit.type !== 'unit' ||
|
|
152
|
+
unit.parentId !== parentBuildingId ||
|
|
153
|
+
unit.members.length === 0 ||
|
|
154
|
+
!unit.members.every((id) => {
|
|
155
|
+
const zone = nodes[id]
|
|
156
|
+
const remapped = idMap.get(id)
|
|
157
|
+
return (
|
|
158
|
+
zone?.type === 'zone' &&
|
|
159
|
+
zone.parentId === level.id &&
|
|
160
|
+
remapped !== undefined &&
|
|
161
|
+
keptIds.has(remapped as AnyNodeId)
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
cleanedNodes.push(
|
|
167
|
+
UnitNode.parse({
|
|
168
|
+
...unit,
|
|
169
|
+
id: undefined,
|
|
170
|
+
parentId: parentBuildingId,
|
|
171
|
+
name: `${unit.name} copy`,
|
|
172
|
+
members: unit.members.map((id) => idMap.get(id)),
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
148
178
|
return {
|
|
149
179
|
createOps: cleanedNodes.map((node) => ({
|
|
150
180
|
node:
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { getLinearDisplay } from './linear-display'
|
|
3
|
+
|
|
4
|
+
describe('linear property control units', () => {
|
|
5
|
+
test('keeps meter storage while displaying and accepting millimeters', () => {
|
|
6
|
+
const display = getLinearDisplay('m', 'metric', 'millimeters', 2, 0.05)
|
|
7
|
+
expect(display.displayUnit).toBe('mm')
|
|
8
|
+
expect(display.parseUnit).toBe('mm')
|
|
9
|
+
expect(display.toDisplay(2.5)).toBe(2500)
|
|
10
|
+
expect(display.toStored(1800)).toBe(1.8)
|
|
11
|
+
expect(display.toStored(-250)).toBe(-0.25)
|
|
12
|
+
expect(display.precision).toBe(0)
|
|
13
|
+
expect(display.step).toBe(50)
|
|
14
|
+
expect(display.toStored(display.step)).toBe(0.05)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('retains sub-millimeter precision when the field supports it', () => {
|
|
18
|
+
const display = getLinearDisplay('m', 'metric', 'millimeters', 4, 0.0001)
|
|
19
|
+
expect(display.precision).toBe(1)
|
|
20
|
+
expect(display.step).toBe(0.1)
|
|
21
|
+
expect(display.toDisplay(0.0125)).toBe(12.5)
|
|
22
|
+
expect(display.roundStored(0.01254)).toBe(0.0125)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('imperial preference takes precedence over the saved metric notation', () => {
|
|
26
|
+
const display = getLinearDisplay('m', 'imperial', 'millimeters', 2, 0.05)
|
|
27
|
+
expect(display.displayUnit).toBe('ft')
|
|
28
|
+
expect(display.parseUnit).toBe('ft')
|
|
29
|
+
expect(display.toDisplay(0.9144)).toBeCloseTo(3, 10)
|
|
30
|
+
expect(display.toStored(7)).toBeCloseTo(2.1336, 10)
|
|
31
|
+
expect(display.precision).toBe(2)
|
|
32
|
+
expect(display.step).toBe(0.05)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('switching notation round-trips the same stored dimensions', () => {
|
|
36
|
+
for (const stored of [0, 0.01, 0.125, 0.9, 2.1, -0.25]) {
|
|
37
|
+
for (const [unit, notation] of [
|
|
38
|
+
['metric', 'meters'],
|
|
39
|
+
['metric', 'millimeters'],
|
|
40
|
+
['imperial', 'meters'],
|
|
41
|
+
] as const) {
|
|
42
|
+
const display = getLinearDisplay('m', unit, notation, 2, 0.01)
|
|
43
|
+
expect(display.toStored(display.toDisplay(stored))).toBeCloseTo(stored, 12)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('leaves non-meter fields and their gesture steps unchanged', () => {
|
|
49
|
+
for (const unit of ['°', 'rad', '%', '', 'in', 'cm']) {
|
|
50
|
+
const display = getLinearDisplay(unit, 'metric', 'millimeters', 2, 0.05)
|
|
51
|
+
expect(display.displayUnit).toBe(unit)
|
|
52
|
+
expect(display.parseUnit).toBeUndefined()
|
|
53
|
+
expect(display.toDisplay(12.5)).toBe(12.5)
|
|
54
|
+
expect(display.toStored(12.5)).toBe(12.5)
|
|
55
|
+
expect(display.precision).toBe(2)
|
|
56
|
+
expect(display.step).toBe(0.05)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLinearUnitLabel,
|
|
3
|
+
type LinearUnit,
|
|
4
|
+
linearUnitToMeters,
|
|
5
|
+
type MetricNotation,
|
|
6
|
+
metersToLinearUnit,
|
|
7
|
+
} from './measurements'
|
|
8
|
+
|
|
9
|
+
export function getLinearDisplay(
|
|
10
|
+
unit: string,
|
|
11
|
+
viewerUnit: LinearUnit,
|
|
12
|
+
metricNotation: MetricNotation,
|
|
13
|
+
precision: number,
|
|
14
|
+
step: number,
|
|
15
|
+
) {
|
|
16
|
+
const isImperial = unit === 'm' && viewerUnit === 'imperial'
|
|
17
|
+
const isMillimeters = unit === 'm' && viewerUnit === 'metric' && metricNotation === 'millimeters'
|
|
18
|
+
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : isMillimeters ? 'mm' : unit
|
|
19
|
+
const displayPrecision = isMillimeters ? Math.max(0, precision - 3) : precision
|
|
20
|
+
const displayStep = isMillimeters ? step * 1000 : step
|
|
21
|
+
const parseUnit = isImperial ? 'ft' : isMillimeters ? 'mm' : undefined
|
|
22
|
+
const toDisplay = (stored: number) =>
|
|
23
|
+
isImperial ? metersToLinearUnit(stored, 'imperial') : isMillimeters ? stored * 1000 : stored
|
|
24
|
+
const toStored = (display: number) =>
|
|
25
|
+
isImperial ? linearUnitToMeters(display, 'imperial') : isMillimeters ? display / 1000 : display
|
|
26
|
+
const roundStored = (stored: number) =>
|
|
27
|
+
toStored(Number.parseFloat(toDisplay(stored).toFixed(displayPrecision)))
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isImperial,
|
|
31
|
+
displayUnit,
|
|
32
|
+
parseUnit,
|
|
33
|
+
precision: displayPrecision,
|
|
34
|
+
step: displayStep,
|
|
35
|
+
toDisplay,
|
|
36
|
+
toStored,
|
|
37
|
+
roundStored,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, mock, test } from 'bun:test'
|
|
2
|
+
import * as core from '@pascal-app/core'
|
|
3
|
+
|
|
4
|
+
const saveAsset = mock(async () => 'asset://stored-scan')
|
|
5
|
+
|
|
6
|
+
mock.module('@pascal-app/core', () => ({ ...core, saveAsset }))
|
|
7
|
+
|
|
8
|
+
const { createLocalScan } = await import('./local-guide-image')
|
|
9
|
+
|
|
10
|
+
test('createLocalScan stores and attaches a scan asset to its level', async () => {
|
|
11
|
+
const createNode = mock()
|
|
12
|
+
const file = new File(['scan data'], 'living-room.glb', { type: 'model/gltf-binary' })
|
|
13
|
+
|
|
14
|
+
const { scan, url } = await createLocalScan({ createNode, file, levelId: 'level_ground' })
|
|
15
|
+
|
|
16
|
+
expect(saveAsset).toHaveBeenCalledWith(file)
|
|
17
|
+
expect(scan).toMatchObject({
|
|
18
|
+
name: 'living-room',
|
|
19
|
+
type: 'scan',
|
|
20
|
+
url: 'asset://stored-scan',
|
|
21
|
+
})
|
|
22
|
+
expect(url).toBe('asset://stored-scan')
|
|
23
|
+
expect(createNode).toHaveBeenCalledWith(scan, 'level_ground')
|
|
24
|
+
})
|
|
@@ -2,13 +2,23 @@ import {
|
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
GuideNode,
|
|
4
4
|
type GuideNode as GuideNodeType,
|
|
5
|
+
ScanNode,
|
|
6
|
+
type ScanNode as ScanNodeType,
|
|
5
7
|
saveAsset,
|
|
6
8
|
} from '@pascal-app/core'
|
|
7
9
|
|
|
8
10
|
export function getGuideImageName(filename: string) {
|
|
11
|
+
return getAssetName(filename, 'Guide image')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getScanName(filename: string) {
|
|
15
|
+
return getAssetName(filename, 'Scan')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getAssetName(filename: string, fallback: string) {
|
|
9
19
|
const trimmed = filename.trim()
|
|
10
20
|
if (!trimmed) {
|
|
11
|
-
return
|
|
21
|
+
return fallback
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
const dotIndex = trimmed.lastIndexOf('.')
|
|
@@ -40,3 +50,26 @@ export async function createLocalGuideImage({
|
|
|
40
50
|
createNode(guide, levelId as AnyNodeId)
|
|
41
51
|
return guide
|
|
42
52
|
}
|
|
53
|
+
|
|
54
|
+
export async function createLocalScan({
|
|
55
|
+
createNode,
|
|
56
|
+
file,
|
|
57
|
+
levelId,
|
|
58
|
+
}: {
|
|
59
|
+
createNode: (node: ScanNodeType, parentId: AnyNodeId) => void
|
|
60
|
+
file: File
|
|
61
|
+
levelId: string
|
|
62
|
+
}) {
|
|
63
|
+
const assetUrl = await saveAsset(file)
|
|
64
|
+
const scan = ScanNode.parse({
|
|
65
|
+
name: getScanName(file.name),
|
|
66
|
+
url: assetUrl,
|
|
67
|
+
position: [0, 0, 0],
|
|
68
|
+
rotation: [0, 0, 0],
|
|
69
|
+
scale: 1,
|
|
70
|
+
opacity: 100,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
createNode(scan, levelId as AnyNodeId)
|
|
74
|
+
return { scan, url: assetUrl }
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getCatalogMaterialById,
|
|
5
|
+
getDynamicLibraryMaterials,
|
|
6
|
+
getLibraryMaterialIdFromRef,
|
|
7
|
+
getLibraryMaterialsVersion,
|
|
8
|
+
getMaterialsForCategory,
|
|
9
|
+
MATERIAL_CATEGORIES,
|
|
10
|
+
type MaterialSource,
|
|
11
|
+
subscribeLibraryMaterials,
|
|
12
|
+
toLibraryMaterialRef,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
import { useEffect, useState, useSyncExternalStore } from 'react'
|
|
15
|
+
|
|
16
|
+
export type MaterialSourceFilter = MaterialSource
|
|
17
|
+
const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
|
|
18
|
+
{ id: 'pascal', label: 'Pascal' },
|
|
19
|
+
{ id: 'mine', label: 'Mine' },
|
|
20
|
+
{ id: 'workspace', label: 'Workspace' },
|
|
21
|
+
{ id: 'community', label: 'Community' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export function useMaterialCatalogModel(
|
|
25
|
+
selectedMaterialPreset?: string,
|
|
26
|
+
onSelectMaterialPreset?: (ref: string) => void,
|
|
27
|
+
disabled = false,
|
|
28
|
+
) {
|
|
29
|
+
const [selectedCategory, setCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
|
30
|
+
MATERIAL_CATEGORIES[0],
|
|
31
|
+
)
|
|
32
|
+
const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('pascal')
|
|
33
|
+
useSyncExternalStore(
|
|
34
|
+
subscribeLibraryMaterials,
|
|
35
|
+
getLibraryMaterialsVersion,
|
|
36
|
+
getLibraryMaterialsVersion,
|
|
37
|
+
)
|
|
38
|
+
const availableCategories = MATERIAL_CATEGORIES.filter(
|
|
39
|
+
(category) => getMaterialsForCategory(category).length > 0,
|
|
40
|
+
)
|
|
41
|
+
const visibleSourceFilters = SOURCE_FILTERS.filter(
|
|
42
|
+
(filter) =>
|
|
43
|
+
filter.id !== 'workspace' ||
|
|
44
|
+
getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
|
|
45
|
+
)
|
|
46
|
+
const itemsFor = (category: typeof selectedCategory) =>
|
|
47
|
+
getMaterialsForCategory(category).filter((item) => (item.source ?? 'pascal') === sourceFilter)
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const entry = getCatalogMaterialById(
|
|
50
|
+
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined,
|
|
51
|
+
)
|
|
52
|
+
if (entry?.category) setCategory(entry.category)
|
|
53
|
+
}, [selectedMaterialPreset])
|
|
54
|
+
const select = (id: string) => {
|
|
55
|
+
if (!disabled) onSelectMaterialPreset?.(toLibraryMaterialRef(id))
|
|
56
|
+
}
|
|
57
|
+
const setSelectedCategory = (category: typeof selectedCategory) => {
|
|
58
|
+
setCategory(category)
|
|
59
|
+
const first = itemsFor(category)[0]
|
|
60
|
+
if (first) select(first.id)
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
selectedCategory,
|
|
64
|
+
setSelectedCategory,
|
|
65
|
+
sourceFilter,
|
|
66
|
+
setSourceFilter,
|
|
67
|
+
visibleSourceFilters,
|
|
68
|
+
availableCategories,
|
|
69
|
+
catalogItems: itemsFor(selectedCategory),
|
|
70
|
+
select,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
generateSceneMaterialId,
|
|
6
|
+
toSceneMaterialRef,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
10
|
+
import { useEffect } from 'react'
|
|
11
|
+
import useEditor from '../store/use-editor'
|
|
12
|
+
import { buildResetSurfaceMaterialUpdates, resolvePaintTargetFromSelection } from './material-paint'
|
|
13
|
+
|
|
14
|
+
export function useMaterialPaintPanelModel(enabled = true) {
|
|
15
|
+
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
|
16
|
+
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
17
|
+
const setActivePaintMaterial = useEditor((state) => state.armMaterialPaint)
|
|
18
|
+
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
|
19
|
+
const paintEraser = useEditor((state) => state.paintEraser)
|
|
20
|
+
const setPaintEraser = useEditor((state) => state.setPaintEraser)
|
|
21
|
+
const selectedId = useViewer((state) =>
|
|
22
|
+
state.selection.selectedIds.length === 1 ? (state.selection.selectedIds[0] ?? null) : null,
|
|
23
|
+
)
|
|
24
|
+
const paintTarget = useScene((state) =>
|
|
25
|
+
resolvePaintTargetFromSelection({ nodes: state.nodes, selectedId }),
|
|
26
|
+
)
|
|
27
|
+
const materials = useScene((state) => state.materials)
|
|
28
|
+
const canResetSelection = paintTarget !== null
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (enabled && paintTarget) setActivePaintTarget(paintTarget)
|
|
31
|
+
}, [enabled, paintTarget, setActivePaintTarget])
|
|
32
|
+
const resetSelection = () => {
|
|
33
|
+
const scene = useScene.getState()
|
|
34
|
+
const node = selectedId ? scene.nodes[selectedId as AnyNodeId] : undefined
|
|
35
|
+
if (node) scene.updateNodes(buildResetSurfaceMaterialUpdates(scene.nodes, node))
|
|
36
|
+
}
|
|
37
|
+
const createCustomMaterial = () => {
|
|
38
|
+
const scene = useScene.getState()
|
|
39
|
+
const id = generateSceneMaterialId()
|
|
40
|
+
scene.addSceneMaterial({
|
|
41
|
+
id,
|
|
42
|
+
name: `Material ${Object.keys(scene.materials).length + 1}`,
|
|
43
|
+
material: {
|
|
44
|
+
preset: 'custom',
|
|
45
|
+
properties: {
|
|
46
|
+
color: '#ffffff',
|
|
47
|
+
roughness: 0.5,
|
|
48
|
+
metalness: 0,
|
|
49
|
+
opacity: 1,
|
|
50
|
+
transparent: false,
|
|
51
|
+
side: 'front',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
setActivePaintMaterial({
|
|
56
|
+
materialPreset: toSceneMaterialRef(id),
|
|
57
|
+
sourceTarget: useEditor.getState().activePaintTarget,
|
|
58
|
+
})
|
|
59
|
+
return id
|
|
60
|
+
}
|
|
61
|
+
const selectMaterial = (materialPreset: string) =>
|
|
62
|
+
setActivePaintMaterial({ materialPreset, sourceTarget: useEditor.getState().activePaintTarget })
|
|
63
|
+
return {
|
|
64
|
+
activePaintMaterial,
|
|
65
|
+
activePaintTarget,
|
|
66
|
+
setActivePaintMaterial,
|
|
67
|
+
paintEraser,
|
|
68
|
+
setPaintEraser,
|
|
69
|
+
materials,
|
|
70
|
+
materialCount: Object.keys(materials).length,
|
|
71
|
+
canResetSelection,
|
|
72
|
+
resetSelection,
|
|
73
|
+
createCustomMaterial,
|
|
74
|
+
selectMaterial,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type AnyNode, createSceneApi, isMovable, nodeRegistry, useScene } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function registryMoveDisabled(node: AnyNode): boolean {
|
|
4
|
+
const def = nodeRegistry.get(node.type)
|
|
5
|
+
return Boolean(
|
|
6
|
+
def?.capabilities.movable &&
|
|
7
|
+
!isMovable(node) &&
|
|
8
|
+
!def.floorplanMoveTarget &&
|
|
9
|
+
!def.affordanceTools?.move,
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function duplicateWithoutMove(node: AnyNode) {
|
|
14
|
+
const api = createSceneApi(useScene)
|
|
15
|
+
const subtree = api.getSubtree(node.id)
|
|
16
|
+
if (!subtree || !node.parentId) return null
|
|
17
|
+
return api.cloneNodesInto([subtree.root, ...subtree.descendants], {
|
|
18
|
+
rootId: node.id,
|
|
19
|
+
parentId: node.parentId as AnyNode['id'],
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'bun:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Group } from 'three'
|
|
4
|
+
import { copyOverlayWorldTransform } from './overlay-transform'
|
|
5
|
+
|
|
6
|
+
test('opening overlays retain the source transform through God-mode zoom and rotation', () => {
|
|
7
|
+
const model = new Group()
|
|
8
|
+
const wall = new Group()
|
|
9
|
+
const opening = new Group()
|
|
10
|
+
model.add(wall)
|
|
11
|
+
wall.add(opening)
|
|
12
|
+
wall.rotation.y = 0.8
|
|
13
|
+
opening.position.set(2, 1.5, 0.1)
|
|
14
|
+
const portal = new Group()
|
|
15
|
+
portal.position.set(4, 1, -2)
|
|
16
|
+
portal.rotation.y = -0.4
|
|
17
|
+
const overlay = new Group()
|
|
18
|
+
portal.add(overlay)
|
|
19
|
+
for (const scale of [0.01, 0.2, 1, 4, 0.05]) {
|
|
20
|
+
model.scale.set(scale, scale * 2, scale)
|
|
21
|
+
model.rotation.y += 0.2
|
|
22
|
+
copyOverlayWorldTransform(opening, overlay)
|
|
23
|
+
overlay.updateWorldMatrix(true, false)
|
|
24
|
+
overlay.matrixWorld.elements.forEach((value, i) => {
|
|
25
|
+
assert.ok(Math.abs(value - opening.matrixWorld.elements[i]!) < 1e-8)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Object3D } from 'three'
|
|
2
|
+
|
|
3
|
+
/** Preserve scale and shear when an overlay is portalled outside its source hierarchy. */
|
|
4
|
+
export function copyOverlayWorldTransform(source: Object3D, overlay: Object3D) {
|
|
5
|
+
source.updateWorldMatrix(true, false)
|
|
6
|
+
overlay.parent?.updateWorldMatrix(true, false)
|
|
7
|
+
overlay.matrixAutoUpdate = false
|
|
8
|
+
if (overlay.parent)
|
|
9
|
+
overlay.matrix.copy(overlay.parent.matrixWorld).invert().multiply(source.matrixWorld)
|
|
10
|
+
else overlay.matrix.copy(source.matrixWorld)
|
|
11
|
+
overlay.matrixWorldNeedsUpdate = true
|
|
12
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeDefinition, AnyNodeId } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type PanelIcon = { color?: string; src?: string }
|
|
4
|
+
|
|
5
|
+
export type PanelAction = {
|
|
6
|
+
icon?: PanelIcon
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
id: string
|
|
9
|
+
label: string
|
|
10
|
+
onSelect?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PanelRow = { section?: string } & (
|
|
14
|
+
| {
|
|
15
|
+
id: string
|
|
16
|
+
kind: 'action'
|
|
17
|
+
icon?: PanelIcon
|
|
18
|
+
label: string
|
|
19
|
+
onSelect?: () => void
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
actions: PanelAction[]
|
|
24
|
+
id: string
|
|
25
|
+
kind: 'actions'
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
id: string
|
|
29
|
+
kind: 'choice'
|
|
30
|
+
label: string
|
|
31
|
+
onSelect?: () => void
|
|
32
|
+
value: string
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
getValue: () => string
|
|
36
|
+
id: string
|
|
37
|
+
kind: 'subscribed-choice'
|
|
38
|
+
label: string
|
|
39
|
+
onSelect?: () => void
|
|
40
|
+
subscribe: (listener: () => void) => () => void
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
id: string
|
|
44
|
+
kind: 'cycle'
|
|
45
|
+
label: string
|
|
46
|
+
next: () => void
|
|
47
|
+
previous: () => void
|
|
48
|
+
value: string
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
id: string
|
|
52
|
+
kind: 'stepper'
|
|
53
|
+
label: string
|
|
54
|
+
max: number
|
|
55
|
+
min: number
|
|
56
|
+
onChange: (value: number) => void
|
|
57
|
+
step: number
|
|
58
|
+
unit?: string
|
|
59
|
+
value: number
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
export const PANEL_MODEL_EXTENSION = 'pascal:editor/panel-model'
|
|
64
|
+
|
|
65
|
+
export type NodePanelModel<N extends AnyNode = AnyNode> = {
|
|
66
|
+
rows: (context: {
|
|
67
|
+
node: N
|
|
68
|
+
nodes: Record<AnyNodeId, AnyNode>
|
|
69
|
+
update: (patch: Partial<N>) => void
|
|
70
|
+
}) => PanelRow[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getNodePanelModel(definition: AnyNodeDefinition | undefined) {
|
|
74
|
+
return definition?.extensions?.[PANEL_MODEL_EXTENSION] as NodePanelModel | undefined
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { createToolHintsStore } from './panel-tool-options'
|
|
3
|
+
|
|
4
|
+
test('tool hints share subscriptions, react to visibility and chip state, and unsubscribe', () => {
|
|
5
|
+
let visible = false
|
|
6
|
+
let value = 'auto'
|
|
7
|
+
let subscriptions = 0
|
|
8
|
+
let cleanups = 0
|
|
9
|
+
const subscribe = (_listener: () => void) => {
|
|
10
|
+
subscriptions++
|
|
11
|
+
return () => {
|
|
12
|
+
cleanups++
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const store = createToolHintsStore([
|
|
16
|
+
{
|
|
17
|
+
key: 'P',
|
|
18
|
+
label: 'Placement',
|
|
19
|
+
visible: { subscribe, value: () => visible },
|
|
20
|
+
chip: { subscribe, value: () => value, cycle: () => {}, labels: { auto: 'Auto' } },
|
|
21
|
+
},
|
|
22
|
+
])
|
|
23
|
+
const cleanup = store.subscribe(() => {})
|
|
24
|
+
expect(subscriptions).toBe(1)
|
|
25
|
+
const hidden = store.getSnapshot()
|
|
26
|
+
visible = true
|
|
27
|
+
expect(store.getSnapshot()).not.toBe(hidden)
|
|
28
|
+
const shown = store.getSnapshot()
|
|
29
|
+
value = 'roof'
|
|
30
|
+
expect(store.getSnapshot()).not.toBe(shown)
|
|
31
|
+
expect(store.getSnapshot()).toBe(store.getSnapshot())
|
|
32
|
+
cleanup()
|
|
33
|
+
expect(cleanups).toBe(1)
|
|
34
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { nodeRegistry, type ToolHint, useRegistryVersion } from '@pascal-app/core'
|
|
4
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
5
|
+
import useEditor from '../store/use-editor'
|
|
6
|
+
|
|
7
|
+
export type PanelToolOption = {
|
|
8
|
+
id: string
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
choices: readonly { value: string; label: string; description?: string }[]
|
|
12
|
+
set: (value: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createToolHintsStore(hints: readonly ToolHint[]) {
|
|
16
|
+
return {
|
|
17
|
+
subscribe(listener: () => void) {
|
|
18
|
+
const subscriptions = hints.flatMap((hint) => [hint.chip?.subscribe, hint.visible?.subscribe])
|
|
19
|
+
const cleanups = [...new Set(subscriptions)].flatMap((subscribe) =>
|
|
20
|
+
subscribe ? [subscribe(listener)] : [],
|
|
21
|
+
)
|
|
22
|
+
return () => {
|
|
23
|
+
for (const cleanup of cleanups) cleanup()
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
getSnapshot: () =>
|
|
27
|
+
JSON.stringify(hints.map((hint) => [hint.visible?.value() ?? true, hint.chip?.value()])),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useVisibleToolHints(hints: readonly ToolHint[] = []) {
|
|
32
|
+
const draftVertexCount = useEditor((state) => state.draftVertexCount)
|
|
33
|
+
const store = useMemo(() => createToolHintsStore(hints), [hints])
|
|
34
|
+
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
35
|
+
return hints.filter(
|
|
36
|
+
(hint) =>
|
|
37
|
+
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
|
|
38
|
+
(hint.visible?.value() ?? true) &&
|
|
39
|
+
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function usePanelToolHints(kind: string | null | undefined) {
|
|
44
|
+
useRegistryVersion()
|
|
45
|
+
return useVisibleToolHints(kind ? nodeRegistry.get(kind)?.toolHints : undefined)
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
nodeRegistry,
|
|
5
|
+
type ParametricDescriptor,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
|
|
9
|
+
export function commitParametricNodeFields(
|
|
10
|
+
nodeId: AnyNodeId,
|
|
11
|
+
requestedPatch: Partial<AnyNode>,
|
|
12
|
+
): void {
|
|
13
|
+
const scene = useScene.getState()
|
|
14
|
+
const node = scene.nodes[nodeId]
|
|
15
|
+
if (!node) return
|
|
16
|
+
|
|
17
|
+
const parametrics = nodeRegistry.get(node.type)?.parametrics as
|
|
18
|
+
| ParametricDescriptor<AnyNode>
|
|
19
|
+
| undefined
|
|
20
|
+
let patch = requestedPatch as Record<string, unknown>
|
|
21
|
+
if (parametrics?.derive) {
|
|
22
|
+
const next = { ...node, ...patch } as AnyNode
|
|
23
|
+
patch = {
|
|
24
|
+
...patch,
|
|
25
|
+
...parametrics.derive(next, patch as Partial<AnyNode>, node),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
|
|
30
|
+
{ id: nodeId, data: patch as Partial<AnyNode> },
|
|
31
|
+
]
|
|
32
|
+
if (parametrics?.reconcile) {
|
|
33
|
+
const next = { ...node, ...patch } as AnyNode
|
|
34
|
+
updates.push(...parametrics.reconcile(node, next))
|
|
35
|
+
}
|
|
36
|
+
scene.updateNodes(updates)
|
|
37
|
+
}
|