@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -0,0 +1,338 @@
1
+ import {
2
+ type AnyNode,
3
+ containsPoint,
4
+ detectSpacesForLevel,
5
+ getBlockFaceFrame,
6
+ getFloorPlacedFootprints,
7
+ getWallBodyCenterOffset,
8
+ getWallCurveFrameAt,
9
+ getWallCurveLength,
10
+ measurementCentroid,
11
+ nodeRegistry,
12
+ polygonInteriorPoint,
13
+ type SeparatorNode,
14
+ type WallNode,
15
+ } from '@pascal-app/core'
16
+ import { Euler, Matrix4, Quaternion, Vector3 } from 'three'
17
+
18
+ type Nodes = Readonly<Record<string, AnyNode>>
19
+ type Point = [number, number]
20
+ type Frame = { nodeId: string; levelId: string; matrix: Matrix4 }
21
+ type Shape = { polygon: Point[]; holes: Point[][] }
22
+
23
+ function fields(node: AnyNode): Record<string, unknown> {
24
+ return node as unknown as Record<string, unknown>
25
+ }
26
+
27
+ function vector(value: unknown): [number, number, number] | null {
28
+ return Array.isArray(value) && value.length === 3 && value.every(Number.isFinite)
29
+ ? (value as [number, number, number])
30
+ : null
31
+ }
32
+
33
+ function ring(value: unknown): Point[] {
34
+ return Array.isArray(value)
35
+ ? value.filter(
36
+ (point): point is Point =>
37
+ Array.isArray(point) && point.length === 2 && point.every(Number.isFinite),
38
+ )
39
+ : []
40
+ }
41
+
42
+ function centroid(polygon: Point[]): Point | null {
43
+ const centre = measurementCentroid(polygon.map(([x, z]) => [x, 0, z]))
44
+ return centre ? [centre[0], centre[2]] : null
45
+ }
46
+
47
+ function covers(shape: Shape, point: Point): boolean {
48
+ return containsPoint([{ outer: shape.polygon, holes: shape.holes }], point)
49
+ }
50
+
51
+ function snapshotScope(nodes: Nodes, targetIds: readonly string[], roomContentsOnly = false) {
52
+ const children = new Map<string, Set<string>>()
53
+ const listedParents = new Map<string, string>()
54
+ const addChild = (parent: string, child: string) => {
55
+ const ids = children.get(parent) ?? new Set<string>()
56
+ ids.add(child)
57
+ children.set(parent, ids)
58
+ }
59
+ for (const node of Object.values(nodes)) {
60
+ if (node.parentId) addChild(node.parentId, node.id)
61
+ for (const child of 'children' in node ? (node.children ?? []) : []) {
62
+ addChild(node.id, child)
63
+ if (!listedParents.has(child)) listedParents.set(child, node.id)
64
+ }
65
+ const wallId = fields(node).wallId
66
+ if (typeof wallId === 'string') addChild(wallId, node.id)
67
+ }
68
+ const parentId = (node: AnyNode): string | undefined => {
69
+ const wallId = fields(node).wallId
70
+ if (typeof wallId === 'string' && nodes[wallId]?.type === 'wall') return wallId
71
+ return node.parentId ?? listedParents.get(node.id)
72
+ }
73
+ const frames = new Map<string, Frame | null>()
74
+ const resolving = new Set<string>()
75
+ const attachmentFrame = (node: AnyNode, host: Frame): Frame => {
76
+ const hostNode = nodes[host.nodeId]
77
+ if (node.type !== 'item' || hostNode?.type !== 'block' || !node.blockFaceId) return host
78
+ const face = getBlockFaceFrame(hostNode.topology, node.blockFaceId)
79
+ if (!face) return host
80
+ // Stored item coordinates are in the face basis, as in BlockFaceHostFrame.
81
+ const local = new Matrix4()
82
+ .makeBasis(
83
+ new Vector3(...face.xAxis),
84
+ new Vector3(...face.yAxis),
85
+ new Vector3(...face.normal),
86
+ )
87
+ .setPosition(...face.origin)
88
+ return { ...host, matrix: host.matrix.clone().multiply(local) }
89
+ }
90
+ const wallPose = (wall: WallNode, position: [number, number, number]) => {
91
+ const curve = getWallCurveFrameAt(wall, position[0] / (getWallCurveLength(wall) || 1))
92
+ const across = getWallBodyCenterOffset(wall) + position[2]
93
+ return {
94
+ position: [
95
+ curve.point.x + curve.normal.x * across,
96
+ position[1],
97
+ curve.point.y + curve.normal.y * across,
98
+ ] as [number, number, number],
99
+ yaw: -Math.atan2(curve.tangent.y, curve.tangent.x),
100
+ }
101
+ }
102
+ const frame = (id: string): Frame | null => {
103
+ if (frames.has(id)) return frames.get(id) ?? null
104
+ const node = nodes[id]
105
+ if (!node || resolving.has(id)) return null
106
+ if (node.type === 'level') {
107
+ const result = { nodeId: id, levelId: id, matrix: new Matrix4() }
108
+ frames.set(id, result)
109
+ return result
110
+ }
111
+ resolving.add(id)
112
+ const parent = parentId(node)
113
+ const host = parent ? frame(parent) : null
114
+ let result: Frame | null = null
115
+ if (host) {
116
+ const data = fields(node)
117
+ let position: [number, number, number] = vector(data.position) ?? [0, 0, 0]
118
+ let rotation: [number, number, number] = vector(data.rotation) ?? [
119
+ 0,
120
+ typeof data.rotation === 'number' ? data.rotation : 0,
121
+ 0,
122
+ ]
123
+ if (node.type === 'wall') {
124
+ const [dx, dz] = [node.end[0] - node.start[0], node.end[1] - node.start[1]]
125
+ position = [node.start[0], 0, node.start[1]]
126
+ rotation = [0, -Math.atan2(dz, dx), 0]
127
+ }
128
+ // Item scale belongs to its model, not the group that hosts its children.
129
+ const scale: [number, number, number] =
130
+ node.type === 'item' ? [1, 1, 1] : (vector(data.scale) ?? [1, 1, 1])
131
+ let basis = attachmentFrame(node, host).matrix
132
+ const hostNode = nodes[host.nodeId]
133
+ let orientation = new Quaternion().setFromEuler(new Euler(...rotation))
134
+ if (hostNode?.type === 'wall') {
135
+ const wallParent = parentId(hostNode)
136
+ const wallHost = wallParent ? frame(wallParent) : null
137
+ if (wallHost) {
138
+ const pose = wallPose(hostNode, position)
139
+ position = pose.position
140
+ basis = wallHost.matrix
141
+ orientation = new Quaternion()
142
+ .setFromEuler(new Euler(0, pose.yaw, 0))
143
+ .multiply(orientation)
144
+ }
145
+ }
146
+ const local = new Matrix4().compose(
147
+ new Vector3(...position),
148
+ orientation,
149
+ new Vector3(...scale),
150
+ )
151
+ result = { nodeId: id, levelId: host.levelId, matrix: basis.clone().multiply(local) }
152
+ }
153
+ resolving.delete(id)
154
+ frames.set(id, result)
155
+ return result
156
+ }
157
+ const parentFrame = (node: AnyNode): Frame | null => {
158
+ const parent = parentId(node)
159
+ return parent ? frame(parent) : null
160
+ }
161
+ const transform = (point: [number, number, number], host: Frame): Point => {
162
+ const hostNode = nodes[host.nodeId]
163
+ if (hostNode?.type === 'wall') {
164
+ const wallHost = parentFrame(hostNode)
165
+ if (wallHost) return transform(wallPose(hostNode, point).position, wallHost)
166
+ }
167
+ const transformed = new Vector3(...point).applyMatrix4(host.matrix)
168
+ return [transformed.x, transformed.z]
169
+ }
170
+ const shape = (node: AnyNode, host: Frame): Shape => {
171
+ const data = fields(node)
172
+ const transformRing = (points: Point[]) => points.map(([x, z]) => transform([x, 0, z], host))
173
+ return {
174
+ polygon: transformRing(ring(data.polygon)),
175
+ holes: Array.isArray(data.holes) ? data.holes.map((hole) => transformRing(ring(hole))) : [],
176
+ }
177
+ }
178
+ const centres = (node: AnyNode, host: Frame): Point[] => {
179
+ host = attachmentFrame(node, host)
180
+ const data = fields(node)
181
+ const position = vector(data.position)
182
+ const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
183
+ if (
184
+ floorPlaced &&
185
+ (!floorPlaced.applies || floorPlaced.applies(node)) &&
186
+ (floorPlaced.footprint || floorPlaced.footprints)
187
+ ) {
188
+ // Resolvers read this snapshot, never the current scene store. Their positions,
189
+ // like node.position, are in the parent's frame rather than offsets from it.
190
+ return getFloorPlacedFootprints(floorPlaced, node, { nodes }).flatMap((footprint) => {
191
+ const centre = vector(footprint.position) ?? position
192
+ return centre ? [transform(centre, host)] : []
193
+ })
194
+ }
195
+ if (position) return [transform(position, host)]
196
+ const polygonCentre = centroid(ring(data.polygon))
197
+ if (polygonCentre) return [transform([polygonCentre[0], 0, polygonCentre[1]], host)]
198
+ const start = ring([data.start])[0]
199
+ const end = ring([data.end])[0]
200
+ return start && end
201
+ ? [transform([(start[0] + end[0]) / 2, 0, (start[1] + end[1]) / 2], host)]
202
+ : []
203
+ }
204
+
205
+ const nodesByLevel = new Map<string, AnyNode[]>()
206
+ for (const node of Object.values(nodes)) {
207
+ const host = parentFrame(node)
208
+ if (!host) continue
209
+ const members = nodesByLevel.get(host.levelId) ?? []
210
+ members.push(node)
211
+ nodesByLevel.set(host.levelId, members)
212
+ }
213
+ const topology = new Map<string, ReturnType<typeof detectSpacesForLevel>>()
214
+ const rooms = (levelId: string) => {
215
+ let result = topology.get(levelId)
216
+ if (!result) {
217
+ const boundaries = (nodesByLevel.get(levelId) ?? []).flatMap<WallNode | SeparatorNode>(
218
+ (node) => {
219
+ if (node.type !== 'wall' && node.type !== 'separator') return []
220
+ const host = parentFrame(node)
221
+ if (!host) return []
222
+ return [
223
+ {
224
+ ...node,
225
+ start: transform([node.start[0], 0, node.start[1]], host),
226
+ end: transform([node.end[0], 0, node.end[1]], host),
227
+ },
228
+ ]
229
+ },
230
+ )
231
+ result = detectSpacesForLevel(levelId, boundaries)
232
+ topology.set(levelId, result)
233
+ }
234
+ return result
235
+ }
236
+ const seeds = new Set<string>(roomContentsOnly ? [] : targetIds)
237
+ const roomExcluded = (node: AnyNode) =>
238
+ node.type === 'zone' ||
239
+ node.type === 'level' ||
240
+ node.type === 'building' ||
241
+ node.type === 'site' ||
242
+ node.type === 'unit'
243
+ for (const id of targetIds) {
244
+ const zone = nodes[id]
245
+ if (zone?.type !== 'zone') continue
246
+ const data = fields(zone)
247
+ const host = parentFrame(zone)
248
+ if (!host) continue
249
+ for (const key of roomContentsOnly ? [] : ['boundaryWallIds', 'boundarySeparatorIds']) {
250
+ const ids = data[key]
251
+ if (Array.isArray(ids))
252
+ for (const boundaryId of ids) {
253
+ if (typeof boundaryId !== 'string') continue
254
+ const boundary = nodes[boundaryId]
255
+ if (
256
+ boundary &&
257
+ parentFrame(boundary)?.levelId === host.levelId &&
258
+ ((key === 'boundaryWallIds' && boundary.type === 'wall') ||
259
+ (key === 'boundarySeparatorIds' && boundary.type === 'separator'))
260
+ )
261
+ seeds.add(boundaryId)
262
+ }
263
+ }
264
+ const outline = shape(zone, host)
265
+ if (outline.polygon.length < 3) continue
266
+ const levelNodes = nodesByLevel.get(host.levelId) ?? []
267
+ const inRoom = (node: AnyNode) => {
268
+ const nodeHost = parentFrame(node)
269
+ return nodeHost && centres(node, nodeHost).some((point) => covers(outline, point))
270
+ }
271
+ if (roomContentsOnly) {
272
+ for (const node of levelNodes) {
273
+ if (nodeRegistry.get(node.type)?.category === 'furnish' && inRoom(node)) seeds.add(node.id)
274
+ }
275
+ continue
276
+ }
277
+ const needsWalls = !Array.isArray(data.boundaryWallIds) || data.boundaryWallIds.length === 0
278
+ const needsSeparators =
279
+ !Array.isArray(data.boundarySeparatorIds) || data.boundarySeparatorIds.length === 0
280
+ if (
281
+ levelNodes.some(
282
+ (node) =>
283
+ (node.type === 'wall' && needsWalls) || (node.type === 'separator' && needsSeparators),
284
+ )
285
+ ) {
286
+ const interior = polygonInteriorPoint(outline, true)
287
+ for (const room of rooms(host.levelId).rooms) {
288
+ const roomShape = {
289
+ polygon: room.polygon.map(({ x, y }): Point => [x, y]),
290
+ holes: room.holes,
291
+ }
292
+ if (!covers(roomShape, interior) && !covers(outline, polygonInteriorPoint(roomShape, true)))
293
+ continue
294
+ for (const span of room.spans) {
295
+ const boundary = nodes[span.boundaryId]
296
+ if (
297
+ (boundary?.type === 'wall' && needsWalls) ||
298
+ (boundary?.type === 'separator' && needsSeparators)
299
+ )
300
+ seeds.add(span.boundaryId)
301
+ }
302
+ }
303
+ }
304
+ for (const node of levelNodes) {
305
+ if (roomExcluded(node)) continue
306
+ if (!inRoom(node)) continue
307
+ seeds.add(node.id)
308
+ // A member hosted on a wall brings the geometry host, not its level ancestors.
309
+ const geometryHost = nodes[parentId(node) ?? '']
310
+ if (geometryHost?.type === 'wall') seeds.add(geometryHost.id)
311
+ }
312
+ }
313
+ // Contents are spatial members, not every descendant of a room or shared boundary wall.
314
+ if (roomContentsOnly) return seeds
315
+ const scope = new Set<string>()
316
+ const pending = [...seeds]
317
+ while (pending.length) {
318
+ const id = pending.pop()!
319
+ if (scope.has(id)) continue
320
+ scope.add(id)
321
+ for (const child of children.get(id) ?? []) pending.push(child)
322
+ }
323
+ return scope
324
+ }
325
+
326
+ /** Current furnishings use the same footprint centres and hosting frames as the pin's scope. */
327
+ export function pointRoomHasContents(roomId: string, nodes: Nodes): boolean {
328
+ return snapshotScope(nodes, [roomId], true).size > 0
329
+ }
330
+
331
+ /** Target ancestry and room membership are evaluated independently in each snapshot. */
332
+ export function pointTargetScope(
333
+ targetIds: readonly string[],
334
+ before: Nodes,
335
+ current: Nodes,
336
+ ): ReadonlySet<string> {
337
+ return new Set([...snapshotScope(before, targetIds), ...snapshotScope(current, targetIds)])
338
+ }
@@ -0,0 +1,48 @@
1
+ // Point and ask, scene side (the owner, 8 October): the shapes the pure modules, the store and the
2
+ // overlay share. Screen units are CSS pixels in viewport coordinates; world units are metres.
3
+
4
+ export type ScreenPoint = { x: number; y: number }
5
+
6
+ /** A screen rectangle by its edges. */
7
+ export type Rect = { x0: number; y0: number; x1: number; y1: number }
8
+
9
+ /** The sides a bubble can sit on beside its target, or `dock` when the target is off screen. */
10
+ export type BubbleSide = 'right' | 'left' | 'below' | 'above' | 'dock'
11
+
12
+ /** What the bubble's suggestions and placeholder are chosen by (spec 2.4). */
13
+ export type PointKind =
14
+ | 'room'
15
+ | 'wall'
16
+ | 'window'
17
+ | 'door'
18
+ | 'item'
19
+ | 'roof'
20
+ | 'stair'
21
+ | 'several'
22
+ | 'area'
23
+ | 'other'
24
+
25
+ /** One pointed element as the overlay holds it. */
26
+ export type PointTarget = {
27
+ /** The node id, authoritative. */
28
+ id: string
29
+ /** The node's type ('zone', 'wall', 'window', ...). */
30
+ type: string
31
+ kind: PointKind
32
+ /** "Kitchen", "Wall, north side of Kitchen", "Sofa". */
33
+ name: string
34
+ /** The chip's size line in the viewer's unit: "14.2 m²", "4.80 × 2.70 m", "1.20 × 1.40 m". */
35
+ size: string
36
+ }
37
+
38
+ /** The pin's states (spec 2.6): a presence on the element while the ask runs, then its receipt. */
39
+ export type PinState =
40
+ | 'sent'
41
+ | 'queued'
42
+ | 'working'
43
+ | 'needs-input'
44
+ | 'done'
45
+ | 'answered'
46
+ | 'no-change'
47
+ | 'error'
48
+ | 'undone'
@@ -69,6 +69,34 @@ describe('portable vertex-color boundary', () => {
69
69
  })
70
70
  })
71
71
 
72
+ test('spreads a mesh with more faces than one atlas holds over several atlases', () => {
73
+ withCanvasCapture((capture) => {
74
+ const faceCount = 256 * 256 + 2
75
+ const colors = new Float32Array(faceCount * 3 * 4)
76
+ for (let vertex = 0; vertex < faceCount * 3; vertex++) {
77
+ colors.set(vertex < (faceCount - 2) * 3 ? [1, 0, 0, 1] : [0, 0, 1, 1], vertex * 4)
78
+ }
79
+ const geometry = new BufferGeometry()
80
+ geometry.setAttribute('position', new Float32BufferAttribute(faceCount * 9, 3))
81
+ geometry.setAttribute('color', new Float32BufferAttribute(colors, 4))
82
+ const mesh: Mesh = new Mesh(geometry, new MeshStandardMaterial({ vertexColors: true }))
83
+ const root = new Group()
84
+ root.add(mesh)
85
+
86
+ expect(normalizePortableScene(root)).toEqual([])
87
+
88
+ const materials = mesh.material as MeshStandardMaterial[]
89
+ expect(materials).toHaveLength(2)
90
+ expect(materials[0]!.map).not.toBe(materials[1]!.map)
91
+ expect(mesh.geometry.groups).toEqual([
92
+ { start: 0, count: (faceCount - 2) * 3, materialIndex: 0 },
93
+ { start: (faceCount - 2) * 3, count: 6, materialIndex: 1 },
94
+ ])
95
+ // The capture holds the last atlas painted: the page with the two blue faces.
96
+ expect(portableColorAt(mesh, faceCount * 3 - 1, capture)).toEqual([0, 0, 255, 255])
97
+ })
98
+ })
99
+
72
100
  test('does not warn when every channel is already portable', () => {
73
101
  withCanvasCapture(() => {
74
102
  const geometry = triangleGeometry(
@@ -300,7 +328,12 @@ function portableColorAt(mesh: Mesh, vertexIndex: number, capture: () => ImageDa
300
328
  Math.round((colors.itemSize >= 4 ? colors.getW(vertexIndex) : 1) * 255),
301
329
  ]
302
330
  }
303
- const material = mesh.material as MeshStandardMaterial
331
+ const group = mesh.geometry.groups.find(
332
+ ({ start, count }) => vertexIndex >= start && vertexIndex < start + count,
333
+ )
334
+ const material = (
335
+ Array.isArray(mesh.material) ? mesh.material[group?.materialIndex ?? 0] : mesh.material
336
+ ) as MeshStandardMaterial
304
337
  if (!material.map) throw new Error('Portable color has no material-connected carrier')
305
338
  const channel = material.map.channel ?? 0
306
339
  const uvName = channel === 0 ? 'uv' : `uv${channel}`
@@ -5,6 +5,7 @@ import { cloneExportUserData } from './export-user-data'
5
5
  const VERTEX_COLOR_UV_CHANNEL = 0
6
6
  const VERTEX_COLOR_TILE_SIZE = 8
7
7
  const MAX_VERTEX_COLOR_ATLAS_SIZE = 2048
8
+ const MAX_VERTEX_COLOR_ATLAS_FACES = (MAX_VERTEX_COLOR_ATLAS_SIZE / VERTEX_COLOR_TILE_SIZE) ** 2
8
9
 
9
10
  const MATERIAL_TEXTURE_SLOTS = [
10
11
  'map',
@@ -260,14 +261,26 @@ function colorComponent(
260
261
  return attribute.itemSize >= 4 ? attribute.getW(index) : 1
261
262
  }
262
263
 
264
+ interface VertexColorAtlasPage {
265
+ /** First face of the expanded geometry painted on this page. */
266
+ firstFace: number
267
+ faceCount: number
268
+ texture: THREE.CanvasTexture
269
+ hasAlpha: boolean
270
+ }
271
+
272
+ /**
273
+ * Paints each face of a vertex-colored geometry into its own atlas tile. A geometry with more faces
274
+ * than one export-sized atlas holds spreads over several pages, each covering a consecutive run of
275
+ * faces, so a large mesh exports at the same tile resolution as a small one.
276
+ */
263
277
  function buildVertexColorAtlas(
264
278
  geometry: THREE.BufferGeometry,
265
279
  materials: readonly THREE.Material[],
266
280
  relocatedUvChannel?: number,
267
281
  ): {
268
282
  geometry: THREE.BufferGeometry
269
- texture: THREE.CanvasTexture
270
- hasAlpha: boolean
283
+ pages: VertexColorAtlasPage[]
271
284
  clampedHdr: boolean
272
285
  } {
273
286
  const expanded = geometry.index ? geometry.toNonIndexed() : geometry.clone()
@@ -286,21 +299,6 @@ function buildVertexColorAtlas(
286
299
  throw new Error('Vertex-color baking requires triangle geometry with a color attribute')
287
300
  }
288
301
 
289
- const faceCount = position.count / 3
290
- const tilesPerRow = Math.ceil(Math.sqrt(faceCount))
291
- const atlasSize = THREE.MathUtils.ceilPowerOfTwo(tilesPerRow * VERTEX_COLOR_TILE_SIZE)
292
- if (atlasSize > MAX_VERTEX_COLOR_ATLAS_SIZE) {
293
- expanded.dispose()
294
- throw new Error(
295
- `Vertex-color atlas requires ${atlasSize}px, exceeding the ${MAX_VERTEX_COLOR_ATLAS_SIZE}px export limit`,
296
- )
297
- }
298
-
299
- const { canvas, context } = canvas2d(atlasSize, atlasSize)
300
- const image = context.createImageData(atlasSize, atlasSize)
301
-
302
- const uv = new Float32Array(position.count * 2)
303
- let hasAlpha = false
304
302
  let clampedHdr = false
305
303
  for (let index = 0; index < color.count && !clampedHdr; index++) {
306
304
  const triangleOffset = index - (index % 3)
@@ -312,9 +310,38 @@ function buildVertexColorAtlas(
312
310
  clampedHdr = red < 0 || red > 1 || green < 0 || green > 1 || blue < 0 || blue > 1
313
311
  }
314
312
 
315
- for (let face = 0; face < faceCount; face++) {
316
- const tileX = (face % tilesPerRow) * VERTEX_COLOR_TILE_SIZE
317
- const tileY = Math.floor(face / tilesPerRow) * VERTEX_COLOR_TILE_SIZE
313
+ const totalFaces = position.count / 3
314
+ const uv = new Float32Array(position.count * 2)
315
+ const pages: VertexColorAtlasPage[] = []
316
+ for (let firstFace = 0; firstFace < totalFaces; firstFace += MAX_VERTEX_COLOR_ATLAS_FACES) {
317
+ const faceCount = Math.min(MAX_VERTEX_COLOR_ATLAS_FACES, totalFaces - firstFace)
318
+ pages.push(paintVertexColorAtlasPage(expanded, materials, uv, firstFace, faceCount))
319
+ }
320
+
321
+ expanded.setAttribute(uvAttributeName(VERTEX_COLOR_UV_CHANNEL), new THREE.BufferAttribute(uv, 2))
322
+ expanded.deleteAttribute('color')
323
+ return { geometry: expanded, pages, clampedHdr }
324
+ }
325
+
326
+ /** Paints faces `firstFace … firstFace + faceCount` into one atlas and writes their tile UVs. */
327
+ function paintVertexColorAtlasPage(
328
+ expanded: THREE.BufferGeometry,
329
+ materials: readonly THREE.Material[],
330
+ uv: Float32Array,
331
+ firstFace: number,
332
+ faceCount: number,
333
+ ): VertexColorAtlasPage {
334
+ const color = expanded.getAttribute('color')
335
+ const tilesPerRow = Math.ceil(Math.sqrt(faceCount))
336
+ const atlasSize = THREE.MathUtils.ceilPowerOfTwo(tilesPerRow * VERTEX_COLOR_TILE_SIZE)
337
+ const { canvas, context } = canvas2d(atlasSize, atlasSize)
338
+ const image = context.createImageData(atlasSize, atlasSize)
339
+ let hasAlpha = false
340
+
341
+ for (let tile = 0; tile < faceCount; tile++) {
342
+ const face = firstFace + tile
343
+ const tileX = (tile % tilesPerRow) * VERTEX_COLOR_TILE_SIZE
344
+ const tileY = Math.floor(tile / tilesPerRow) * VERTEX_COLOR_TILE_SIZE
318
345
  const vertices = [face * 3, face * 3 + 1, face * 3 + 2]
319
346
  const colors = vertices.map((vertex) => [
320
347
  colorComponent(color, vertex, 0),
@@ -375,14 +402,14 @@ function buildVertexColorAtlas(
375
402
  const tileColumn = Math.floor(x / VERTEX_COLOR_TILE_SIZE)
376
403
  const tileIndex = tileRow * tilesPerRow + tileColumn
377
404
  if (tileColumn < tilesPerRow && tileIndex < faceCount) continue
378
- const sourceFace =
405
+ const sourceTile =
379
406
  tileRow * tilesPerRow < faceCount
380
407
  ? Math.min((tileRow + 1) * tilesPerRow - 1, faceCount - 1)
381
408
  : faceCount - 1
382
409
  const sourceX =
383
- (sourceFace % tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (x % VERTEX_COLOR_TILE_SIZE)
410
+ (sourceTile % tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (x % VERTEX_COLOR_TILE_SIZE)
384
411
  const sourceY =
385
- Math.floor(sourceFace / tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (y % VERTEX_COLOR_TILE_SIZE)
412
+ Math.floor(sourceTile / tilesPerRow) * VERTEX_COLOR_TILE_SIZE + (y % VERTEX_COLOR_TILE_SIZE)
386
413
  const sourcePixel = (sourceY * atlasSize + sourceX) * 4
387
414
  const targetPixel = (y * atlasSize + x) * 4
388
415
  image.data[targetPixel] = image.data[sourcePixel]!
@@ -393,16 +420,45 @@ function buildVertexColorAtlas(
393
420
  }
394
421
 
395
422
  context.putImageData(image, 0, 0)
396
- expanded.setAttribute(uvAttributeName(VERTEX_COLOR_UV_CHANNEL), new THREE.BufferAttribute(uv, 2))
397
- expanded.deleteAttribute('color')
398
423
  const texture = new THREE.CanvasTexture(canvas)
399
424
  texture.name = 'pascal_vertex_color_atlas'
400
425
  texture.channel = VERTEX_COLOR_UV_CHANNEL
401
426
  texture.colorSpace = THREE.SRGBColorSpace
402
427
  texture.flipY = true
403
428
  texture.needsUpdate = true
404
- return { geometry: expanded, texture, hasAlpha, clampedHdr }
429
+ return { firstFace, faceCount, texture, hasAlpha }
430
+ }
431
+
432
+ /**
433
+ * Geometry groups for a geometry whose faces are spread over atlas pages: every source group is cut
434
+ * at page boundaries, and the piece on page `p` uses material `p * materialCount + its own index`.
435
+ */
436
+ function pagedGroups(
437
+ geometry: THREE.BufferGeometry,
438
+ pages: readonly VertexColorAtlasPage[],
439
+ materialCount: number,
440
+ ): { start: number; count: number; materialIndex: number }[] {
441
+ const vertexCount = geometry.getAttribute('position').count
442
+ const sources = geometry.groups.length
443
+ ? geometry.groups
444
+ : [{ start: 0, count: vertexCount, materialIndex: 0 }]
445
+ const groups: { start: number; count: number; materialIndex: number }[] = []
446
+ for (const source of sources) {
447
+ const sourceEnd = Math.min(source.start + source.count, vertexCount)
448
+ pages.forEach((page, pageIndex) => {
449
+ const start = Math.max(source.start, page.firstFace * 3)
450
+ const end = Math.min(sourceEnd, (page.firstFace + page.faceCount) * 3)
451
+ if (end <= start) return
452
+ groups.push({
453
+ start,
454
+ count: end - start,
455
+ materialIndex: pageIndex * materialCount + (source.materialIndex ?? 0),
456
+ })
457
+ })
458
+ }
459
+ return groups
405
460
  }
461
+
406
462
  function hasDeformedGeometry(mesh: THREE.Mesh): boolean {
407
463
  const skinnedMesh = mesh as THREE.SkinnedMesh
408
464
  return (
@@ -466,36 +522,46 @@ function bakeVertexColors(root: THREE.Object3D, preserveDeformations = false): b
466
522
  if (baked.clampedHdr) mesh.userData.pascalHdrVertexColor = 'clamped-to-portable-range'
467
523
  clampedHdr ||= baked.clampedHdr
468
524
  const relocatedTextures = new Map<THREE.Texture, THREE.Texture>()
469
- const convertedMaterials = new Map<THREE.Material, THREE.Material>()
470
- const converted = materials.map((material) => {
471
- const cached = convertedMaterials.get(material)
472
- if (cached) return cached
473
- if (!material.vertexColors && relocatedUvChannel === undefined) return material
474
- const clone = material.clone() as TexturedMaterial
475
- if (relocatedUvChannel !== undefined) {
476
- for (const slot of MATERIAL_TEXTURE_SLOTS) {
477
- const sourceTexture = clone[slot]
478
- if (!(sourceTexture instanceof THREE.Texture) || sourceTexture.channel !== 0) continue
479
- let relocatedTexture = relocatedTextures.get(sourceTexture)
480
- if (!relocatedTexture) {
481
- relocatedTexture = sourceTexture.clone()
482
- relocatedTexture.channel = relocatedUvChannel
483
- relocatedTexture.needsUpdate = true
484
- relocatedTextures.set(sourceTexture, relocatedTexture)
485
- rememberDiscarded(root, sourceTexture)
525
+ const converted = baked.pages.flatMap((page) => {
526
+ const convertedMaterials = new Map<THREE.Material, THREE.Material>()
527
+ return materials.map((material) => {
528
+ const cached = convertedMaterials.get(material)
529
+ if (cached) return cached
530
+ if (!material.vertexColors && relocatedUvChannel === undefined) return material
531
+ const clone = material.clone() as TexturedMaterial
532
+ if (relocatedUvChannel !== undefined) {
533
+ for (const slot of MATERIAL_TEXTURE_SLOTS) {
534
+ const sourceTexture = clone[slot]
535
+ if (!(sourceTexture instanceof THREE.Texture) || sourceTexture.channel !== 0) continue
536
+ let relocatedTexture = relocatedTextures.get(sourceTexture)
537
+ if (!relocatedTexture) {
538
+ relocatedTexture = sourceTexture.clone()
539
+ relocatedTexture.channel = relocatedUvChannel
540
+ relocatedTexture.needsUpdate = true
541
+ relocatedTextures.set(sourceTexture, relocatedTexture)
542
+ rememberDiscarded(root, sourceTexture)
543
+ }
544
+ clone[slot] = relocatedTexture
486
545
  }
487
- clone[slot] = relocatedTexture
488
546
  }
489
- }
490
- if (material.vertexColors) {
491
- clone.vertexColors = false
492
- clone.map = baked.texture
493
- if (baked.hasAlpha) clone.transparent = true
494
- }
495
- convertedMaterials.set(material, clone)
496
- return clone
547
+ if (material.vertexColors) {
548
+ clone.vertexColors = false
549
+ clone.map = page.texture
550
+ if (page.hasAlpha) clone.transparent = true
551
+ }
552
+ convertedMaterials.set(material, clone)
553
+ return clone
554
+ })
497
555
  })
498
- mesh.material = Array.isArray(mesh.material) ? converted : converted[0]!
556
+ if (baked.pages.length > 1) {
557
+ const groups = pagedGroups(mesh.geometry, baked.pages, materials.length)
558
+ mesh.geometry.clearGroups()
559
+ for (const group of groups)
560
+ mesh.geometry.addGroup(group.start, group.count, group.materialIndex)
561
+ mesh.material = converted
562
+ } else {
563
+ mesh.material = Array.isArray(mesh.material) ? converted : converted[0]!
564
+ }
499
565
  rememberDiscarded(root, originalGeometry)
500
566
  for (const material of materials) rememberDiscarded(root, material)
501
567
  }