@pascal-app/editor 1.0.0-beta.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type AnyNode, RoofSegmentNode, SlabNode, WallNode } from '@pascal-app/core'
3
+ import type { PrintExportReport } from './print-export'
4
+ import {
5
+ applySemanticPrintFeatureThickness,
6
+ measureSemanticPrintFeatureThickness,
7
+ } from './print-feature-thickness'
8
+
9
+ const nodes = {
10
+ wall_test: WallNode.parse({
11
+ id: 'wall_test',
12
+ parentId: 'level_test',
13
+ start: [0, 0],
14
+ end: [4, 0],
15
+ thickness: 0.2,
16
+ }),
17
+ slab_test: SlabNode.parse({
18
+ id: 'slab_test',
19
+ parentId: 'level_test',
20
+ polygon: [
21
+ [0, 0],
22
+ [4, 0],
23
+ [4, 3],
24
+ ],
25
+ thickness: 0.25,
26
+ }),
27
+ rseg_test: RoofSegmentNode.parse({
28
+ id: 'rseg_test',
29
+ parentId: 'level_test',
30
+ wallThickness: 0.15,
31
+ deckThickness: 0.12,
32
+ shingleThickness: 0.03,
33
+ }),
34
+ } satisfies Record<string, AnyNode>
35
+
36
+ const report: PrintExportReport = {
37
+ kind: 'print-export-report',
38
+ version: 2,
39
+ format: '3mf',
40
+ scale: 100,
41
+ units: 'millimeter',
42
+ orientation: 'z-up',
43
+ status: 'pass',
44
+ bounds: null,
45
+ triangleCount: 12,
46
+ invalidTriangleCount: 0,
47
+ degenerateTriangleCount: 0,
48
+ boundaryEdgeCount: 0,
49
+ nonManifoldEdgeCount: 0,
50
+ connectedComponentCount: 1,
51
+ solidComponentCount: 1,
52
+ invertedWinding: false,
53
+ volumeMm3: 100,
54
+ diagnostics: [
55
+ {
56
+ severity: 'info',
57
+ code: 'compiler_limits',
58
+ message: 'Old compiler limit message.',
59
+ },
60
+ ],
61
+ }
62
+
63
+ describe('print feature thickness', () => {
64
+ test('measures semantic wall, slab, and roof dimensions at print scale', () => {
65
+ const measurement = measureSemanticPrintFeatureThickness(
66
+ nodes,
67
+ ['slab_test', 'rseg_test', 'wall_test'],
68
+ 100,
69
+ )
70
+
71
+ expect(measurement).toEqual({
72
+ features: [
73
+ { nodeId: 'rseg_test', thicknessMm: 1.5 },
74
+ { nodeId: 'slab_test', thicknessMm: 2.5 },
75
+ { nodeId: 'wall_test', thicknessMm: 2 },
76
+ ],
77
+ unmeasuredNodeIds: [],
78
+ })
79
+ })
80
+
81
+ test('blocks located semantic features below a custom target', () => {
82
+ const measured = applySemanticPrintFeatureThickness(report, nodes, Object.keys(nodes), 1.8)
83
+
84
+ expect(measured.status).toBe('blocked')
85
+ expect(measured.minimumFeatureThicknessMm).toBeCloseTo(1.5)
86
+ expect(measured.diagnostics).toContainEqual(
87
+ expect.objectContaining({
88
+ code: 'feature_below_target',
89
+ nodeIds: ['rseg_test'],
90
+ }),
91
+ )
92
+ expect(measured.diagnostics).toContainEqual(
93
+ expect.objectContaining({
94
+ code: 'minimum_feature_thickness',
95
+ nodeIds: ['rseg_test'],
96
+ }),
97
+ )
98
+ })
99
+
100
+ test('includes the canonical Dutch top-rake slab in roof measurement', () => {
101
+ const dutch = RoofSegmentNode.parse({
102
+ id: 'rseg_dutch-test',
103
+ parentId: 'level_test',
104
+ roofType: 'dutch',
105
+ wallThickness: 0.2,
106
+ deckThickness: 0.12,
107
+ shingleThickness: 0.03,
108
+ dutchTopRakeThickness: 0.05,
109
+ })
110
+
111
+ expect(
112
+ measureSemanticPrintFeatureThickness({ [dutch.id]: dutch }, [dutch.id], 100).features,
113
+ ).toEqual([{ nodeId: dutch.id, thicknessMm: 0.5 }])
114
+ })
115
+
116
+ test('does not certify a custom target when source-node coverage is incomplete', () => {
117
+ const measured = applySemanticPrintFeatureThickness(
118
+ report,
119
+ nodes,
120
+ ['wall_test', 'column_unmeasured'],
121
+ 1.5,
122
+ )
123
+
124
+ expect(measured.status).toBe('blocked')
125
+ expect(measured.minimumFeatureThicknessMm).toBe(2)
126
+ expect(measured.diagnostics).toContainEqual(
127
+ expect.objectContaining({
128
+ severity: 'error',
129
+ code: 'feature_thickness_incomplete',
130
+ nodeIds: ['column_unmeasured'],
131
+ }),
132
+ )
133
+ })
134
+
135
+ test('does not certify a custom target from an empty measurement set', () => {
136
+ const measured = applySemanticPrintFeatureThickness(report, nodes, [], 1.5)
137
+
138
+ expect(measured.status).toBe('blocked')
139
+ expect(measured.minimumFeatureThicknessMm).toBeNull()
140
+ expect(measured.diagnostics).toContainEqual(
141
+ expect.objectContaining({
142
+ severity: 'error',
143
+ code: 'feature_thickness_incomplete',
144
+ }),
145
+ )
146
+ })
147
+ })
@@ -0,0 +1,180 @@
1
+ import { type AnyNode, getWallThickness } from '@pascal-app/core'
2
+ import {
3
+ mergePrintExportDiagnostics,
4
+ type PrintExportDiagnostic,
5
+ type PrintExportReport,
6
+ } from './print-export'
7
+
8
+ const MILLIMETERS_PER_METER = 1000
9
+
10
+ export type PrintFeatureThickness = {
11
+ nodeId: string
12
+ thicknessMm: number
13
+ }
14
+
15
+ export type PrintFeatureThicknessMeasurement = {
16
+ features: PrintFeatureThickness[]
17
+ unmeasuredNodeIds: string[]
18
+ }
19
+
20
+ const FEATURE_DIAGNOSTIC_CODES = new Set([
21
+ 'minimum_feature_thickness',
22
+ 'feature_below_target',
23
+ 'feature_thickness_incomplete',
24
+ ])
25
+
26
+ function semanticThicknessMeters(node: AnyNode): number | null {
27
+ switch (node.type) {
28
+ case 'wall':
29
+ return getWallThickness(node)
30
+ case 'slab':
31
+ return node.thickness
32
+ case 'roof-segment': {
33
+ const structuralThicknesses = [node.wallThickness, node.deckThickness + node.shingleThickness]
34
+ if (node.roofType === 'dutch') structuralThicknesses.push(node.dutchTopRakeThickness)
35
+ return Math.min(...structuralThicknesses)
36
+ }
37
+ default:
38
+ return null
39
+ }
40
+ }
41
+
42
+ function formatThickness(value: number): string {
43
+ return value
44
+ .toFixed(3)
45
+ .replace(/\.000$/, '')
46
+ .replace(/(\.\d*[1-9])0+$/, '$1')
47
+ }
48
+
49
+ export function measureSemanticPrintFeatureThickness(
50
+ nodes: Record<string, AnyNode>,
51
+ sourceNodeIds: Iterable<string>,
52
+ scale: number,
53
+ ): PrintFeatureThicknessMeasurement {
54
+ if (!Number.isFinite(scale) || scale <= 0) {
55
+ throw new RangeError('Print scale must be a positive finite denominator')
56
+ }
57
+
58
+ const features: PrintFeatureThickness[] = []
59
+ const unmeasuredNodeIds: string[] = []
60
+ for (const nodeId of Array.from(new Set(sourceNodeIds)).sort()) {
61
+ const node = nodes[nodeId]
62
+ const thicknessMeters = node ? semanticThicknessMeters(node) : null
63
+ if (thicknessMeters === null || !Number.isFinite(thicknessMeters) || thicknessMeters <= 0) {
64
+ unmeasuredNodeIds.push(nodeId)
65
+ continue
66
+ }
67
+ features.push({
68
+ nodeId,
69
+ thicknessMm: (thicknessMeters * MILLIMETERS_PER_METER) / scale,
70
+ })
71
+ }
72
+
73
+ return { features, unmeasuredNodeIds }
74
+ }
75
+
76
+ export function applyPrintFeatureThickness(
77
+ report: PrintExportReport,
78
+ measurement: PrintFeatureThicknessMeasurement,
79
+ minimumFeatureMm?: number,
80
+ ): PrintExportReport {
81
+ if (
82
+ minimumFeatureMm !== undefined &&
83
+ (!Number.isFinite(minimumFeatureMm) || minimumFeatureMm <= 0)
84
+ ) {
85
+ throw new RangeError('Minimum print feature target must be positive and finite')
86
+ }
87
+
88
+ const minimum = measurement.features.reduce<PrintFeatureThickness | null>(
89
+ (current, feature) =>
90
+ !current || feature.thicknessMm < current.thicknessMm ? feature : current,
91
+ null,
92
+ )
93
+ const minimumNodeIds = minimum
94
+ ? measurement.features
95
+ .filter((feature) => Math.abs(feature.thicknessMm - minimum.thicknessMm) <= 1e-9)
96
+ .map((feature) => feature.nodeId)
97
+ .sort()
98
+ : []
99
+ const diagnostics: PrintExportDiagnostic[] = [
100
+ {
101
+ severity: 'info',
102
+ code: 'compiler_limits',
103
+ message:
104
+ 'Known semantic or generated feature dimensions were measured; mesh-observed thin features and self-intersections are not checked.',
105
+ },
106
+ ]
107
+
108
+ if (minimum) {
109
+ diagnostics.push({
110
+ severity: 'info',
111
+ code: 'minimum_feature_thickness',
112
+ message: `Minimum known feature thickness is ${formatThickness(minimum.thicknessMm)} mm.`,
113
+ nodeIds: minimumNodeIds,
114
+ })
115
+ }
116
+
117
+ if (minimumFeatureMm !== undefined) {
118
+ const belowTargetNodeIds = measurement.features
119
+ .filter((feature) => feature.thicknessMm < minimumFeatureMm)
120
+ .map((feature) => feature.nodeId)
121
+ .sort()
122
+ if (belowTargetNodeIds.length > 0) {
123
+ diagnostics.push({
124
+ severity: 'error',
125
+ code: 'feature_below_target',
126
+ message: `${belowTargetNodeIds.length.toLocaleString()} source node${
127
+ belowTargetNodeIds.length === 1 ? '' : 's'
128
+ } fall below the custom ${formatThickness(minimumFeatureMm)} mm feature target; the minimum is ${formatThickness(minimum?.thicknessMm ?? 0)} mm.`,
129
+ nodeIds: belowTargetNodeIds,
130
+ })
131
+ }
132
+ }
133
+
134
+ if (measurement.unmeasuredNodeIds.length > 0) {
135
+ diagnostics.push({
136
+ severity: minimumFeatureMm === undefined ? 'warning' : 'error',
137
+ code: 'feature_thickness_incomplete',
138
+ message:
139
+ minimumFeatureMm === undefined
140
+ ? `Known feature thickness was not measured for ${measurement.unmeasuredNodeIds.length.toLocaleString()} source node${measurement.unmeasuredNodeIds.length === 1 ? '' : 's'}; inspect those features in a slicer.`
141
+ : `The custom feature target cannot be verified for ${measurement.unmeasuredNodeIds.length.toLocaleString()} source node${measurement.unmeasuredNodeIds.length === 1 ? '' : 's'} without a supported semantic thickness measurement.`,
142
+ nodeIds: measurement.unmeasuredNodeIds,
143
+ })
144
+ } else if (measurement.features.length === 0) {
145
+ diagnostics.push({
146
+ severity: minimumFeatureMm === undefined ? 'warning' : 'error',
147
+ code: 'feature_thickness_incomplete',
148
+ message:
149
+ minimumFeatureMm === undefined
150
+ ? 'No known source feature thickness was available; inspect the part in a slicer.'
151
+ : 'The custom feature target cannot be verified because no known source feature thickness was available.',
152
+ })
153
+ }
154
+
155
+ return {
156
+ ...mergePrintExportDiagnostics(
157
+ report,
158
+ diagnostics,
159
+ new Set(['compiler_limits', 'compiler_pending']),
160
+ ),
161
+ minimumFeatureThicknessMm: minimum?.thicknessMm ?? null,
162
+ }
163
+ }
164
+
165
+ export function applySemanticPrintFeatureThickness(
166
+ report: PrintExportReport,
167
+ nodes: Record<string, AnyNode>,
168
+ sourceNodeIds: Iterable<string>,
169
+ minimumFeatureMm?: number,
170
+ ): PrintExportReport {
171
+ return applyPrintFeatureThickness(
172
+ report,
173
+ measureSemanticPrintFeatureThickness(nodes, sourceNodeIds, report.scale),
174
+ minimumFeatureMm,
175
+ )
176
+ }
177
+
178
+ export function isPrintFeatureThicknessDiagnostic(diagnostic: PrintExportDiagnostic): boolean {
179
+ return FEATURE_DIAGNOSTIC_CODES.has(diagnostic.code)
180
+ }
@@ -0,0 +1,352 @@
1
+ import {
2
+ type AnyNode,
3
+ BuildingNode,
4
+ DoorNode,
5
+ getLevelElevations,
6
+ getWallThickness,
7
+ LevelNode,
8
+ nodeRegistry,
9
+ RoofSegmentNode,
10
+ registerNode,
11
+ SlabNode,
12
+ type SlabPolygonContext,
13
+ sceneRegistry,
14
+ WallNode,
15
+ WindowNode,
16
+ } from '@pascal-app/core'
17
+ import { generateRoofSegmentGeometry, generateSlabGeometry } from '@pascal-app/viewer'
18
+ import * as THREE from 'three'
19
+
20
+ const EMPTY_SLAB_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] }
21
+ const PRINT_GOLDEN_FURNITURE_KIND = 'print-golden-furniture'
22
+
23
+ export const PRINT_GOLDEN_HOUSE_IDS = {
24
+ building: 'building_print-golden-house',
25
+ groundLevel: 'level_print-golden-ground',
26
+ upperLevel: 'level_print-golden-upper',
27
+ groundWalls: [
28
+ 'wall_print-golden-ground-front',
29
+ 'wall_print-golden-ground-right',
30
+ 'wall_print-golden-ground-back',
31
+ 'wall_print-golden-ground-left',
32
+ ],
33
+ upperWalls: [
34
+ 'wall_print-golden-upper-front',
35
+ 'wall_print-golden-upper-right',
36
+ 'wall_print-golden-upper-back',
37
+ 'wall_print-golden-upper-left',
38
+ ],
39
+ door: 'door_print-golden-ground-front',
40
+ window: 'window_print-golden-upper-back',
41
+ groundSlab: 'slab_print-golden-ground',
42
+ upperSlab: 'slab_print-golden-upper',
43
+ roof: 'rseg_print-golden-upper',
44
+ visibleFurniture: 'furniture_print-golden-visible',
45
+ hiddenFurnitureParent: 'furniture_print-golden-hidden-parent',
46
+ hiddenFurnitureChild: 'furniture_print-golden-hidden-child',
47
+ } as const
48
+
49
+ export type PrintGoldenHouseFixture = {
50
+ root: THREE.Group
51
+ nodes: Record<string, AnyNode>
52
+ structuralNodeIds: string[]
53
+ groundStructuralNodeIds: string[]
54
+ upperStructuralNodeIds: string[]
55
+ dispose: () => void
56
+ }
57
+
58
+ function wall(
59
+ id: string,
60
+ parentId: string,
61
+ start: [number, number],
62
+ end: [number, number],
63
+ children: string[] = [],
64
+ ) {
65
+ return WallNode.parse({
66
+ id,
67
+ parentId,
68
+ start,
69
+ end,
70
+ height: 2.5,
71
+ thickness: 0.2,
72
+ children,
73
+ })
74
+ }
75
+
76
+ function slab(id: string, parentId: string) {
77
+ return SlabNode.parse({
78
+ id,
79
+ parentId,
80
+ elevation: 0.2,
81
+ thickness: 0.2,
82
+ polygon: [
83
+ [-2.1, -1.6],
84
+ [2.1, -1.6],
85
+ [2.1, 1.6],
86
+ [-2.1, 1.6],
87
+ ],
88
+ })
89
+ }
90
+
91
+ function disposeObject(root: THREE.Object3D) {
92
+ root.traverse((object) => {
93
+ const mesh = object as THREE.Mesh
94
+ if (!mesh.isMesh) return
95
+ mesh.geometry.dispose()
96
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
97
+ for (const material of materials) material.dispose()
98
+ })
99
+ }
100
+
101
+ export function createPrintGoldenHouseFixture(): PrintGoldenHouseFixture {
102
+ const ids = PRINT_GOLDEN_HOUSE_IDS
103
+ for (const kind of ['wall', 'door', 'window', 'slab', 'roof-segment']) {
104
+ if (nodeRegistry.has(kind)) continue
105
+ registerNode({
106
+ kind,
107
+ schemaVersion: 1,
108
+ category: 'structure',
109
+ defaults: () => ({}),
110
+ capabilities: {},
111
+ } as never)
112
+ }
113
+ const groundWalls = [
114
+ wall(ids.groundWalls[0], ids.groundLevel, [-2, -1.5], [2, -1.5], [ids.door]),
115
+ wall(ids.groundWalls[1], ids.groundLevel, [2, -1.5], [2, 1.5]),
116
+ wall(ids.groundWalls[2], ids.groundLevel, [2, 1.5], [-2, 1.5]),
117
+ wall(ids.groundWalls[3], ids.groundLevel, [-2, 1.5], [-2, -1.5]),
118
+ ]
119
+ const upperWalls = [
120
+ wall(ids.upperWalls[0], ids.upperLevel, [-2, -1.5], [2, -1.5]),
121
+ wall(ids.upperWalls[1], ids.upperLevel, [2, -1.5], [2, 1.5]),
122
+ wall(ids.upperWalls[2], ids.upperLevel, [2, 1.5], [-2, 1.5], [ids.window]),
123
+ wall(ids.upperWalls[3], ids.upperLevel, [-2, 1.5], [-2, -1.5]),
124
+ ]
125
+ const door = DoorNode.parse({
126
+ id: ids.door,
127
+ parentId: groundWalls[0]!.id,
128
+ wallId: groundWalls[0]!.id,
129
+ position: [2, 1.05, 0],
130
+ width: 0.9,
131
+ height: 2.1,
132
+ })
133
+ const window = WindowNode.parse({
134
+ id: ids.window,
135
+ parentId: upperWalls[2]!.id,
136
+ wallId: upperWalls[2]!.id,
137
+ position: [2, 1.4, 0],
138
+ width: 1.2,
139
+ height: 1,
140
+ })
141
+ const groundSlab = slab(ids.groundSlab, ids.groundLevel)
142
+ const upperSlab = slab(ids.upperSlab, ids.upperLevel)
143
+ const roof = RoofSegmentNode.parse({
144
+ id: ids.roof,
145
+ parentId: ids.upperLevel,
146
+ roofType: 'gable',
147
+ position: [0, 2.5, 0],
148
+ width: 4,
149
+ depth: 3,
150
+ wallHeight: 0.5,
151
+ pitch: 30,
152
+ wallThickness: 0.15,
153
+ deckThickness: 0.1,
154
+ overhang: 0.3,
155
+ shingleThickness: 0.05,
156
+ })
157
+
158
+ if (!nodeRegistry.has(PRINT_GOLDEN_FURNITURE_KIND)) {
159
+ registerNode({
160
+ kind: PRINT_GOLDEN_FURNITURE_KIND,
161
+ schemaVersion: 1,
162
+ category: 'furnish',
163
+ defaults: () => ({}),
164
+ capabilities: {},
165
+ } as never)
166
+ }
167
+ const visibleFurniture = {
168
+ object: 'node',
169
+ id: ids.visibleFurniture,
170
+ type: PRINT_GOLDEN_FURNITURE_KIND,
171
+ parentId: ids.groundLevel,
172
+ children: [],
173
+ visible: true,
174
+ } as unknown as AnyNode
175
+ const hiddenFurnitureParent = {
176
+ object: 'node',
177
+ id: ids.hiddenFurnitureParent,
178
+ type: PRINT_GOLDEN_FURNITURE_KIND,
179
+ parentId: ids.groundLevel,
180
+ children: [ids.hiddenFurnitureChild],
181
+ visible: false,
182
+ } as unknown as AnyNode
183
+ const hiddenFurnitureChild = {
184
+ object: 'node',
185
+ id: ids.hiddenFurnitureChild,
186
+ type: PRINT_GOLDEN_FURNITURE_KIND,
187
+ parentId: ids.hiddenFurnitureParent,
188
+ children: [],
189
+ visible: true,
190
+ } as unknown as AnyNode
191
+
192
+ const groundLevel = LevelNode.parse({
193
+ id: ids.groundLevel,
194
+ parentId: ids.building,
195
+ name: 'Ground',
196
+ level: 0,
197
+ height: 2.5,
198
+ children: [
199
+ ...groundWalls.map((node) => node.id),
200
+ groundSlab.id,
201
+ visibleFurniture.id,
202
+ hiddenFurnitureParent.id,
203
+ ],
204
+ })
205
+ const upperLevel = LevelNode.parse({
206
+ id: ids.upperLevel,
207
+ parentId: ids.building,
208
+ name: 'Upper',
209
+ level: 1,
210
+ height: 2.5,
211
+ children: [...upperWalls.map((node) => node.id), upperSlab.id, roof.id],
212
+ })
213
+ const building = BuildingNode.parse({
214
+ id: ids.building,
215
+ children: [groundLevel.id, upperLevel.id],
216
+ })
217
+ const nodes = Object.fromEntries(
218
+ [
219
+ building,
220
+ groundLevel,
221
+ upperLevel,
222
+ ...groundWalls,
223
+ ...upperWalls,
224
+ door,
225
+ window,
226
+ groundSlab,
227
+ upperSlab,
228
+ roof,
229
+ visibleFurniture,
230
+ hiddenFurnitureParent,
231
+ hiddenFurnitureChild,
232
+ ].map((node) => [node.id, node]),
233
+ ) as Record<string, AnyNode>
234
+
235
+ const root = new THREE.Group()
236
+ root.name = 'print-golden-house'
237
+ const buildingRoot = new THREE.Group()
238
+ buildingRoot.userData = { pascalId: building.id }
239
+ const groundRoot = new THREE.Group()
240
+ groundRoot.userData = { pascalId: groundLevel.id }
241
+ const upperRoot = new THREE.Group()
242
+ upperRoot.userData = { pascalId: upperLevel.id }
243
+ const elevations = getLevelElevations(nodes)
244
+ groundRoot.position.y = elevations.get(groundLevel.id)?.baseY ?? 0
245
+ upperRoot.position.y = elevations.get(upperLevel.id)?.baseY ?? 0
246
+ root.add(buildingRoot)
247
+ buildingRoot.add(groundRoot, upperRoot)
248
+
249
+ const registered = new Map<string, THREE.Object3D>()
250
+ const registerObject = (id: string, object: THREE.Object3D) => {
251
+ sceneRegistry.nodes.set(id, object)
252
+ registered.set(id, object)
253
+ }
254
+ registerObject(building.id, buildingRoot)
255
+ registerObject(groundLevel.id, groundRoot)
256
+ registerObject(upperLevel.id, upperRoot)
257
+
258
+ const mountWalls = (
259
+ levelRoot: THREE.Group,
260
+ walls: WallNode[],
261
+ openings: Array<typeof door | typeof window>,
262
+ ) => {
263
+ for (const wallNode of walls) {
264
+ const wallRoot = new THREE.Group()
265
+ wallRoot.userData = { pascalId: wallNode.id }
266
+ wallRoot.position.set(wallNode.start[0], 0, wallNode.start[1])
267
+ wallRoot.rotation.y = -Math.atan2(
268
+ wallNode.end[1] - wallNode.start[1],
269
+ wallNode.end[0] - wallNode.start[0],
270
+ )
271
+ const wallOpenings = openings.filter((opening) => opening.wallId === wallNode.id)
272
+ const wallLength = Math.hypot(
273
+ wallNode.end[0] - wallNode.start[0],
274
+ wallNode.end[1] - wallNode.start[1],
275
+ )
276
+ const wallHeight = wallNode.height ?? 2.5
277
+ const displayWall = new THREE.Mesh(
278
+ new THREE.BoxGeometry(wallLength, wallHeight, getWallThickness(wallNode)),
279
+ )
280
+ displayWall.position.set(wallLength / 2, wallHeight / 2, 0)
281
+ wallRoot.add(displayWall)
282
+ for (const opening of wallOpenings) {
283
+ const openingRoot = new THREE.Group()
284
+ openingRoot.userData = { pascalId: opening.id }
285
+ wallRoot.add(openingRoot)
286
+ registerObject(opening.id, openingRoot)
287
+ }
288
+ levelRoot.add(wallRoot)
289
+ registerObject(wallNode.id, wallRoot)
290
+ }
291
+ }
292
+ mountWalls(groundRoot, groundWalls, [door])
293
+ mountWalls(upperRoot, upperWalls, [window])
294
+
295
+ const mountSlab = (levelRoot: THREE.Group, node: SlabNode) => {
296
+ const slabRoot = new THREE.Group()
297
+ slabRoot.userData = { pascalId: node.id }
298
+ slabRoot.add(new THREE.Mesh(generateSlabGeometry(node, EMPTY_SLAB_CONTEXT)))
299
+ levelRoot.add(slabRoot)
300
+ registerObject(node.id, slabRoot)
301
+ }
302
+ mountSlab(groundRoot, groundSlab)
303
+ mountSlab(upperRoot, upperSlab)
304
+
305
+ const roofRoot = new THREE.Group()
306
+ roofRoot.userData = { pascalId: roof.id }
307
+ roofRoot.position.set(...roof.position)
308
+ roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof)))
309
+ upperRoot.add(roofRoot)
310
+ registerObject(roof.id, roofRoot)
311
+
312
+ const visibleFurnitureRoot = new THREE.Group()
313
+ visibleFurnitureRoot.userData = { pascalId: visibleFurniture.id }
314
+ visibleFurnitureRoot.position.set(1, 0.5, 0)
315
+ visibleFurnitureRoot.add(new THREE.Mesh(new THREE.BoxGeometry(0.8, 1, 0.8)))
316
+ groundRoot.add(visibleFurnitureRoot)
317
+ registerObject(visibleFurniture.id, visibleFurnitureRoot)
318
+
319
+ const hiddenFurnitureParentRoot = new THREE.Group()
320
+ hiddenFurnitureParentRoot.userData = { pascalId: hiddenFurnitureParent.id }
321
+ hiddenFurnitureParentRoot.position.set(-1, 0, 0)
322
+ const hiddenFurnitureChildRoot = new THREE.Group()
323
+ hiddenFurnitureChildRoot.userData = { pascalId: hiddenFurnitureChild.id }
324
+ hiddenFurnitureChildRoot.position.y = 0.5
325
+ hiddenFurnitureChildRoot.add(new THREE.Mesh(new THREE.BoxGeometry(0.8, 1, 0.8)))
326
+ hiddenFurnitureParentRoot.add(hiddenFurnitureChildRoot)
327
+ groundRoot.add(hiddenFurnitureParentRoot)
328
+ registerObject(hiddenFurnitureParent.id, hiddenFurnitureParentRoot)
329
+ registerObject(hiddenFurnitureChild.id, hiddenFurnitureChildRoot)
330
+
331
+ const groundStructuralNodeIds = [...groundWalls.map((node) => node.id), groundSlab.id].sort()
332
+ const upperStructuralNodeIds = [
333
+ ...upperWalls.map((node) => node.id),
334
+ upperSlab.id,
335
+ roof.id,
336
+ ].sort()
337
+
338
+ return {
339
+ root,
340
+ nodes,
341
+ structuralNodeIds: [...groundStructuralNodeIds, ...upperStructuralNodeIds].sort(),
342
+ groundStructuralNodeIds,
343
+ upperStructuralNodeIds,
344
+ dispose: () => {
345
+ for (const [id, object] of registered) {
346
+ if (sceneRegistry.nodes.get(id) === object) sceneRegistry.nodes.delete(id)
347
+ }
348
+ disposeObject(root)
349
+ root.clear()
350
+ },
351
+ }
352
+ }