@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,630 @@
1
+ import {
2
+ type AnyNode,
3
+ getConicalRoofCoverage,
4
+ getRoofModuleFaces,
5
+ getRoofShapeInsets,
6
+ getRoofShapeRatios,
7
+ getSegmentSlopeFrame,
8
+ nodeRegistry,
9
+ normalizeRoofSegmentTrim,
10
+ type RoofSegmentNode,
11
+ type RoofShapeFaceVertex,
12
+ } from '@pascal-app/core'
13
+ import * as THREE from 'three'
14
+ import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
15
+
16
+ // Manufacturing geometry belongs to the editor layer, not the read-only viewer runtime.
17
+ const FACE_EPSILON = 1e-7
18
+ const SURFACE_NORMAL_EPSILON = 1e-6
19
+
20
+ type RoofFace = RoofShapeFaceVertex[]
21
+
22
+ export type PrintRoofSolidDiagnostic = {
23
+ severity: 'error'
24
+ code:
25
+ | 'invalid_roof_print_dimensions'
26
+ | 'roof_print_topology_mismatch'
27
+ | 'unsupported_roof_print_trim'
28
+ | 'unsupported_roof_print_cut'
29
+ message: string
30
+ nodeIds: string[]
31
+ }
32
+
33
+ export type PrintRoofSolidResult =
34
+ | { status: 'ready'; object: THREE.Group; diagnostics: [] }
35
+ | { status: 'blocked'; object: null; diagnostics: PrintRoofSolidDiagnostic[] }
36
+
37
+ type BoundaryEdge = {
38
+ start: RoofShapeFaceVertex
39
+ end: RoofShapeFaceVertex
40
+ }
41
+
42
+ type RoofModuleGeometryResult =
43
+ | { status: 'ready'; geometry: THREE.BufferGeometry }
44
+ | { status: 'blocked'; message: string }
45
+
46
+ function faceNormal(face: RoofFace): THREE.Vector3 | null {
47
+ if (face.length < 3) return null
48
+ const origin = new THREE.Vector3(face[0]!.x, face[0]!.y, face[0]!.z)
49
+ const first = new THREE.Vector3()
50
+ const second = new THREE.Vector3()
51
+ const normal = new THREE.Vector3()
52
+
53
+ for (let firstIndex = 1; firstIndex < face.length - 1; firstIndex += 1) {
54
+ first.set(face[firstIndex]!.x, face[firstIndex]!.y, face[firstIndex]!.z).sub(origin)
55
+ for (let secondIndex = firstIndex + 1; secondIndex < face.length; secondIndex += 1) {
56
+ second.set(face[secondIndex]!.x, face[secondIndex]!.y, face[secondIndex]!.z).sub(origin)
57
+ normal.crossVectors(first, second)
58
+ if (normal.lengthSq() > FACE_EPSILON * FACE_EPSILON) return normal.normalize()
59
+ }
60
+ }
61
+
62
+ return null
63
+ }
64
+
65
+ function pointKey(point: RoofShapeFaceVertex): string {
66
+ return `${Math.round(point.x / FACE_EPSILON)},${Math.round(
67
+ point.y / FACE_EPSILON,
68
+ )},${Math.round(point.z / FACE_EPSILON)}`
69
+ }
70
+
71
+ function edgeKey(a: RoofShapeFaceVertex, b: RoofShapeFaceVertex): string {
72
+ const keyA = pointKey(a)
73
+ const keyB = pointKey(b)
74
+ return keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}`
75
+ }
76
+
77
+ function projectFace(face: RoofFace, normal: THREE.Vector3): THREE.Vector2[] {
78
+ const absX = Math.abs(normal.x)
79
+ const absY = Math.abs(normal.y)
80
+ const absZ = Math.abs(normal.z)
81
+
82
+ if (absX >= absY && absX >= absZ) {
83
+ return face.map((point) => new THREE.Vector2(point.z, point.y))
84
+ }
85
+ if (absY >= absX && absY >= absZ) {
86
+ return face.map((point) => new THREE.Vector2(point.x, point.z))
87
+ }
88
+ return face.map((point) => new THREE.Vector2(point.x, point.y))
89
+ }
90
+
91
+ function geometryFromFaces(faces: RoofFace[]): THREE.BufferGeometry | null {
92
+ const positions: number[] = []
93
+ const a = new THREE.Vector3()
94
+ const b = new THREE.Vector3()
95
+ const c = new THREE.Vector3()
96
+ const triangleSecondEdge = new THREE.Vector3()
97
+ const triangleNormal = new THREE.Vector3()
98
+
99
+ for (const face of faces) {
100
+ const normal = faceNormal(face)
101
+ if (!normal) return null
102
+ const triangles = THREE.ShapeUtils.triangulateShape(projectFace(face, normal), [])
103
+ if (triangles.length === 0) return null
104
+
105
+ for (const triangle of triangles) {
106
+ const [indexA, indexB, indexC] = triangle
107
+ if (indexA === undefined || indexB === undefined || indexC === undefined) return null
108
+ const pointA = face[indexA]!
109
+ let pointB = face[indexB]!
110
+ let pointC = face[indexC]!
111
+ a.set(pointA.x, pointA.y, pointA.z)
112
+ b.set(pointB.x, pointB.y, pointB.z)
113
+ c.set(pointC.x, pointC.y, pointC.z)
114
+ triangleNormal.subVectors(b, a).cross(triangleSecondEdge.subVectors(c, a))
115
+ if (triangleNormal.dot(normal) < 0) {
116
+ ;[pointB, pointC] = [pointC, pointB]
117
+ }
118
+ positions.push(
119
+ pointA.x,
120
+ pointA.y,
121
+ pointA.z,
122
+ pointB.x,
123
+ pointB.y,
124
+ pointB.z,
125
+ pointC.x,
126
+ pointC.y,
127
+ pointC.z,
128
+ )
129
+ }
130
+ }
131
+
132
+ const geometry = new THREE.BufferGeometry()
133
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
134
+ const indexed = mergeVertices(geometry, FACE_EPSILON)
135
+ geometry.dispose()
136
+ indexed.computeVertexNormals()
137
+ return indexed
138
+ }
139
+
140
+ function selectSurfaceFaces(
141
+ faces: RoofFace[],
142
+ include: (normal: THREE.Vector3) => boolean,
143
+ reverse = false,
144
+ ): RoofFace[] | null {
145
+ const selected: RoofFace[] = []
146
+ for (const face of faces) {
147
+ const normal = faceNormal(face)
148
+ if (!normal) return null
149
+ if (include(normal)) selected.push(reverse ? [...face].reverse() : face)
150
+ }
151
+ return selected.length > 0 ? selected : null
152
+ }
153
+
154
+ function boundaryEdges(faces: RoofFace[]): BoundaryEdge[] | null {
155
+ const edgeUses = new Map<string, BoundaryEdge[]>()
156
+ for (const face of faces) {
157
+ for (let edgeIndex = 0; edgeIndex < face.length; edgeIndex += 1) {
158
+ const nextIndex = (edgeIndex + 1) % face.length
159
+ const edge = { start: face[edgeIndex]!, end: face[nextIndex]! }
160
+ const key = edgeKey(edge.start, edge.end)
161
+ const uses = edgeUses.get(key) ?? []
162
+ uses.push(edge)
163
+ edgeUses.set(key, uses)
164
+ }
165
+ }
166
+
167
+ const boundary: BoundaryEdge[] = []
168
+ for (const uses of edgeUses.values()) {
169
+ if (uses.length === 1) boundary.push(uses[0]!)
170
+ else if (uses.length !== 2) return null
171
+ }
172
+ return boundary
173
+ }
174
+
175
+ function pointDistanceSq(a: RoofShapeFaceVertex, b: RoofShapeFaceVertex): number {
176
+ const dx = a.x - b.x
177
+ const dy = a.y - b.y
178
+ const dz = a.z - b.z
179
+ return dx * dx + dy * dy + dz * dz
180
+ }
181
+
182
+ function boundaryLoops(edges: BoundaryEdge[]): RoofFace[] | null {
183
+ const byStart = new Map<string, BoundaryEdge>()
184
+ for (const edge of edges) {
185
+ const key = pointKey(edge.start)
186
+ if (byStart.has(key)) return null
187
+ byStart.set(key, edge)
188
+ }
189
+
190
+ const unused = new Set(byStart.keys())
191
+ const loops: RoofFace[] = []
192
+ while (unused.size > 0) {
193
+ const firstKey = unused.values().next().value
194
+ if (typeof firstKey !== 'string') return null
195
+ const loop: RoofFace = []
196
+ let key = firstKey
197
+
198
+ do {
199
+ const edge = byStart.get(key)
200
+ if (!edge || !unused.delete(key)) return null
201
+ loop.push(edge.start)
202
+ key = pointKey(edge.end)
203
+ } while (key !== firstKey)
204
+
205
+ if (loop.length < 3) return null
206
+ loops.push(loop)
207
+ }
208
+
209
+ return loops
210
+ }
211
+
212
+ function loopCentroid(loop: RoofFace): RoofShapeFaceVertex {
213
+ const sum = loop.reduce(
214
+ (total, point) => ({ x: total.x + point.x, y: total.y + point.y, z: total.z + point.z }),
215
+ { x: 0, y: 0, z: 0 },
216
+ )
217
+ return { x: sum.x / loop.length, y: sum.y / loop.length, z: sum.z / loop.length }
218
+ }
219
+
220
+ function rotated<T>(values: T[], start: number): T[] {
221
+ return [...values.slice(start), ...values.slice(0, start)]
222
+ }
223
+
224
+ function bridgeBoundaryLoop(first: RoofFace, second: RoofFace): RoofFace[] | null {
225
+ const reversedSecond = [...second].reverse()
226
+ let best: { cost: number; second: RoofFace; moves: ('first' | 'second')[] } | undefined
227
+
228
+ for (let secondStart = 0; secondStart < reversedSecond.length; secondStart += 1) {
229
+ const candidate = rotated(reversedSecond, secondStart)
230
+ const width = candidate.length + 1
231
+ const costs = new Array<number>((first.length + 1) * width).fill(Number.POSITIVE_INFINITY)
232
+ const previous = new Array<'first' | 'second' | undefined>(costs.length)
233
+ const indexOf = (firstIndex: number, secondIndex: number) => firstIndex * width + secondIndex
234
+ costs[0] = pointDistanceSq(first[0]!, candidate[0]!)
235
+
236
+ for (let firstIndex = 0; firstIndex <= first.length; firstIndex += 1) {
237
+ for (let secondIndex = 0; secondIndex <= candidate.length; secondIndex += 1) {
238
+ const index = indexOf(firstIndex, secondIndex)
239
+ const cost = costs[index]!
240
+ if (!Number.isFinite(cost)) continue
241
+ const firstPoint = first[firstIndex % first.length]!
242
+ const secondPoint = candidate[secondIndex % candidate.length]!
243
+
244
+ if (firstIndex < first.length) {
245
+ const nextFirst = first[(firstIndex + 1) % first.length]!
246
+ const nextIndex = indexOf(firstIndex + 1, secondIndex)
247
+ const nextCost = cost + pointDistanceSq(nextFirst, secondPoint)
248
+ if (nextCost < costs[nextIndex]!) {
249
+ costs[nextIndex] = nextCost
250
+ previous[nextIndex] = 'first'
251
+ }
252
+ }
253
+ if (secondIndex < candidate.length) {
254
+ const nextSecond = candidate[(secondIndex + 1) % candidate.length]!
255
+ const nextIndex = indexOf(firstIndex, secondIndex + 1)
256
+ const nextCost = cost + pointDistanceSq(firstPoint, nextSecond)
257
+ if (nextCost < costs[nextIndex]!) {
258
+ costs[nextIndex] = nextCost
259
+ previous[nextIndex] = 'second'
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ const endIndex = indexOf(first.length, candidate.length)
266
+ const moves: ('first' | 'second')[] = []
267
+ let firstIndex = first.length
268
+ let secondIndex = candidate.length
269
+ while (firstIndex > 0 || secondIndex > 0) {
270
+ const move = previous[indexOf(firstIndex, secondIndex)]
271
+ if (!move) return null
272
+ moves.push(move)
273
+ if (move === 'first') firstIndex -= 1
274
+ else secondIndex -= 1
275
+ }
276
+ moves.reverse()
277
+
278
+ if (!best || costs[endIndex]! < best.cost) {
279
+ best = { cost: costs[endIndex]!, second: candidate, moves }
280
+ }
281
+ }
282
+
283
+ if (!best) return null
284
+ const bridges: RoofFace[] = []
285
+ let firstIndex = 0
286
+ let secondIndex = 0
287
+ for (const move of best.moves) {
288
+ const firstPoint = first[firstIndex % first.length]!
289
+ const secondPoint = best.second[secondIndex % best.second.length]!
290
+ if (move === 'first') {
291
+ const nextFirst = first[(firstIndex + 1) % first.length]!
292
+ bridges.push([nextFirst, firstPoint, secondPoint])
293
+ firstIndex += 1
294
+ } else {
295
+ const nextSecond = best.second[(secondIndex + 1) % best.second.length]!
296
+ bridges.push([firstPoint, secondPoint, nextSecond])
297
+ secondIndex += 1
298
+ }
299
+ }
300
+ return bridges
301
+ }
302
+
303
+ function bridgeBoundaries(first: BoundaryEdge[], second: BoundaryEdge[]): RoofFace[] | null {
304
+ const firstLoops = boundaryLoops(first)
305
+ const secondLoops = boundaryLoops(second)
306
+ if (!firstLoops || !secondLoops || firstLoops.length !== secondLoops.length) return null
307
+
308
+ const unmatched = new Set(secondLoops.map((_, index) => index))
309
+ const bridges: RoofFace[] = []
310
+ for (const firstLoop of firstLoops) {
311
+ const firstCentroid = loopCentroid(firstLoop)
312
+ let bestIndex = -1
313
+ let bestScore = Number.POSITIVE_INFINITY
314
+ for (const candidateIndex of unmatched) {
315
+ const score = pointDistanceSq(firstCentroid, loopCentroid(secondLoops[candidateIndex]!))
316
+ if (score < bestScore) {
317
+ bestIndex = candidateIndex
318
+ bestScore = score
319
+ }
320
+ }
321
+ if (bestIndex < 0) return null
322
+ unmatched.delete(bestIndex)
323
+ const loopBridges = bridgeBoundaryLoop(firstLoop, secondLoops[bestIndex]!)
324
+ if (!loopBridges) return null
325
+ bridges.push(...loopBridges)
326
+ }
327
+ return bridges
328
+ }
329
+
330
+ function splitWallBoundary(edges: BoundaryEdge[]): {
331
+ bottom: BoundaryEdge[]
332
+ top: BoundaryEdge[]
333
+ } | null {
334
+ const bottom: BoundaryEdge[] = []
335
+ const top: BoundaryEdge[] = []
336
+ for (const edge of edges) {
337
+ if (Math.abs(edge.start.y) <= FACE_EPSILON && Math.abs(edge.end.y) <= FACE_EPSILON) {
338
+ bottom.push(edge)
339
+ } else {
340
+ top.push(edge)
341
+ }
342
+ }
343
+ return bottom.length > 0 && top.length > 0 ? { bottom, top } : null
344
+ }
345
+
346
+ function buildRoofModuleGeometry(
347
+ wallOuterFaces: RoofFace[],
348
+ wallInnerFaces: RoofFace[],
349
+ roofOuterFaces: RoofFace[],
350
+ roofInnerFaces: RoofFace[],
351
+ ): RoofModuleGeometryResult {
352
+ const isWall = (normal: THREE.Vector3) => Math.abs(normal.y) <= SURFACE_NORMAL_EPSILON
353
+ const isRoof = (normal: THREE.Vector3) => normal.y > SURFACE_NORMAL_EPSILON
354
+ const wallOuter = selectSurfaceFaces(wallOuterFaces, isWall)
355
+ const wallInner = selectSurfaceFaces(wallInnerFaces, isWall, true)
356
+ const roofOuter = selectSurfaceFaces(roofOuterFaces, isRoof)
357
+ const roofInner = selectSurfaceFaces(roofInnerFaces, isRoof, true)
358
+ if (!wallOuter || !wallInner || !roofOuter || !roofInner) {
359
+ return { status: 'blocked', message: 'A canonical wall or roof surface is missing.' }
360
+ }
361
+
362
+ const wallOuterBoundary = boundaryEdges(wallOuter)
363
+ const wallInnerBoundary = boundaryEdges(wallInner)
364
+ const roofOuterBoundary = boundaryEdges(roofOuter)
365
+ const roofInnerBoundary = boundaryEdges(roofInner)
366
+ if (!wallOuterBoundary || !wallInnerBoundary || !roofOuterBoundary || !roofInnerBoundary) {
367
+ return { status: 'blocked', message: 'A canonical surface has invalid edge incidence.' }
368
+ }
369
+
370
+ const outerWallLoops = splitWallBoundary(wallOuterBoundary)
371
+ const innerWallLoops = splitWallBoundary(wallInnerBoundary)
372
+ if (!outerWallLoops || !innerWallLoops) {
373
+ return { status: 'blocked', message: 'A wall surface does not expose top and bottom loops.' }
374
+ }
375
+
376
+ const bottomRing = bridgeBoundaries(outerWallLoops.bottom, innerWallLoops.bottom)
377
+ const outerRoofJoin = bridgeBoundaries(outerWallLoops.top, roofOuterBoundary)
378
+ const innerRoofJoin = bridgeBoundaries(innerWallLoops.top, roofInnerBoundary)
379
+ if (!bottomRing) {
380
+ return {
381
+ status: 'blocked',
382
+ message: `Outer and inner wall bottoms have incompatible edge counts (${outerWallLoops.bottom.length} and ${innerWallLoops.bottom.length}).`,
383
+ }
384
+ }
385
+ if (!outerRoofJoin) {
386
+ return {
387
+ status: 'blocked',
388
+ message: `Outer wall and roof boundaries have incompatible edge counts (${outerWallLoops.top.length} and ${roofOuterBoundary.length}).`,
389
+ }
390
+ }
391
+ if (!innerRoofJoin) {
392
+ return {
393
+ status: 'blocked',
394
+ message: `Inner wall and roof boundaries have incompatible edge counts (${innerWallLoops.top.length} and ${roofInnerBoundary.length}).`,
395
+ }
396
+ }
397
+
398
+ const geometry = geometryFromFaces([
399
+ ...wallOuter,
400
+ ...roofOuter,
401
+ ...wallInner,
402
+ ...roofInner,
403
+ ...bottomRing,
404
+ ...outerRoofJoin,
405
+ ...innerRoofJoin,
406
+ ])
407
+ return geometry
408
+ ? { status: 'ready', geometry }
409
+ : { status: 'blocked', message: 'A canonical print face could not be triangulated.' }
410
+ }
411
+
412
+ function getVolumeFaces(
413
+ node: RoofSegmentNode,
414
+ options: { widthExtension: number; verticalOffset: number; isVoid: boolean },
415
+ ): RoofFace[] {
416
+ const conicalCoverage = getConicalRoofCoverage(node)
417
+ const { activeRh, tanTheta } = getSegmentSlopeFrame(node)
418
+ const width = Math.max(0.01, node.width + options.widthExtension * 2)
419
+ const depth = Math.max(0.01, node.depth + options.widthExtension * 2)
420
+ const autoDrop = options.widthExtension * tanTheta
421
+ const wallHeight = Math.max(0.01, node.wallHeight - autoDrop + options.verticalOffset)
422
+ const roofHeight =
423
+ activeRh > 0 ? activeRh + autoDrop * (node.roofType === 'shed' ? 2 : 1) : activeRh
424
+ const shapeRatios = getRoofShapeRatios(node)
425
+ const dutchInset =
426
+ Math.min(node.width, node.depth) * node.dutchHipWidthRatio +
427
+ (options.isVoid ? node.deckThickness : 0)
428
+
429
+ return getRoofModuleFaces({
430
+ type: node.roofType,
431
+ w: width,
432
+ d: depth,
433
+ wh: wallHeight,
434
+ rh: roofHeight,
435
+ baseY: 0,
436
+ insets: { dutchI: dutchInset },
437
+ baseW: node.width,
438
+ baseD: node.depth,
439
+ tanTheta,
440
+ shapeRatios,
441
+ dutchTopRakeThickness: node.dutchTopRakeThickness,
442
+ conicalStartAngle: conicalCoverage.startAngle,
443
+ conicalSweepAngle: conicalCoverage.sweepAngle,
444
+ })
445
+ }
446
+
447
+ function getShingleOuterFaces(node: RoofSegmentNode): RoofFace[] {
448
+ const conicalCoverage = getConicalRoofCoverage(node)
449
+ const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node)
450
+ const shapeRatios = getRoofShapeRatios(node)
451
+ const horizontalOverhang = node.overhang * cosTheta
452
+ const deckExtension = node.wallThickness / 2 + horizontalOverhang
453
+ const deckVerticalThickness = activeRh > 0 ? node.deckThickness / cosTheta : node.deckThickness
454
+ const deckDrop = deckExtension * tanTheta
455
+ const shingleHorizontalThickness = node.shingleThickness * sinTheta
456
+ const shingleVerticalThickness = node.shingleThickness * cosTheta
457
+ const baseWidth = Math.max(0.01, node.width + deckExtension * 2)
458
+ const baseDepth = Math.max(0.01, node.depth + deckExtension * 2)
459
+ const baseWallHeight = node.wallHeight - deckDrop + deckVerticalThickness
460
+ const baseRoofHeight =
461
+ activeRh > 0 ? activeRh + deckDrop * (node.roofType === 'shed' ? 2 : 1) : activeRh
462
+ let width = baseWidth
463
+ let depth = baseDepth
464
+ let translateZ = 0
465
+
466
+ if (['hip', 'mansard', 'dutch', 'conical'].includes(node.roofType)) {
467
+ width += shingleHorizontalThickness * 2
468
+ depth += shingleHorizontalThickness * 2
469
+ } else if (['gable', 'gambrel'].includes(node.roofType)) {
470
+ depth += shingleHorizontalThickness * 2
471
+ } else if (node.roofType === 'shed') {
472
+ depth += shingleHorizontalThickness
473
+ translateZ = shingleHorizontalThickness / 2
474
+ }
475
+
476
+ const wallHeight = baseWallHeight + shingleVerticalThickness
477
+ const roofHeight =
478
+ activeRh > 0 ? baseRoofHeight + shingleHorizontalThickness * tanTheta : baseRoofHeight
479
+ const insets = getRoofShapeInsets({
480
+ roofType: node.roofType,
481
+ width: node.width,
482
+ depth: node.depth,
483
+ wh: wallHeight,
484
+ baseY: 0,
485
+ isVoid: false,
486
+ brushW: width,
487
+ brushD: depth,
488
+ tanTheta,
489
+ shingleThickness: node.shingleThickness,
490
+ dutchHipWidthRatio: node.dutchHipWidthRatio,
491
+ })
492
+
493
+ const faces = getRoofModuleFaces({
494
+ type: node.roofType,
495
+ w: width,
496
+ d: depth,
497
+ wh: wallHeight,
498
+ rh: roofHeight,
499
+ baseY: 0,
500
+ insets,
501
+ baseW: node.width,
502
+ baseD: node.depth,
503
+ tanTheta,
504
+ shapeRatios,
505
+ dutchTopRakeThickness: node.dutchTopRakeThickness,
506
+ conicalStartAngle: conicalCoverage.startAngle,
507
+ conicalSweepAngle: conicalCoverage.sweepAngle,
508
+ })
509
+
510
+ if (translateZ === 0) return faces
511
+ return faces.map((face) => face.map((point) => ({ ...point, z: point.z + translateZ })))
512
+ }
513
+
514
+ function blockingDiagnostics(
515
+ node: RoofSegmentNode,
516
+ nodes: Record<string, AnyNode> | undefined,
517
+ ): PrintRoofSolidDiagnostic[] {
518
+ const diagnostics: PrintRoofSolidDiagnostic[] = []
519
+ const dimensions = [
520
+ node.width,
521
+ node.depth,
522
+ node.wallHeight,
523
+ node.wallThickness,
524
+ node.deckThickness,
525
+ node.overhang,
526
+ node.shingleThickness,
527
+ ]
528
+ if (
529
+ dimensions.some((value) => !Number.isFinite(value) || value < 0) ||
530
+ node.width <= FACE_EPSILON ||
531
+ node.depth <= FACE_EPSILON ||
532
+ node.wallThickness <= FACE_EPSILON ||
533
+ node.deckThickness + node.shingleThickness <= FACE_EPSILON
534
+ ) {
535
+ diagnostics.push({
536
+ severity: 'error',
537
+ code: 'invalid_roof_print_dimensions',
538
+ message: `Roof segment ${node.id} needs positive footprint, wall, and roof-cover thickness for print compilation.`,
539
+ nodeIds: [node.id],
540
+ })
541
+ }
542
+
543
+ const trim = normalizeRoofSegmentTrim(node)
544
+ if (Object.values(trim).some((value) => value > FACE_EPSILON)) {
545
+ diagnostics.push({
546
+ severity: 'error',
547
+ code: 'unsupported_roof_print_trim',
548
+ message: `Roof segment ${node.id} has trim cuts that do not yet have a manifold print fixture.`,
549
+ nodeIds: [node.id],
550
+ })
551
+ }
552
+
553
+ const cutNodeIds: string[] = []
554
+ for (const childId of node.children) {
555
+ const child = nodes?.[childId]
556
+ if (!child) {
557
+ cutNodeIds.push(childId)
558
+ continue
559
+ }
560
+ const definition = nodeRegistry.get(child.type)
561
+ const roofAccessory = definition?.capabilities.roofAccessory
562
+ if (!roofAccessory || roofAccessory.buildCut) {
563
+ cutNodeIds.push(child.id)
564
+ }
565
+ }
566
+ if (cutNodeIds.length > 0) {
567
+ diagnostics.push({
568
+ severity: 'error',
569
+ code: 'unsupported_roof_print_cut',
570
+ message: `Roof segment ${node.id} has unresolved, unregistered, or cutting accessories that do not yet have a manifold print fixture.`,
571
+ nodeIds: [node.id, ...cutNodeIds].sort(),
572
+ })
573
+ }
574
+
575
+ return diagnostics
576
+ }
577
+
578
+ export function buildPrintableRoofSegmentSolids(
579
+ node: RoofSegmentNode,
580
+ nodes?: Record<string, AnyNode>,
581
+ ): PrintRoofSolidResult {
582
+ const diagnostics = blockingDiagnostics(node, nodes)
583
+ if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics }
584
+
585
+ const { cosTheta } = getSegmentSlopeFrame(node)
586
+ const wallOuter = getVolumeFaces(node, {
587
+ widthExtension: node.wallThickness / 2,
588
+ verticalOffset: 0,
589
+ isVoid: false,
590
+ })
591
+ const wallInner = getVolumeFaces(node, {
592
+ widthExtension: -node.wallThickness / 2,
593
+ verticalOffset: 0,
594
+ isVoid: false,
595
+ })
596
+ const deckExtension = node.wallThickness / 2 + node.overhang * cosTheta
597
+ const roofInner = getVolumeFaces(node, {
598
+ widthExtension: deckExtension,
599
+ verticalOffset: 0,
600
+ isVoid: true,
601
+ })
602
+ const roofOuter = getShingleOuterFaces(node)
603
+ const geometryResult = buildRoofModuleGeometry(wallOuter, wallInner, roofOuter, roofInner)
604
+ if (geometryResult.status === 'blocked') {
605
+ return {
606
+ status: 'blocked',
607
+ object: null,
608
+ diagnostics: [
609
+ {
610
+ severity: 'error',
611
+ code: 'roof_print_topology_mismatch',
612
+ message: `Roof segment ${node.id} cannot form one closed print module. ${geometryResult.message}`,
613
+ nodeIds: [node.id],
614
+ },
615
+ ],
616
+ }
617
+ }
618
+
619
+ const root = new THREE.Group()
620
+ root.name = 'print-roof-segment-solids'
621
+ root.userData = { pascalId: node.id }
622
+ root.position.set(node.position[0], node.position[1], node.position[2])
623
+ root.rotation.y = node.rotation
624
+
625
+ const mesh = new THREE.Mesh(geometryResult.geometry)
626
+ mesh.name = 'print-roof-shell'
627
+ root.add(mesh)
628
+
629
+ return { status: 'ready', object: root, diagnostics: [] }
630
+ }