@pascal-app/editor 0.7.0 → 0.9.1
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 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
generateId,
|
|
5
|
+
type LevelNode,
|
|
6
|
+
type StairNode,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
10
|
+
|
|
11
|
+
type ClipboardPayload = {
|
|
12
|
+
copiedAt: number
|
|
13
|
+
nodes: AnyNode[]
|
|
14
|
+
rootIds: AnyNodeId[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type PasteResult = {
|
|
18
|
+
pastedIds: AnyNodeId[]
|
|
19
|
+
skippedIds: AnyNodeId[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
|
|
23
|
+
'wall',
|
|
24
|
+
'fence',
|
|
25
|
+
'column',
|
|
26
|
+
'item',
|
|
27
|
+
'slab',
|
|
28
|
+
'ceiling',
|
|
29
|
+
'roof',
|
|
30
|
+
'stair',
|
|
31
|
+
'spawn',
|
|
32
|
+
'zone',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
let clipboardPayload: ClipboardPayload | null = null
|
|
36
|
+
const subscribers = new Set<() => void>()
|
|
37
|
+
|
|
38
|
+
function notifySubscribers() {
|
|
39
|
+
for (const subscriber of subscribers) {
|
|
40
|
+
subscriber()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function subscribeEditorClipboard(subscriber: () => void) {
|
|
45
|
+
subscribers.add(subscriber)
|
|
46
|
+
return () => {
|
|
47
|
+
subscribers.delete(subscriber)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getEditorClipboardSnapshot() {
|
|
52
|
+
return clipboardPayload
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function hasEditorClipboard() {
|
|
56
|
+
return !!clipboardPayload && clipboardPayload.rootIds.length > 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractIdPrefix(id: string) {
|
|
60
|
+
const underscoreIndex = id.indexOf('_')
|
|
61
|
+
return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function collectSubtreeIds(
|
|
65
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
66
|
+
rootId: AnyNodeId,
|
|
67
|
+
ids: Set<AnyNodeId>,
|
|
68
|
+
) {
|
|
69
|
+
if (ids.has(rootId)) return
|
|
70
|
+
const node = nodes[rootId]
|
|
71
|
+
if (!node) return
|
|
72
|
+
ids.add(rootId)
|
|
73
|
+
|
|
74
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
75
|
+
for (const childId of node.children as AnyNodeId[]) {
|
|
76
|
+
collectSubtreeIds(nodes, childId, ids)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasSelectedAncestor(
|
|
82
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
83
|
+
id: AnyNodeId,
|
|
84
|
+
selectedIds: Set<AnyNodeId>,
|
|
85
|
+
) {
|
|
86
|
+
let parentId = nodes[id]?.parentId as AnyNodeId | null
|
|
87
|
+
|
|
88
|
+
while (parentId) {
|
|
89
|
+
if (selectedIds.has(parentId)) return true
|
|
90
|
+
parentId = nodes[parentId]?.parentId as AnyNodeId | null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
|
|
97
|
+
const parentId = node.parentId as AnyNodeId | null
|
|
98
|
+
if (!parentId) return true
|
|
99
|
+
return nodes[parentId]?.type === 'level'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
|
|
103
|
+
const scene = useScene.getState()
|
|
104
|
+
const resolvedLevelId =
|
|
105
|
+
targetLevelId ?? (useViewer.getState().selection.levelId as AnyNodeId | null)
|
|
106
|
+
if (!resolvedLevelId) return null
|
|
107
|
+
|
|
108
|
+
const level = scene.nodes[resolvedLevelId]
|
|
109
|
+
return level?.type === 'level' ? level : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getNextLevelId(level: LevelNode, nodes: Record<AnyNodeId, AnyNode>) {
|
|
113
|
+
const parentId = level.parentId as AnyNodeId | null
|
|
114
|
+
if (!parentId) return null
|
|
115
|
+
|
|
116
|
+
const building = nodes[parentId]
|
|
117
|
+
if (!building || building.type !== 'building') return null
|
|
118
|
+
|
|
119
|
+
const siblingLevels = building.children
|
|
120
|
+
.map((childId) => nodes[childId as AnyNodeId])
|
|
121
|
+
.filter((node): node is LevelNode => node?.type === 'level')
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
siblingLevels
|
|
125
|
+
.filter((candidate) => candidate.level > level.level)
|
|
126
|
+
.sort((a, b) => a.level - b.level)[0]?.id ?? null
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function remapNodeReferences(
|
|
131
|
+
node: AnyNode,
|
|
132
|
+
oldId: AnyNodeId,
|
|
133
|
+
targetLevel: LevelNode,
|
|
134
|
+
idMap: Map<AnyNodeId, AnyNodeId>,
|
|
135
|
+
rootIds: Set<AnyNodeId>,
|
|
136
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
137
|
+
) {
|
|
138
|
+
const clone = JSON.parse(JSON.stringify(node)) as AnyNode
|
|
139
|
+
;(clone as Record<string, unknown>).id = idMap.get(oldId)
|
|
140
|
+
|
|
141
|
+
if (rootIds.has(oldId)) {
|
|
142
|
+
clone.parentId = targetLevel.id
|
|
143
|
+
} else if (clone.parentId && typeof clone.parentId === 'string') {
|
|
144
|
+
clone.parentId = idMap.get(clone.parentId as AnyNodeId) ?? clone.parentId
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if ('children' in clone && Array.isArray(clone.children)) {
|
|
148
|
+
;(clone as Record<string, unknown>).children = (clone.children as AnyNodeId[])
|
|
149
|
+
.map((childId) => idMap.get(childId))
|
|
150
|
+
.filter((childId): childId is AnyNodeId => !!childId)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if ('wallId' in clone && typeof clone.wallId === 'string') {
|
|
154
|
+
const nextWallId = idMap.get(clone.wallId as AnyNodeId)
|
|
155
|
+
if (nextWallId) {
|
|
156
|
+
;(clone as Record<string, unknown>).wallId = nextWallId
|
|
157
|
+
} else {
|
|
158
|
+
delete (clone as Record<string, unknown>).wallId
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (clone.type === 'stair') {
|
|
163
|
+
const nextLevelId = getNextLevelId(targetLevel, nodes)
|
|
164
|
+
;(clone as StairNode).fromLevelId = targetLevel.id
|
|
165
|
+
;(clone as StairNode).toLevelId = nextLevelId
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const metadata =
|
|
169
|
+
clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
|
|
170
|
+
? { ...(clone.metadata as Record<string, unknown>) }
|
|
171
|
+
: {}
|
|
172
|
+
delete metadata.isNew
|
|
173
|
+
delete metadata.isTransient
|
|
174
|
+
;(clone as Record<string, unknown>).metadata = metadata
|
|
175
|
+
|
|
176
|
+
return AnyNode.parse(clone)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
|
|
180
|
+
const scene = useScene.getState()
|
|
181
|
+
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
|
|
182
|
+
const selectedIdSet = new Set(ids)
|
|
183
|
+
const rootIds = ids.filter((id) => {
|
|
184
|
+
const node = scene.nodes[id]
|
|
185
|
+
return (
|
|
186
|
+
node &&
|
|
187
|
+
COPYABLE_ROOT_TYPES.has(node.type) &&
|
|
188
|
+
isLevelChildRoot(scene.nodes, node) &&
|
|
189
|
+
!hasSelectedAncestor(scene.nodes, id, selectedIdSet)
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
if (rootIds.length === 0) {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const subtreeIds = new Set<AnyNodeId>()
|
|
198
|
+
for (const rootId of rootIds) {
|
|
199
|
+
collectSubtreeIds(scene.nodes, rootId, subtreeIds)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
clipboardPayload = {
|
|
203
|
+
copiedAt: Date.now(),
|
|
204
|
+
nodes: [...subtreeIds]
|
|
205
|
+
.map((id) => scene.nodes[id])
|
|
206
|
+
.filter((node): node is AnyNode => !!node)
|
|
207
|
+
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
|
|
208
|
+
rootIds,
|
|
209
|
+
}
|
|
210
|
+
notifySubscribers()
|
|
211
|
+
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
|
|
216
|
+
const payload = clipboardPayload
|
|
217
|
+
const targetLevel = getPasteTargetLevel(targetLevelId)
|
|
218
|
+
if (!payload || !targetLevel) return null
|
|
219
|
+
|
|
220
|
+
const scene = useScene.getState()
|
|
221
|
+
const idMap = new Map<AnyNodeId, AnyNodeId>()
|
|
222
|
+
|
|
223
|
+
for (const node of payload.nodes) {
|
|
224
|
+
idMap.set(node.id as AnyNodeId, generateId(extractIdPrefix(node.id)) as AnyNodeId)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rootIdSet = new Set(payload.rootIds)
|
|
228
|
+
const pastedNodes: AnyNode[] = []
|
|
229
|
+
const skippedIds: AnyNodeId[] = []
|
|
230
|
+
|
|
231
|
+
for (const node of payload.nodes) {
|
|
232
|
+
try {
|
|
233
|
+
pastedNodes.push(
|
|
234
|
+
remapNodeReferences(node, node.id as AnyNodeId, targetLevel, idMap, rootIdSet, scene.nodes),
|
|
235
|
+
)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.error('Failed to paste copied node', node.id, error)
|
|
238
|
+
skippedIds.push(node.id as AnyNodeId)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (pastedNodes.length === 0) {
|
|
243
|
+
return { pastedIds: [], skippedIds }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
scene.createNodes(
|
|
247
|
+
pastedNodes.map((node) => ({
|
|
248
|
+
node,
|
|
249
|
+
parentId: (node.parentId as AnyNodeId | null) ?? undefined,
|
|
250
|
+
})),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const pastedNodeIds = new Set(pastedNodes.map((node) => node.id as AnyNodeId))
|
|
254
|
+
const pastedRootIds = payload.rootIds
|
|
255
|
+
.map((rootId) => idMap.get(rootId))
|
|
256
|
+
.filter((id): id is AnyNodeId => !!id && pastedNodeIds.has(id))
|
|
257
|
+
|
|
258
|
+
useViewer.getState().setSelection({
|
|
259
|
+
levelId: targetLevel.id,
|
|
260
|
+
selectedIds: pastedRootIds,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
pastedIds: pastedRootIds,
|
|
265
|
+
skippedIds,
|
|
266
|
+
}
|
|
267
|
+
}
|
package/src/lib/scene.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
|
|
3
|
+
import { nodeRegistry, resolveLevelId, sceneRegistry, useScene } from '@pascal-app/core'
|
|
4
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
5
5
|
import useEditor, {
|
|
6
6
|
hasCustomPersistedEditorUiState,
|
|
@@ -220,7 +220,11 @@ function getValidatedSelectionForScene(
|
|
|
220
220
|
|
|
221
221
|
const selectedIds = selection.selectedIds.filter((id) => {
|
|
222
222
|
const node = sceneNodes[id]
|
|
223
|
-
|
|
223
|
+
if (!node) return false
|
|
224
|
+
if (resolveLevelId(node, sceneNodes) === levelId) return true
|
|
225
|
+
|
|
226
|
+
const def = nodeRegistry.get(node.type)
|
|
227
|
+
return def?.floorplanScope === 'building' && node.parentId === buildingId
|
|
224
228
|
})
|
|
225
229
|
|
|
226
230
|
return {
|
|
@@ -283,7 +287,11 @@ export function syncEditorSelectionFromCurrentScene() {
|
|
|
283
287
|
|
|
284
288
|
if (shouldRestoreEditorUiState) {
|
|
285
289
|
if (restoredSelection) {
|
|
286
|
-
|
|
290
|
+
// PersistedSelectionPath carries plain `string` ids (read from
|
|
291
|
+
// localStorage, no branded-template-literal guarantee). The viewer's
|
|
292
|
+
// SelectionPath expects branded ids. The runtime values match the
|
|
293
|
+
// brand; the cast bridges the static gap.
|
|
294
|
+
useViewer.getState().setSelection(restoredSelection as never)
|
|
287
295
|
useEditor.setState(
|
|
288
296
|
restoredEditorUiState.phase === 'site'
|
|
289
297
|
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
|
|
@@ -305,7 +313,7 @@ export function syncEditorSelectionFromCurrentScene() {
|
|
|
305
313
|
}
|
|
306
314
|
|
|
307
315
|
if (restoredSelection) {
|
|
308
|
-
useViewer.getState().setSelection(restoredSelection)
|
|
316
|
+
useViewer.getState().setSelection(restoredSelection as never)
|
|
309
317
|
if (selectionDrivenEditorUiState) {
|
|
310
318
|
useEditor.setState(selectionDrivenEditorUiState)
|
|
311
319
|
}
|
|
@@ -351,6 +359,7 @@ function resetEditorInteractionState() {
|
|
|
351
359
|
selectedReferenceId: null,
|
|
352
360
|
spaces: {},
|
|
353
361
|
editingHole: null,
|
|
362
|
+
hoveredHole: null,
|
|
354
363
|
isPreviewMode: false,
|
|
355
364
|
})
|
|
356
365
|
}
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -10,8 +10,12 @@ type SFXEvents = {
|
|
|
10
10
|
'sfx:item-pick': undefined
|
|
11
11
|
'sfx:item-place': undefined
|
|
12
12
|
'sfx:item-rotate': undefined
|
|
13
|
+
'sfx:structure-build-start': undefined
|
|
13
14
|
'sfx:structure-build': undefined
|
|
14
15
|
'sfx:structure-delete': undefined
|
|
16
|
+
'sfx:snapshot-capture': undefined
|
|
17
|
+
'sfx:menu-hover': undefined
|
|
18
|
+
'sfx:menu-click': undefined
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -35,8 +39,12 @@ export function initSFXBus() {
|
|
|
35
39
|
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
|
36
40
|
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
|
37
41
|
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
|
38
|
-
sfxEmitter.on('sfx:structure-build', () => playSFX('
|
|
42
|
+
sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
|
|
43
|
+
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
|
|
39
44
|
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
|
45
|
+
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
|
|
46
|
+
sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
|
|
47
|
+
sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
@@ -47,3 +55,19 @@ export function initSFXBus() {
|
|
|
47
55
|
export function triggerSFX(event: keyof SFXEvents) {
|
|
48
56
|
sfxEmitter.emit(event)
|
|
49
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Node types whose deletion should use the lighter item-delete cue rather
|
|
61
|
+
* than the heavier structure-delete one. Shelves are furniture-like placeable
|
|
62
|
+
* objects, so they sound like items being removed, not structures demolished.
|
|
63
|
+
*/
|
|
64
|
+
const ITEM_DELETE_NODE_TYPES = new Set(['item', 'shelf'])
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Emit the delete SFX appropriate for a deleted node's type.
|
|
68
|
+
*/
|
|
69
|
+
export function emitDeleteSFX(nodeType: string | undefined) {
|
|
70
|
+
sfxEmitter.emit(
|
|
71
|
+
nodeType && ITEM_DELETE_NODE_TYPES.has(nodeType) ? 'sfx:item-delete' : 'sfx:structure-delete',
|
|
72
|
+
)
|
|
73
|
+
}
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -2,10 +2,11 @@ import { Howl } from 'howler'
|
|
|
2
2
|
import useAudio from '../store/use-audio'
|
|
3
3
|
|
|
4
4
|
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
|
|
5
|
-
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones
|
|
5
|
+
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
|
|
6
6
|
// machine-gun feeling when the same SFX fires in rapid succession.
|
|
7
7
|
type SFXConfig = {
|
|
8
|
-
|
|
8
|
+
// One file, or several pre-rendered variations cycled round-robin per play.
|
|
9
|
+
src: string | string[]
|
|
9
10
|
// Random playback-rate range applied per play (1 = unchanged).
|
|
10
11
|
rateRange?: [number, number]
|
|
11
12
|
// Random volume multiplier range applied per play (1 = unchanged).
|
|
@@ -13,8 +14,8 @@ type SFXConfig = {
|
|
|
13
14
|
// Minimum gap between two plays of this SFX. Triggers within this window
|
|
14
15
|
// are silently dropped so bursty sequences don't phase-stack into noise.
|
|
15
16
|
minIntervalMs?: number
|
|
16
|
-
// Random stereo pan per play
|
|
17
|
-
// right). A small value like 0.15 keeps things
|
|
17
|
+
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
|
|
18
|
+
// right). A small value like 0.15 keeps things centred but adds just enough
|
|
18
19
|
// spread to stop repeats from stacking on the same point in the field.
|
|
19
20
|
panJitter?: number
|
|
20
21
|
}
|
|
@@ -24,26 +25,31 @@ const DEFAULT_MIN_INTERVAL_MS = 30
|
|
|
24
25
|
// SFX sound definitions
|
|
25
26
|
export const SFX: Record<string, SFXConfig> = {
|
|
26
27
|
gridSnap: {
|
|
27
|
-
src:
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
src: [
|
|
29
|
+
'/audios/sfx/grid_snap_0.mp3',
|
|
30
|
+
'/audios/sfx/grid_snap_1.mp3',
|
|
31
|
+
'/audios/sfx/grid_snap_2.mp3',
|
|
32
|
+
],
|
|
33
|
+
rateRange: [0.98, 1.02],
|
|
34
|
+
volumeRange: [0.5, 0.6],
|
|
30
35
|
panJitter: 0.15,
|
|
36
|
+
minIntervalMs: 50,
|
|
31
37
|
},
|
|
32
38
|
itemDelete: {
|
|
33
39
|
src: '/audios/sfx/item_delete.mp3',
|
|
34
40
|
rateRange: [0.9, 1.1],
|
|
35
41
|
volumeRange: [0.9, 1.0],
|
|
36
|
-
panJitter: 0.
|
|
42
|
+
panJitter: 0.05,
|
|
37
43
|
},
|
|
38
44
|
itemPick: {
|
|
39
45
|
src: '/audios/sfx/item_pick.mp3',
|
|
40
|
-
rateRange: [0.
|
|
46
|
+
rateRange: [0.95, 1.05],
|
|
41
47
|
volumeRange: [0.92, 1.0],
|
|
42
48
|
panJitter: 0.15,
|
|
43
49
|
},
|
|
44
50
|
itemPlace: {
|
|
45
51
|
src: '/audios/sfx/item_place.mp3',
|
|
46
|
-
rateRange: [0.98, 1.
|
|
52
|
+
rateRange: [0.98, 1.02],
|
|
47
53
|
volumeRange: [0.9, 1.0],
|
|
48
54
|
panJitter: 0.15,
|
|
49
55
|
},
|
|
@@ -53,8 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
53
59
|
volumeRange: [0.92, 1.0],
|
|
54
60
|
panJitter: 0.15,
|
|
55
61
|
},
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
|
63
|
+
structureBuildStart: {
|
|
64
|
+
src: '/audios/sfx/structure_build_start.mp3',
|
|
65
|
+
rateRange: [0.95, 1.05],
|
|
66
|
+
volumeRange: [0.88, 1.0],
|
|
67
|
+
panJitter: 0.15,
|
|
68
|
+
},
|
|
69
|
+
// Fired when a structure is committed (segment placed / polygon closed).
|
|
70
|
+
structureBuildEnd: {
|
|
71
|
+
src: '/audios/sfx/structure_build_end.mp3',
|
|
58
72
|
rateRange: [0.95, 1.05],
|
|
59
73
|
volumeRange: [0.88, 1.0],
|
|
60
74
|
panJitter: 0.15,
|
|
@@ -63,12 +77,29 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
63
77
|
src: '/audios/sfx/structure_delete.mp3',
|
|
64
78
|
rateRange: [0.9, 1.1],
|
|
65
79
|
volumeRange: [0.9, 1.0],
|
|
66
|
-
panJitter: 0.
|
|
80
|
+
panJitter: 0.08,
|
|
67
81
|
},
|
|
68
82
|
snapshotCapture: {
|
|
69
|
-
// Shutter should sound consistent
|
|
83
|
+
// Shutter should sound consistent — no variation.
|
|
70
84
|
src: '/audios/sfx/snapshot_capture.mp3',
|
|
71
85
|
},
|
|
86
|
+
// Soft tick when hovering a main category in the Build / Items panels.
|
|
87
|
+
// Kept quiet and rate-locked so sweeping across the grid reads as texture,
|
|
88
|
+
// not a melody.
|
|
89
|
+
menuHover: {
|
|
90
|
+
src: '/audios/sfx/menu_hover.mp3',
|
|
91
|
+
rateRange: [0.98, 1.02],
|
|
92
|
+
volumeRange: [0.2, 0.3],
|
|
93
|
+
panJitter: 0.1,
|
|
94
|
+
minIntervalMs: 0,
|
|
95
|
+
},
|
|
96
|
+
// Fired when a main category in the Build / Items panels is clicked.
|
|
97
|
+
menuClick: {
|
|
98
|
+
src: '/audios/sfx/menu_click.mp3',
|
|
99
|
+
rateRange: [0.98, 1.02],
|
|
100
|
+
volumeRange: [0.5, 0.6],
|
|
101
|
+
panJitter: 0.1,
|
|
102
|
+
},
|
|
72
103
|
} as const
|
|
73
104
|
|
|
74
105
|
export type SFXName = keyof typeof SFX
|
|
@@ -77,32 +108,38 @@ function randomInRange([min, max]: [number, number]): number {
|
|
|
77
108
|
return min + Math.random() * (max - min)
|
|
78
109
|
}
|
|
79
110
|
|
|
80
|
-
// Preload all SFX sounds
|
|
81
|
-
|
|
111
|
+
// Preload all SFX sounds. Each variation gets its own Howl so they can overlap
|
|
112
|
+
// and be cycled round-robin.
|
|
113
|
+
const sfxCache = new Map<SFXName, Howl[]>()
|
|
82
114
|
const lastPlayedAt = new Map<SFXName, number>()
|
|
115
|
+
const lastVariation = new Map<SFXName, number>()
|
|
83
116
|
|
|
84
117
|
// Initialize all sounds
|
|
85
118
|
Object.entries(SFX).forEach(([name, config]) => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
119
|
+
const sources = Array.isArray(config.src) ? config.src : [config.src]
|
|
120
|
+
const sounds = sources.map(
|
|
121
|
+
(src) =>
|
|
122
|
+
new Howl({
|
|
123
|
+
src: [src],
|
|
124
|
+
preload: true,
|
|
125
|
+
volume: 0.5, // Will be adjusted by the bus
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
sfxCache.set(name as SFXName, sounds)
|
|
92
129
|
})
|
|
93
130
|
|
|
94
131
|
/**
|
|
95
132
|
* Play a sound effect with volume based on audio settings
|
|
96
133
|
*/
|
|
97
134
|
export function playSFX(name: SFXName) {
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
135
|
+
const sounds = sfxCache.get(name)
|
|
136
|
+
if (!sounds || sounds.length === 0) {
|
|
100
137
|
console.warn(`SFX not found: ${name}`)
|
|
101
138
|
return
|
|
102
139
|
}
|
|
103
140
|
const config = SFX[name]!
|
|
104
141
|
|
|
105
|
-
// Drop rapid repeats
|
|
142
|
+
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
|
|
106
143
|
// smear into noise, they don't add useful information.
|
|
107
144
|
const now = performance.now()
|
|
108
145
|
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
|
@@ -110,6 +147,15 @@ export function playSFX(name: SFXName) {
|
|
|
110
147
|
if (last !== undefined && now - last < minInterval) return
|
|
111
148
|
lastPlayedAt.set(name, now)
|
|
112
149
|
|
|
150
|
+
// Pick a random variation, avoiding an immediate repeat of the last one so
|
|
151
|
+
// consecutive plays don't land on the same file.
|
|
152
|
+
let index = Math.floor(Math.random() * sounds.length)
|
|
153
|
+
if (sounds.length > 1 && index === lastVariation.get(name)) {
|
|
154
|
+
index = (index + 1) % sounds.length
|
|
155
|
+
}
|
|
156
|
+
lastVariation.set(name, index)
|
|
157
|
+
const sound = sounds[index]!
|
|
158
|
+
|
|
113
159
|
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
|
114
160
|
|
|
115
161
|
if (muted) return
|
|
@@ -137,7 +183,9 @@ export function updateSFXVolumes() {
|
|
|
137
183
|
const { masterVolume, sfxVolume } = useAudio.getState()
|
|
138
184
|
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
|
139
185
|
|
|
140
|
-
sfxCache.forEach((
|
|
141
|
-
|
|
186
|
+
sfxCache.forEach((sounds) => {
|
|
187
|
+
sounds.forEach((sound) => {
|
|
188
|
+
sound.volume(finalVolume)
|
|
189
|
+
})
|
|
142
190
|
})
|
|
143
191
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
generateId,
|
|
3
2
|
type AnyNodeId,
|
|
4
|
-
|
|
3
|
+
generateId,
|
|
5
4
|
type StairNode,
|
|
6
5
|
StairNode as StairNodeSchema,
|
|
7
6
|
type StairSegmentNode,
|
|
8
7
|
StairSegmentNode as StairSegmentNodeSchema,
|
|
8
|
+
sceneRegistry,
|
|
9
9
|
useScene,
|
|
10
10
|
} from '@pascal-app/core'
|
|
11
11
|
import { useViewer } from '@pascal-app/viewer'
|