@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.
Files changed (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. 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 { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core/schema'
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 'Guide image'
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
+ }