@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
@@ -10,14 +10,16 @@ import {
10
10
  type AnyNode,
11
11
  type AnyNodeId,
12
12
  type BuildingNode,
13
- calculateLevelMiters,
14
- getWallPlanFootprint,
13
+ getRoofSegmentDeckReach,
15
14
  type LevelNode,
16
15
  migrateSiteMetadata,
16
+ type RoofSegmentNode,
17
17
  type SceneSnapshot,
18
18
  type SiteNode,
19
+ siteFrame,
20
+ turnPlanPoint,
19
21
  unionPolygons,
20
- type WallNode,
22
+ yawOf,
21
23
  } from '@pascal-app/core'
22
24
  import { type Pt, pointInPolygon, polygonArea, polygonBounds } from './geometry'
23
25
 
@@ -69,98 +71,9 @@ export function findLowestLevel(
69
71
  }
70
72
 
71
73
  /**
72
- * Per-wall plan footprints for a level, mitred, then transformed by the
73
- * building's site placement. `position` is `[x, y, z]` in site metres (y is
74
- * height, ignored in plan); `rotation[1]` is the yaw in radians.
75
- */
76
- export function levelFootprintLoops(
77
- scene: SceneSnapshot,
78
- level: LevelNode | null,
79
- building: BuildingNode | null,
80
- ): Pt[][] {
81
- if (!level) return []
82
- const walls: WallNode[] = []
83
- for (const childId of level.children ?? []) {
84
- const child = scene.nodes[childId as AnyNodeId] as AnyNode | undefined
85
- if (child?.type === 'wall') walls.push(child as WallNode)
86
- }
87
- if (walls.length === 0) return []
88
-
89
- const miters = calculateLevelMiters(walls)
90
- const yaw = building?.rotation?.[1] ?? 0
91
- const cos = Math.cos(yaw)
92
- const sin = Math.sin(yaw)
93
- const ox = building?.position?.[0] ?? 0
94
- const oz = building?.position?.[2] ?? 0
95
-
96
- const loops: Pt[][] = []
97
- for (const wall of walls) {
98
- const poly = getWallPlanFootprint(wall, miters)
99
- if (poly.length < 3) continue
100
- // three.js Y rotation, the convention BuildingRenderer applies:
101
- // world = (cos·lx + sin·lz, −sin·lx + cos·lz) + position
102
- loops.push(poly.map((p) => [ox + p.x * cos + p.y * sin, oz - p.x * sin + p.y * cos] as Pt))
103
- }
104
- return loops
105
- }
106
-
107
- /**
108
- * The outer ring(s) of a set of wall bands: their union, minus any ring that
109
- * lies inside another (a room enclosed by partitions is a hole in the union,
110
- * not a second building). Falls back to the bands themselves when the union
111
- * yields nothing.
112
- */
113
- export function footprintOutline(loops: readonly Pt[][]): Pt[][] {
114
- const rings = unionPolygons(loops.map((loop) => loop.map((p) => [p[0], p[1]]))) as Pt[][]
115
- if (rings.length === 0) return [...loops]
116
- const inside = (p: Pt, ring: Pt[]): boolean => {
117
- let hit = false
118
- for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
119
- const a = ring[i] as Pt
120
- const b = ring[j] as Pt
121
- if (
122
- a[1] > p[1] !== b[1] > p[1] &&
123
- p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0]
124
- ) {
125
- hit = !hit
126
- }
127
- }
128
- return hit
129
- }
130
- return rings.filter(
131
- (ring, i) =>
132
- !rings.some((other, j) => j !== i && ring[0] !== undefined && inside(ring[0], other)),
133
- )
134
- }
135
-
136
- /** A three.js Y rotation of a plan point: local (x, z) turned by `yaw`. */
137
- export function turn(x: number, z: number, yaw: number): Pt {
138
- const c = Math.cos(yaw)
139
- const s = Math.sin(yaw)
140
- return [c * x + s * z, -s * x + c * z]
141
- }
142
-
143
- export function yawOf(rotation: unknown): number {
144
- if (typeof rotation === 'number') return rotation
145
- if (Array.isArray(rotation)) return Number(rotation[1] ?? 0) || 0
146
- return 0
147
- }
148
-
149
- /** Level-local plan (x, z) → site metres, through the building's placement (see `levelFootprintLoops`). */
150
- export function siteFrame(building: BuildingNode | null): (x: number, z: number) => Pt {
151
- const yaw = building?.rotation?.[1] ?? 0
152
- const ox = building?.position?.[0] ?? 0
153
- const oz = building?.position?.[2] ?? 0
154
- return (x, z) => {
155
- const [tx, tz] = turn(x, z, yaw)
156
- return [ox + tx, oz + tz]
157
- }
158
- }
159
-
160
- /**
161
- * The roof's outline on the lot — every roof segment's plan rectangle plus
162
- * its overhang, through the segment's, the roof's and the building's turns,
163
- * unioned into the outer ring(s). Dashed on the site plan: what the eye sees
74
+ * The roof's outline on the lot — every roof segment's plan rectangle out to
75
+ * its deck's reach at its eaves and rakes, through the segment's, the roof's
76
+ * and the building's turns, unioned into the outer ring(s). Dashed on the site plan: what the eye sees
164
77
  * from above is the roof, and the setback is measured to the wall under it.
165
78
  */
166
79
  export function roofOutlineRings(
@@ -182,15 +95,10 @@ export function roofOutlineRings(
182
95
  if (node.type !== 'roof-segment') continue
183
96
  const roof = roofs.get(String((node as { parentId?: unknown }).parentId))
184
97
  if (!roof) continue
185
- const seg = node as unknown as {
186
- position?: number[]
187
- rotation?: unknown
188
- width?: number
189
- depth?: number
190
- overhang?: number
191
- }
192
- const w = (seg.width ?? 0) / 2 + (seg.overhang ?? 0)
193
- const d = (seg.depth ?? 0) / 2 + (seg.overhang ?? 0)
98
+ const seg = node as RoofSegmentNode
99
+ const reach = getRoofSegmentDeckReach(seg)
100
+ const w = (seg.width ?? 0) / 2 + reach.x
101
+ const d = (seg.depth ?? 0) / 2 + reach.z
194
102
  if (w <= 0 || d <= 0) continue
195
103
  const segYaw = yawOf(seg.rotation)
196
104
  const roofYaw = yawOf(roof.rotation)
@@ -206,8 +114,8 @@ export function roofOutlineRings(
206
114
  ]
207
115
  rects.push(
208
116
  local.map(([lx, lz]) => {
209
- const [ax, az] = turn(lx, lz, segYaw)
210
- const [bx, bz] = turn(ax + sx, az + sz, roofYaw)
117
+ const [ax, az] = turnPlanPoint(lx, lz, segYaw)
118
+ const [bx, bz] = turnPlanPoint(ax + sx, az + sz, roofYaw)
211
119
  return toSite(bx + rx, bz + rz)
212
120
  }),
213
121
  )
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { castYardDimensions, castYardDimensionsOriented, type Pt } from './geometry'
2
+ import { castYardDimensionsOriented } from '@pascal-app/core'
3
+ import { castYardDimensions, type Pt } from './geometry'
3
4
 
4
5
  // 20 m × 30 m lot centred on the origin, y south
5
6
  const LOT: Pt[] = [
@@ -0,0 +1,17 @@
1
+ import type { AnyNode } from '@pascal-app/core'
2
+
3
+ // What the nodes package declares as `capabilities.exterior` for its wall, door and window, written
4
+ // out for the tests that register their own throw-away kinds (`packages/editor` does not import
5
+ // `@pascal-app/nodes`).
6
+
7
+ /** A wall with an outdoor face. */
8
+ export const wallExterior = (node: AnyNode) =>
9
+ node.type === 'wall' && (node.frontSide === 'exterior' || node.backSide === 'exterior')
10
+
11
+ /** An opening in a roof segment or in such a wall. */
12
+ export const openingExterior = (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) => {
13
+ if (node.type !== 'door' && node.type !== 'window') return false
14
+ if (node.roofSegmentId) return true
15
+ const host = nodes[node.wallId ?? node.parentId ?? '']
16
+ return host ? wallExterior(host) : false
17
+ }
@@ -0,0 +1,171 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ BuildingNode,
6
+ type Capabilities,
7
+ DoorNode,
8
+ ItemNode,
9
+ LevelNode,
10
+ loadPlugin,
11
+ nodeRegistry,
12
+ RoofNode,
13
+ RoofSegmentNode,
14
+ SiteNode,
15
+ WallNode,
16
+ } from '@pascal-app/core'
17
+ import { anyExterior, isExteriorNode } from './follow-pascal-exterior'
18
+ import { openingExterior, wallExterior } from './follow-pascal-exterior.test-fixture'
19
+
20
+ // The kinds are the nodes package's (it declares `capabilities.exterior` on them); fixtures of the
21
+ // same kind stand in for them here, and plugin-style kinds that declare the same fields are held to
22
+ // the same rules.
23
+ const kind = (type: string, schema: unknown, exterior?: Capabilities['exterior']) =>
24
+ ({
25
+ kind: type,
26
+ schemaVersion: 1,
27
+ schema,
28
+ category: 'structure',
29
+ defaults: () => ({}),
30
+ capabilities: exterior === undefined ? {} : { exterior },
31
+ geometry: () => null,
32
+ }) as unknown as AnyNodeDefinition
33
+
34
+ let restoreRegistry: () => void = () => {}
35
+ beforeAll(async () => {
36
+ restoreRegistry = nodeRegistry._snapshot()
37
+ nodeRegistry._reset()
38
+ await loadPlugin({
39
+ id: 'fixture:exterior',
40
+ apiVersion: 1,
41
+ nodes: [
42
+ kind('site', SiteNode, true),
43
+ kind('building', BuildingNode, false),
44
+ kind('level', LevelNode, false),
45
+ kind('roof', RoofNode, true),
46
+ kind('roof-segment', RoofSegmentNode, true),
47
+ kind('wall', WallNode, wallExterior),
48
+ kind('door', DoorNode, openingExterior),
49
+ kind('item', ItemNode),
50
+ // A plugin's own kinds: outdoors, outdoors by a field, and saying nothing.
51
+ kind('fixture:deck', ItemNode, true),
52
+ kind('fixture:cover', ItemNode, (node) => (node.name === 'seen' ? true : undefined)),
53
+ kind('fixture:cable', ItemNode),
54
+ ],
55
+ })
56
+ })
57
+ afterAll(() => restoreRegistry())
58
+
59
+ function scene() {
60
+ const site = SiteNode.parse({ id: 'site_x' })
61
+ const building = BuildingNode.parse({ id: 'building_x', parentId: site.id })
62
+ const level = LevelNode.parse({ id: 'level_x', parentId: building.id, level: 0 })
63
+ const outer = WallNode.parse({
64
+ id: 'wall_outer',
65
+ parentId: level.id,
66
+ start: [0, 0],
67
+ end: [4, 0],
68
+ frontSide: 'interior',
69
+ backSide: 'exterior',
70
+ })
71
+ const inner = WallNode.parse({
72
+ id: 'wall_inner',
73
+ parentId: level.id,
74
+ start: [0, 2],
75
+ end: [4, 2],
76
+ frontSide: 'interior',
77
+ backSide: 'interior',
78
+ })
79
+ const onOuter = DoorNode.parse({ id: 'door_outer', parentId: outer.id, wallId: outer.id })
80
+ const onInner = DoorNode.parse({ id: 'door_inner', parentId: inner.id, wallId: inner.id })
81
+ const sofa = ItemNode.parse({
82
+ id: 'item_sofa',
83
+ parentId: level.id,
84
+ asset: {
85
+ id: 'sofa',
86
+ category: 'furniture',
87
+ name: 'Sofa',
88
+ thumbnail: '',
89
+ src: '/sofa.glb',
90
+ dimensions: [2, 1, 1],
91
+ },
92
+ })
93
+ const bench = ItemNode.parse({ ...sofa, id: 'item_bench', parentId: site.id })
94
+ const segment = RoofSegmentNode.parse({ id: 'rseg_x', parentId: 'roof_x' })
95
+ const roof = RoofNode.parse({ id: 'roof_x', parentId: level.id, children: [segment.id] })
96
+ // Only their kind, name and parent matter here.
97
+ const plugin = (id: string, type: string, parentId: string, name?: string) =>
98
+ ({ id, type, parentId, name }) as unknown as AnyNode
99
+ const deck = plugin('deck_x', 'fixture:deck', site.id)
100
+ const deckOnLevel = plugin('deck_level', 'fixture:deck', level.id)
101
+ const underDeck = plugin('cable_deck', 'fixture:cable', deckOnLevel.id)
102
+ const underRoof = plugin('cable_roof', 'fixture:cable', roof.id)
103
+ const cableInRoom = plugin('cable_room', 'fixture:cable', level.id)
104
+ const seen = plugin('cover_seen', 'fixture:cover', level.id, 'seen')
105
+ const unseen = plugin('cover_unseen', 'fixture:cover', level.id)
106
+ const coverOnSite = plugin('cover_site', 'fixture:cover', site.id)
107
+ return Object.fromEntries(
108
+ [
109
+ site,
110
+ building,
111
+ level,
112
+ outer,
113
+ inner,
114
+ onOuter,
115
+ onInner,
116
+ sofa,
117
+ bench,
118
+ roof,
119
+ segment,
120
+ deck,
121
+ deckOnLevel,
122
+ underDeck,
123
+ underRoof,
124
+ cableInRoom,
125
+ seen,
126
+ unseen,
127
+ coverOnSite,
128
+ ].map((node) => [node.id, node as AnyNode]),
129
+ )
130
+ }
131
+
132
+ describe('what a change is seen from', () => {
133
+ const nodes = scene()
134
+
135
+ test('a wall with an exterior face, and an opening in it, are seen from outside', () => {
136
+ expect(isExteriorNode(nodes, 'wall_outer')).toBe(true)
137
+ expect(isExteriorNode(nodes, 'door_outer')).toBe(true)
138
+ })
139
+
140
+ test('an interior wall, an opening in it and furniture are looked at through a cutaway', () => {
141
+ expect(isExteriorNode(nodes, 'wall_inner')).toBe(false)
142
+ expect(isExteriorNode(nodes, 'door_inner')).toBe(false)
143
+ expect(isExteriorNode(nodes, 'item_sofa')).toBe(false)
144
+ })
145
+
146
+ test('a roof and what stands on it, the ground, and furnishing standing in no storey are outside', () => {
147
+ for (const id of ['roof_x', 'rseg_x', 'deck_x', 'item_bench'])
148
+ expect(isExteriorNode(nodes, id)).toBe(true)
149
+ })
150
+
151
+ // A kind that declares what the built-ins declare is treated as the built-in is.
152
+ test('a plugin kind is outdoors as its definition says: always, by a field, or by what holds it', () => {
153
+ expect(isExteriorNode(nodes, 'deck_level')).toBe(true)
154
+ expect(isExteriorNode(nodes, 'cover_seen')).toBe(true)
155
+ expect(isExteriorNode(nodes, 'cover_unseen')).toBe(false)
156
+ expect(isExteriorNode(nodes, 'cable_deck')).toBe(true)
157
+ expect(isExteriorNode(nodes, 'cable_roof')).toBe(true)
158
+ expect(isExteriorNode(nodes, 'cable_room')).toBe(false)
159
+ expect(isExteriorNode(nodes, 'cover_site')).toBe(true)
160
+ })
161
+
162
+ test('a node that is not in the scene is nothing', () => {
163
+ expect(isExteriorNode(nodes, 'wall_gone')).toBe(false)
164
+ })
165
+
166
+ test('a group is outside when any of its nodes is', () => {
167
+ expect(anyExterior(nodes, ['item_sofa', 'door_outer'])).toBe(true)
168
+ expect(anyExterior(nodes, ['item_sofa', 'wall_inner'])).toBe(false)
169
+ expect(anyExterior(nodes, [])).toBe(false)
170
+ })
171
+ })
@@ -0,0 +1,39 @@
1
+ import { type AnyNode, type AnyNodeId, findLevelAncestorId, nodeRegistry } from '@pascal-app/core'
2
+
3
+ type Nodes = Readonly<Record<string, AnyNode>>
4
+
5
+ /** What a kind says about a node (`capabilities.exterior`): outdoors, never, or nothing. */
6
+ function declaredExterior(node: AnyNode, nodes: Nodes): boolean | undefined {
7
+ const exterior = nodeRegistry.get(node.type)?.capabilities?.exterior
8
+ return typeof exterior === 'function' ? exterior(node, nodes) : exterior
9
+ }
10
+
11
+ /**
12
+ * Whether a change to this node is seen from outside, as its kind declares (`capabilities.exterior`:
13
+ * a roof, a wall with an exterior face and an opening in it, a fence). A node whose kind says nothing
14
+ * is outdoors when what holds it is (a chimney's cap), or when it stands in no building's storey
15
+ * (outdoor furnishing, plants). A sofa, an interior wall and a finish inside are not: they are looked
16
+ * at through a cutaway.
17
+ */
18
+ export function isExteriorNode(nodes: Nodes, id: string): boolean {
19
+ const node = nodes[id]
20
+ if (!node) return false
21
+ const own = declaredExterior(node, nodes)
22
+ if (own !== undefined) return own
23
+ // Standing under a node that is outdoors, up to the first one that never is (a storey).
24
+ for (
25
+ let at = node.parentId ? nodes[node.parentId] : undefined, guard = 0;
26
+ at && guard < 16;
27
+ at = at.parentId ? nodes[at.parentId] : undefined, guard += 1
28
+ ) {
29
+ const held = nodeRegistry.get(at.type)?.capabilities?.exterior
30
+ if (held === true) return true
31
+ if (held === false) break
32
+ }
33
+ return findLevelAncestorId(id as AnyNodeId, nodes as never) === null
34
+ }
35
+
36
+ /** Whether any of `ids` is a change seen from outside. */
37
+ export function anyExterior(nodes: Nodes, ids: readonly string[]): boolean {
38
+ return ids.some((id) => isExteriorNode(nodes, id))
39
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type Floor,
4
+ FollowPresentation,
5
+ type PresentationPort,
6
+ type ViewPresentation,
7
+ } from './follow-pascal-presentation'
8
+
9
+ // The owner, 12 October, on 3114: Follow Pascal left the view in cutaway with the upper floors hidden
10
+ // after it stopped, and the walls looked invisible. Follow changes the presentation to see what a build
11
+ // works on; when it ends it puts back what it changed, and only that.
12
+
13
+ const GROUND: Floor = {
14
+ buildingId: 'b',
15
+ levelId: 'ground',
16
+ zoneId: null,
17
+ selectedIds: [],
18
+ room: null,
19
+ }
20
+
21
+ /** A view and a record of every write made to it. */
22
+ function view(initial: Partial<ViewPresentation> = {}) {
23
+ const state: ViewPresentation = {
24
+ wall: 'up',
25
+ levels: 'stacked',
26
+ hideAbove: false,
27
+ floor: { ...GROUND },
28
+ ...initial,
29
+ }
30
+ const writes: string[] = []
31
+ const port: PresentationPort = {
32
+ read: () => ({ ...state, floor: { ...state.floor } }),
33
+ setWall: (mode) => {
34
+ writes.push(`wall:${mode}`)
35
+ state.wall = mode
36
+ },
37
+ setLevels: (mode) => {
38
+ writes.push(`levels:${mode}`)
39
+ state.levels = mode
40
+ },
41
+ setHideAbove: (hide) => {
42
+ writes.push(`hideAbove:${hide}`)
43
+ state.hideAbove = hide
44
+ },
45
+ // A selected floor drops the room and what was picked, as the viewer's selection does.
46
+ setFloor: ({ buildingId, levelId }) => {
47
+ writes.push(`floor:${levelId}`)
48
+ state.floor = {
49
+ buildingId: buildingId ?? state.floor.buildingId,
50
+ levelId,
51
+ zoneId: null,
52
+ selectedIds: [],
53
+ room: null,
54
+ }
55
+ },
56
+ putFloor: (floor) => {
57
+ writes.push(`put:${floor.levelId}`)
58
+ state.floor = { ...floor }
59
+ },
60
+ }
61
+ return { state, writes, port }
62
+ }
63
+
64
+ describe('what Follow changes, it puts back', () => {
65
+ test('the cutaway it switched to', () => {
66
+ const { state, port } = view({ wall: 'up' })
67
+ const follow = new FollowPresentation(port)
68
+ follow.wall('cutaway')
69
+ expect(state.wall).toBe('cutaway')
70
+ follow.restore()
71
+ expect(state.wall).toBe('up')
72
+ })
73
+
74
+ test('the floor it selected, with the room and the picks the person had', () => {
75
+ const had: Floor = {
76
+ buildingId: 'b',
77
+ levelId: 'upper',
78
+ zoneId: 'zone_kitchen',
79
+ selectedIds: ['item_a', 'item_b'],
80
+ room: { levelId: 'upper', zoneId: 'zone_kitchen' },
81
+ }
82
+ const { state, port } = view({ floor: had })
83
+ const follow = new FollowPresentation(port)
84
+ follow.floor({ buildingId: 'b', levelId: 'ground' })
85
+ expect(state.floor.levelId).toBe('ground')
86
+ expect(state.floor.room).toBeNull()
87
+ follow.restore()
88
+ expect(state.floor).toEqual(had)
89
+ })
90
+
91
+ test('the upper floors it hid, and the level display it set', () => {
92
+ const { state, port } = view({ hideAbove: false, levels: 'solo' })
93
+ const follow = new FollowPresentation(port)
94
+ follow.hideAbove(true)
95
+ follow.levels('stacked')
96
+ expect(state).toMatchObject({ hideAbove: true, levels: 'stacked' })
97
+ follow.restore()
98
+ expect(state).toMatchObject({ hideAbove: false, levels: 'solo' })
99
+ })
100
+
101
+ test('to what the person had before the first change, however many floors it visited', () => {
102
+ const { state, port } = view()
103
+ const follow = new FollowPresentation(port)
104
+ follow.floor({ buildingId: 'b', levelId: 'upper' })
105
+ follow.floor({ buildingId: 'b', levelId: 'roof' })
106
+ follow.wall('cutaway')
107
+ follow.wall('cutaway')
108
+ follow.restore()
109
+ expect(state.floor.levelId).toBe('ground')
110
+ expect(state.wall).toBe('up')
111
+ })
112
+
113
+ test('nothing it did not change: a setting already as Follow wants it is not written', () => {
114
+ const { writes, port } = view({ wall: 'cutaway', hideAbove: true, levels: 'stacked' })
115
+ const follow = new FollowPresentation(port)
116
+ follow.wall('cutaway')
117
+ follow.hideAbove(true)
118
+ follow.levels('stacked')
119
+ follow.floor({ buildingId: 'b', levelId: 'ground' })
120
+ follow.restore()
121
+ expect(writes).toEqual([])
122
+ })
123
+
124
+ test('once: ending again, with nothing held, writes nothing', () => {
125
+ const { writes, port } = view()
126
+ const follow = new FollowPresentation(port)
127
+ follow.wall('cutaway')
128
+ follow.restore()
129
+ const count = writes.length
130
+ follow.restore()
131
+ expect(writes).toHaveLength(count)
132
+ })
133
+
134
+ test('a later session starts from what the person has then', () => {
135
+ const { state, port } = view()
136
+ const follow = new FollowPresentation(port)
137
+ follow.wall('cutaway')
138
+ follow.restore()
139
+ state.wall = 'down'
140
+ follow.wall('cutaway')
141
+ follow.restore()
142
+ expect(state.wall).toBe('down')
143
+ })
144
+ })
145
+
146
+ describe("the person's own choices stand", () => {
147
+ test('a wall mode picked while Follow is on is kept, and Follow does not take it back', () => {
148
+ const { state, writes, port } = view({ wall: 'up' })
149
+ const follow = new FollowPresentation(port)
150
+ follow.wall('cutaway')
151
+ state.wall = 'translucent'
152
+ follow.wall('cutaway')
153
+ expect(state.wall).toBe('translucent')
154
+ follow.restore()
155
+ expect(state.wall).toBe('translucent')
156
+ expect(writes).toEqual(['wall:cutaway'])
157
+ })
158
+
159
+ test('a floor picked while Follow is on is kept, and Follow does not select over it', () => {
160
+ const { state, writes, port } = view()
161
+ const follow = new FollowPresentation(port)
162
+ follow.floor({ buildingId: 'b', levelId: 'upper' })
163
+ state.floor = { ...state.floor, levelId: 'roof' }
164
+ follow.floor({ buildingId: 'b', levelId: 'upper' })
165
+ expect(state.floor.levelId).toBe('roof')
166
+ follow.restore()
167
+ expect(state.floor.levelId).toBe('roof')
168
+ expect(writes).toEqual(['floor:upper'])
169
+ })
170
+
171
+ test('the floor stays when the person picked something on it', () => {
172
+ const { state, writes, port } = view()
173
+ const follow = new FollowPresentation(port)
174
+ follow.floor({ buildingId: 'b', levelId: 'upper' })
175
+ state.floor = { ...state.floor, selectedIds: ['item_x'] }
176
+ follow.restore()
177
+ expect(state.floor).toMatchObject({ levelId: 'upper', selectedIds: ['item_x'] })
178
+ expect(writes).toEqual(['floor:upper'])
179
+ })
180
+
181
+ test('the settings are independent: a wall mode of their own, the floor still goes back', () => {
182
+ const { state, port } = view()
183
+ const follow = new FollowPresentation(port)
184
+ follow.wall('cutaway')
185
+ follow.floor({ buildingId: 'b', levelId: 'upper' })
186
+ state.wall = 'down'
187
+ follow.restore()
188
+ expect(state.wall).toBe('down')
189
+ expect(state.floor.levelId).toBe('ground')
190
+ })
191
+
192
+ test("the person's choice is theirs for the session, then Follow may change it again", () => {
193
+ const { state, port } = view()
194
+ const follow = new FollowPresentation(port)
195
+ follow.wall('cutaway')
196
+ state.wall = 'down'
197
+ follow.restore()
198
+ follow.wall('cutaway')
199
+ expect(port.read().wall).toBe('cutaway')
200
+ follow.restore()
201
+ expect(port.read().wall).toBe('down')
202
+ })
203
+ })