@pascal-app/editor 1.0.4 → 1.0.6

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 +40 -38
  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,83 @@
1
+ 'use client'
2
+
3
+ import {
4
+ emitter,
5
+ type PointAskStatus,
6
+ registerPointAskHandler,
7
+ useHasPointAskHandler,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { subscribeRevealEvents, useViewer } from '@pascal-app/viewer'
11
+ import { useEffect } from 'react'
12
+ import { startPointAskSession } from '../../lib/point-ask/session'
13
+ import { usePointAsk } from '../../store/use-point-ask'
14
+ import { PointAskBubble } from './bubble'
15
+ import { HoverChip } from './hover-chip'
16
+ import { BeforeTag, ModePill, ModeVignette } from './mode-pill'
17
+ import { usePointMotionCalm } from './optional-motion'
18
+ import { OutlineCorners } from './outline-corners'
19
+ import { PointAskPins } from './pins'
20
+ import { POINT_ASK_CSS } from './point-ask-styles'
21
+ import { RegionMarquee } from './region-marquee'
22
+ import { REGION_MARQUEE_CSS } from './region-marquee-styles'
23
+
24
+ const CALM_CSS = `
25
+ .pa-layer[data-calm] .pa-hlabel{transform:none;transition-property:opacity}
26
+ .pa-layer[data-calm] .pa-send,.pa-layer[data-calm] .pa-dict,.pa-layer[data-calm] .pa-btn,.pa-layer[data-calm] .pa-hold{transform:none;transition-property:opacity,color,background}
27
+ .pa-layer[data-calm] .pa-pk,.pa-layer[data-calm] .pa-edgemark{transition:none}
28
+ `
29
+
30
+ /**
31
+ * Point and ask's overlay (the owner, 8 October): the outline's corners, the label chip, the
32
+ * anchored bubble and the pins, drawn over the 3D view and following its camera. It renders only
33
+ * where a host has registered a handler for asks (the open-source editor has no chat), though pins
34
+ * already planted stay until they retire.
35
+ */
36
+ export function PointAskLayer() {
37
+ const available = useHasPointAskHandler()
38
+ const hasPins = usePointAsk((s) => s.pinOrder.length > 0)
39
+ const active = usePointAsk((s) => s.active)
40
+ const region = usePointAsk((s) => s.region)
41
+ const calm = usePointMotionCalm()
42
+
43
+ useEffect(() => startPointAskSession(), [])
44
+
45
+ // A test door in development only, like the camera controls': a page script registers a fake
46
+ // chat and plays its statuses, so the scene side can be driven without a model.
47
+ useEffect(() => {
48
+ if (process.env.NODE_ENV !== 'development') return
49
+ const w = window as typeof window & { __pascalPointAskTest?: unknown }
50
+ w.__pascalPointAskTest = {
51
+ register: registerPointAskHandler,
52
+ status: (status: PointAskStatus) => emitter.emit('point-ask:status', status),
53
+ store: usePointAsk,
54
+ scene: useScene,
55
+ emitter,
56
+ viewer: useViewer,
57
+ reveal: subscribeRevealEvents,
58
+ }
59
+ return () => {
60
+ w.__pascalPointAskTest = undefined
61
+ }
62
+ }, [])
63
+
64
+ if (!(available || hasPins || active)) return null
65
+ return (
66
+ <div className="pa-layer" data-calm={calm || undefined} data-point-ask-layer>
67
+ <style>{POINT_ASK_CSS + REGION_MARQUEE_CSS + CALM_CSS}</style>
68
+ <ModeVignette />
69
+ <OutlineCorners />
70
+ <RegionMarquee
71
+ flashKey={region?.flashKey ?? 0}
72
+ rect={region?.rect ?? null}
73
+ settled={region?.settled ?? false}
74
+ sizeLabel={region?.sizeLabel ?? null}
75
+ />
76
+ <PointAskPins />
77
+ <PointAskBubble />
78
+ <HoverChip />
79
+ <ModePill />
80
+ <BeforeTag />
81
+ </div>
82
+ )
83
+ }
@@ -0,0 +1,105 @@
1
+ // The look of Point and ask: one stylesheet for the whole overlay, its tokens and moments taken
2
+ // from the chat prototype the owner approved (chat-prototype/pascal-builds.html). Every class is
3
+ // prefixed `pa-`; motion that follows the camera is set from script, the rest is declared here.
4
+ export const POINT_ASK_CSS = `
5
+ .pa-layer{--pa-accent:#a99bff;--pa-ok:#79e2b0;--pa-warm:#f6b866;--pa-bad:#ff8a80;--pa-tx:#edece9;--pa-tx2:#a7a7ae;--pa-tx3:#73747c;--pa-ink:#16171b;--pa-ease:cubic-bezier(.23,1,.32,1);
6
+ position:fixed;inset:0;pointer-events:none;z-index:45;color:var(--pa-tx);font-size:13px;line-height:1.35;-webkit-font-smoothing:antialiased}
7
+ .pa-layer *{box-sizing:border-box}
8
+ :where(.pa-layer) button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
9
+ .pa-vignette{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity 150ms var(--pa-ease);box-shadow:inset 0 0 90px 6px rgba(169,155,255,.16),inset 0 0 0 1.5px rgba(169,155,255,.26)}
10
+ .pa-vignette.on{opacity:1;transition-duration:180ms}
11
+ .pa-pill{position:absolute;left:50%;top:64px;transform:translateX(-50%);padding:6px 14px;border-radius:999px;background:rgba(19,20,24,.82);backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.09),0 10px 28px -12px rgba(0,0,0,.7);white-space:nowrap;font-weight:500;color:var(--pa-tx2)}
12
+ .pa-pill b{color:var(--pa-tx);font-weight:600}
13
+ .pa-fc{position:absolute;left:0;top:0;width:12px;height:12px;border:0 solid var(--pa-accent);opacity:0;transition:opacity 120ms ease;filter:drop-shadow(0 0 3px rgba(169,155,255,.6));will-change:transform}
14
+ .pa-fc.tl{border-width:2px 0 0 2px;border-top-left-radius:4px;margin:-3px 0 0 -3px}
15
+ .pa-fc.tr{border-width:2px 2px 0 0;border-top-right-radius:4px;margin:-3px 0 0 -9px}
16
+ .pa-fc.bl{border-width:0 0 2px 2px;border-bottom-left-radius:4px;margin:-9px 0 0 -3px}
17
+ .pa-fc.br{border-width:0 2px 2px 0;border-bottom-right-radius:4px;margin:-9px 0 0 -9px}
18
+ .pa-chipwrap{position:absolute;left:0;top:0;will-change:transform}
19
+ .pa-hlabel{padding:4px 10px;border-radius:12px;background:rgba(18,19,23,.9);box-shadow:inset 0 0 0 1px rgba(255,255,255,.1);font-weight:600;font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap;opacity:0;transform:translateY(4px);transition:opacity 120ms ease,transform 180ms var(--pa-ease)}
20
+ .pa-hlabel.show{opacity:1;transform:none}
21
+ .pa-hlabel.press{transform:scale(.97);transition-duration:120ms}
22
+ .pa-hlabel .sub{display:block;font-weight:500;font-size:11px;color:var(--pa-tx3)}
23
+ .pa-hlabel .sz{color:var(--pa-tx2);font-weight:500;margin-left:6px}
24
+ .pa-bwrap{position:absolute;left:0;top:0;pointer-events:auto;will-change:transform}
25
+ .pa-bub{position:relative;width:320px;padding:12px;border-radius:14px;background:rgba(32,33,39,.95);backdrop-filter:blur(18px) saturate(150%);-webkit-backdrop-filter:blur(18px) saturate(150%);box-shadow:inset 0 1px 0 rgba(255,255,255,.08),inset 0 0 0 1px rgba(255,255,255,.06),0 18px 40px -16px rgba(0,0,0,.75)}
26
+ .pa-tail{position:absolute;left:0;top:0;width:12px;height:12px;border-radius:2px;background:rgba(32,33,39,.95)}
27
+ .pa-ctx{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:10px;cursor:grab;position:relative}
28
+ .pa-ctx:active{cursor:grabbing}
29
+ .pa-thumb{width:40px;height:40px;border-radius:8px;object-fit:cover;margin-left:auto;background:#0f1013}
30
+ .pa-nopic{margin-left:auto;font-size:11px;color:var(--pa-tx3);font-weight:500}
31
+ .pa-tchip{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 6px 0 8px;border-radius:999px;background:rgba(169,155,255,.16);color:#d9d2ff;font-weight:600;font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap;max-width:210px}
32
+ .pa-tchip i{width:6px;height:6px;border-radius:50%;background:var(--pa-accent);flex:none}
33
+ .pa-tchip span{overflow:hidden;text-overflow:ellipsis}
34
+ .pa-tchip .tx{width:16px;height:16px;display:grid;place-items:center;border-radius:50%;color:#cfc7ff;flex:none}
35
+ .pa-tchip .tx:hover{background:rgba(255,255,255,.12)}
36
+ .pa-tchip .tx svg{width:10px;height:10px}
37
+ .pa-field{display:flex;align-items:center;gap:6px;padding:4px 4px 4px 12px;border-radius:999px;background:rgba(255,255,255,.06)}
38
+ .pa-field input{flex:1;min-width:0;height:30px;font:inherit;font-size:14px;color:var(--pa-tx);background:none;border:0;outline:0}
39
+ .pa-field input::placeholder{color:var(--pa-tx3)}
40
+ .pa-send{height:30px;min-width:30px;border-radius:999px;background:#edece9;color:var(--pa-ink);display:grid;place-items:center;transition:transform 120ms var(--pa-ease),opacity 160ms ease}
41
+ .pa-send:active{transform:scale(.97)}
42
+ .pa-send:disabled{opacity:.4;cursor:default}
43
+ .pa-send svg{width:15px;height:15px}
44
+ .pa-send.queue{padding:0 12px;font-weight:600;font-size:13px}
45
+ .pa-dict{width:30px;height:30px;border-radius:50%;color:var(--pa-tx2);display:grid;place-items:center;transition:transform 120ms var(--pa-ease),color 160ms ease,background 160ms ease}
46
+ .pa-dict:hover{color:var(--pa-tx);background:rgba(255,255,255,.08)}
47
+ .pa-dict:active{transform:scale(.97)}
48
+ .pa-dict.on{color:var(--pa-ink);background:var(--pa-accent)}
49
+ .pa-dict svg{width:16px;height:16px}
50
+ .pa-sugs{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
51
+ .pa-btn{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;border-radius:999px;background:rgba(255,255,255,.08);color:var(--pa-tx);font-weight:500;font-size:12.5px;white-space:nowrap;transition:transform 120ms var(--pa-ease),background 160ms ease}
52
+ .pa-btn:hover{background:rgba(255,255,255,.13)}
53
+ .pa-btn:active{transform:scale(.97)}
54
+ .pa-btn.primary{background:#edece9;color:var(--pa-ink)}
55
+ .pa-btn.primary:hover{background:#fff}
56
+ .pa-btn.ghost{background:transparent;box-shadow:inset 0 0 0 1px rgba(255,255,255,.14)}
57
+ .pa-err{margin:8px 2px 0;font-size:12px;color:var(--pa-warm);display:flex;gap:8px;align-items:center;flex-wrap:wrap}
58
+ .pa-pin{position:absolute;left:0;top:0;width:32px;height:32px;margin:-38px 0 0 -16px;pointer-events:auto;cursor:pointer;display:grid;place-items:center;will-change:transform}
59
+ .pa-pin::after{content:"";position:absolute;left:50%;top:28px;width:2px;height:8px;margin-left:-1px;border-radius:1px;background:var(--pa-accent);opacity:.85}
60
+ .pa-pdisc{width:24px;height:24px;border-radius:50%;background:rgba(24,25,30,.92);box-shadow:inset 0 0 0 1.5px rgba(169,155,255,.9),0 6px 16px -6px rgba(0,0,0,.7);display:grid;place-items:center;transition:box-shadow 240ms ease}
61
+ .pa-pdisc svg{width:20px;height:20px;overflow:visible}
62
+ .pa-pq,.pa-pa{fill:none;stroke:#a99bff;stroke-width:1.8;opacity:0;transition:opacity 160ms ease}
63
+ .pa-pq{stroke-dasharray:2.4 2.4}
64
+ .pa-pa{stroke-width:2;stroke-linecap:round}
65
+ .pa-pk{fill:none;stroke:var(--pa-ok);stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:14;stroke-dashoffset:14;transition:stroke-dashoffset 240ms var(--pa-ease) 80ms}
66
+ .pa-sg{fill:none;stroke:var(--pa-accent);stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;opacity:0;transition:opacity 160ms ease}
67
+ .pa-pin.queued .pa-pq,.pa-pin.working .pa-pa{opacity:1}
68
+ .pa-pin.needs-input .pa-pdisc{box-shadow:inset 0 0 0 1.5px rgba(246,184,102,.95),0 0 14px 1px rgba(246,184,102,.45),0 6px 16px -6px rgba(0,0,0,.7)}
69
+ .pa-pin.needs-input .pa-pa{opacity:1;stroke:var(--pa-warm)}
70
+ .pa-pin.done .pa-pk{stroke-dashoffset:0}
71
+ .pa-pin.done .pa-pdisc{box-shadow:inset 0 0 0 1.5px rgba(121,226,176,.9),0 6px 16px -6px rgba(0,0,0,.7)}
72
+ .pa-pin.done::after{background:var(--pa-ok)}
73
+ .pa-pin.answered .pa-sg{opacity:1}
74
+ .pa-pin.no-change .pa-pdisc{box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.35),0 6px 16px -6px rgba(0,0,0,.7)}
75
+ .pa-pin.no-change .pa-pk{stroke:var(--pa-tx3);stroke-dashoffset:0;opacity:.8}
76
+ .pa-pin.no-change::after{background:var(--pa-tx3)}
77
+ .pa-pin.error .pa-pdisc{box-shadow:inset 0 0 0 1.5px rgba(255,138,128,.95),0 6px 16px -6px rgba(0,0,0,.7)}
78
+ .pa-pin.error::after{background:var(--pa-bad)}
79
+ .pa-pin.undone .pa-pq{opacity:.7;stroke:var(--pa-tx3)}
80
+ .pa-pin.undone .pa-pdisc{box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.25),0 6px 16px -6px rgba(0,0,0,.7)}
81
+ .pa-pin.undone::after{background:var(--pa-tx3)}
82
+ .pa-pin.edge{pointer-events:auto}
83
+ .pa-ring{position:absolute;left:4px;top:4px;width:24px;height:24px;border-radius:50%;box-shadow:0 0 0 1.5px var(--pa-accent);opacity:0;pointer-events:none}
84
+ .pa-edgemark{position:absolute;left:0;top:0;display:flex;align-items:center;gap:6px;height:28px;padding:0 12px 0 8px;border-radius:999px;background:rgba(19,20,24,.88);backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%);box-shadow:inset 0 0 0 1px rgba(169,155,255,.45),0 8px 22px -10px rgba(0,0,0,.7);color:var(--pa-tx);font-weight:600;font-size:12px;white-space:nowrap;pointer-events:auto;cursor:pointer;will-change:transform;transition:transform 120ms var(--pa-ease)}
85
+ .pa-edgemark svg{width:14px;height:14px;color:var(--pa-accent);flex:none}
86
+ .pa-cardwrap{position:absolute;left:0;top:0;pointer-events:auto;will-change:transform}
87
+ .pa-card{width:250px;padding:10px;border-radius:14px;background:rgba(30,31,37,.95);backdrop-filter:blur(18px) saturate(150%);-webkit-backdrop-filter:blur(18px) saturate(150%);box-shadow:inset 0 1px 0 rgba(255,255,255,.07),0 18px 40px -16px rgba(0,0,0,.75);transform-origin:50% 100%}
88
+ .pa-card-t{font-weight:600;font-size:13.5px;line-height:1.3}
89
+ .pa-card-s{margin-top:2px;font-size:12px;color:var(--pa-tx2)}
90
+ .pa-card-a{margin-top:6px;font-size:12px;color:var(--pa-tx2);text-decoration:underline;text-decoration-color:rgba(255,255,255,.2);text-underline-offset:2px;cursor:default}
91
+ .pa-card-row{display:flex;gap:6px;margin-top:8px}
92
+ .pa-card-head{display:flex;align-items:center;gap:10px}
93
+ .pa-card-head .pa-card-t{flex:1;min-width:0}
94
+ .pa-card-thumb{flex:none;width:64px;height:48px;border-radius:8px;overflow:hidden;background:#0f1013;box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)}
95
+ .pa-card-thumb img{width:100%;height:100%;object-fit:cover;display:block}
96
+ .pa-hold{display:flex;align-items:center;justify-content:center;width:100%;height:28px;margin-top:8px;border-radius:999px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.14);color:var(--pa-tx2);font-weight:500;font-size:12.5px;touch-action:none;user-select:none;-webkit-user-select:none;transition:background 160ms ease,color 160ms ease,transform 120ms var(--pa-ease)}
97
+ .pa-hold:hover{background:rgba(255,255,255,.06);color:var(--pa-tx)}
98
+ .pa-hold:active,.pa-hold.on{transform:scale(.98)}
99
+ .pa-hold.on{background:var(--pa-accent);color:var(--pa-ink);box-shadow:none}
100
+ .pa-before{position:fixed;display:flex;align-items:center;gap:7px;padding:5px 12px 5px 10px;border-radius:999px;background:rgba(19,20,24,.82);backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.09),0 10px 28px -12px rgba(0,0,0,.7);font-weight:600;color:var(--pa-tx);pointer-events:none}
101
+ .pa-before i{width:7px;height:7px;border-radius:50%;background:var(--pa-warm)}
102
+ .pa-edge{position:absolute;pointer-events:auto}
103
+ @media (prefers-reduced-transparency:reduce){.pa-bub,.pa-card,.pa-pill{background:#1c1d22;backdrop-filter:none;-webkit-backdrop-filter:none}.pa-tail{background:#1c1d22}}
104
+ @media (prefers-contrast:more){.pa-bub,.pa-card{box-shadow:inset 0 0 0 1px rgba(255,255,255,.5)}}
105
+ `
@@ -0,0 +1,15 @@
1
+ // The region marquee's look (spec 2.3, Region): the capture overlay's corner accents on a thin
2
+ // accent border with a faint fill, the live size pinned inside its bottom-right, drawn back to 60%
3
+ // once the person has let go. Uses the layer's `--pa-*` tokens; append it to POINT_ASK_CSS.
4
+ export const REGION_MARQUEE_CSS = `
5
+ .pa-rgn{position:fixed;left:0;top:0;pointer-events:none;border:1.5px solid var(--pa-accent);border-radius:3px;background:rgba(169,155,255,.07);transition:opacity 160ms ease;will-change:transform,width,height}
6
+ .pa-rgn.settled{opacity:.6}
7
+ .pa-rgn-c{position:absolute;width:14px;height:14px;border:0 solid var(--pa-accent);filter:drop-shadow(0 0 3px rgba(169,155,255,.5))}
8
+ .pa-rgn-c.tl{left:-3px;top:-3px;border-width:2.5px 0 0 2.5px;border-top-left-radius:5px}
9
+ .pa-rgn-c.tr{right:-3px;top:-3px;border-width:2.5px 2.5px 0 0;border-top-right-radius:5px}
10
+ .pa-rgn-c.bl{left:-3px;bottom:-3px;border-width:0 0 2.5px 2.5px;border-bottom-left-radius:5px}
11
+ .pa-rgn-c.br{right:-3px;bottom:-3px;border-width:0 2.5px 2.5px 0;border-bottom-right-radius:5px}
12
+ .pa-rgn-size{position:absolute;right:6px;bottom:6px;padding:2px 8px;border-radius:999px;background:rgba(18,19,23,.85);font-weight:600;font-size:11.5px;font-variant-numeric:tabular-nums;color:var(--pa-tx);white-space:nowrap}
13
+ .pa-rgn-size:empty{display:none}
14
+ .pa-rgn-flash{position:absolute;inset:0;background:#fff;opacity:0;pointer-events:none;border-radius:inherit}
15
+ `
@@ -0,0 +1,64 @@
1
+ 'use client'
2
+
3
+ import { prefersReducedMotion } from '@pascal-app/core'
4
+ import { useEffect, useRef } from 'react'
5
+ import { EASE_OUT, REGION_FLASH_MS } from '../../lib/point-ask/choreography'
6
+ import type { Rect } from '../../lib/point-ask/types'
7
+ import { usePointAsk } from '../../store/use-point-ask'
8
+
9
+ const CORNERS = ['tl', 'tr', 'bl', 'br'] as const
10
+
11
+ type RegionMarqueeProps = {
12
+ /** The rectangle in viewport pixels, or null when no region is being drawn. */
13
+ rect: Rect | null
14
+ /** The live size inside the rectangle ("4.2 × 3.1 m"), or null while it cannot be measured. */
15
+ sizeLabel: string | null
16
+ /** The person let go: the rectangle stays at 60% until the ask is sent or dismissed. */
17
+ settled: boolean
18
+ /** Changes when a capture happens: the shutter flash fills the rectangle once. */
19
+ flashKey: number
20
+ }
21
+
22
+ /**
23
+ * The rectangle of a region capture (the owner's "the marquee shows its live size in metres; on
24
+ * release a shutter flash fills the rectangle"). It follows the pointer 1:1, with no spring: a
25
+ * trailing rectangle would read as latency. The flash is a "Playful touches" moment; under
26
+ * reduced motion it is a quiet fade.
27
+ */
28
+ export function RegionMarquee({ rect, sizeLabel, settled, flashKey }: RegionMarqueeProps) {
29
+ const flash = useRef<HTMLDivElement>(null)
30
+ const seen = useRef(flashKey)
31
+ const playful = usePointAsk((s) => s.playful)
32
+
33
+ useEffect(() => {
34
+ if (flashKey === seen.current) return
35
+ seen.current = flashKey
36
+ const el = flash.current
37
+ if (!(el && playful)) return
38
+ const peak = prefersReducedMotion() ? 0.3 : 0.9
39
+ el.animate([{ opacity: peak }, { opacity: 0 }], {
40
+ duration: REGION_FLASH_MS,
41
+ easing: EASE_OUT,
42
+ fill: 'forwards',
43
+ })
44
+ }, [flashKey, playful])
45
+
46
+ if (!rect) return null
47
+ return (
48
+ <div
49
+ aria-hidden
50
+ className={`pa-rgn${settled ? ' settled' : ''}`}
51
+ style={{
52
+ transform: `translate3d(${rect.x0}px,${rect.y0}px,0)`,
53
+ width: rect.x1 - rect.x0,
54
+ height: rect.y1 - rect.y0,
55
+ }}
56
+ >
57
+ {CORNERS.map((corner) => (
58
+ <span className={`pa-rgn-c ${corner}`} key={corner} />
59
+ ))}
60
+ <div className="pa-rgn-flash" ref={flash} />
61
+ <div className="pa-rgn-size">{sizeLabel}</div>
62
+ </div>
63
+ )
64
+ }
@@ -10,7 +10,8 @@ import {
10
10
  StaticDrawUsage,
11
11
  Vector3,
12
12
  } from 'three'
13
- import Attributes from 'three/src/renderers/common/Attributes.js'
13
+ // @ts-expect-error -- @types/three 0.186 no longer declares this renderer internal
14
+ import UntypedAttributes from 'three/src/renderers/common/Attributes.js'
14
15
  import { AttributeType } from 'three/src/renderers/common/Constants.js'
15
16
  import Info from 'three/src/renderers/common/Info.js'
16
17
  import {
@@ -25,6 +26,15 @@ import {
25
26
  growBracketCapacity,
26
27
  } from './ceiling-bracket-batch'
27
28
 
29
+ type AttributesBackend = {
30
+ createAttribute(): void
31
+ updateAttribute(attribute: BufferAttribute): void
32
+ }
33
+ const Attributes = UntypedAttributes as new (
34
+ backend: AttributesBackend,
35
+ info: Info,
36
+ ) => { update(attribute: BufferAttribute, type: AttributeType): void }
37
+
28
38
  const polygon: Array<[number, number]> = [
29
39
  [0, 0],
30
40
  [4, 0],
@@ -365,7 +375,7 @@ test('WebGPU attribute updates skip resting frames and upload only written slots
365
375
  uploads.push({ attribute, ranges: attribute.updateRanges.map((range) => ({ ...range })) })
366
376
  attribute.clearUpdateRanges()
367
377
  },
368
- } as unknown as ConstructorParameters<typeof Attributes>[0],
378
+ },
369
379
  new Info(),
370
380
  )
371
381
  const render = () => {
@@ -511,6 +511,7 @@ function segmentShapeKey(segment: RoofSegmentNode): string {
511
511
  segment.wallThickness,
512
512
  segment.deckThickness,
513
513
  segment.overhang,
514
+ segment.gableOverhang,
514
515
  segment.shingleThickness,
515
516
  segment.gambrelLowerWidthRatio,
516
517
  segment.gambrelLowerHeightRatio,
@@ -0,0 +1,34 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { connectionCompatibility } from './connection-compatibility'
3
+
4
+ const supply = { system: 'supply', diameter: 6 }
5
+ test('matching profiles report a match', () => {
6
+ expect(connectionCompatibility(supply, { ...supply }).status).toBe('match')
7
+ })
8
+ test('system mismatch takes precedence over size', () => {
9
+ expect(connectionCompatibility(supply, { system: 'return', diameter: 8 }).status).toBe(
10
+ 'incompatible',
11
+ )
12
+ expect(
13
+ connectionCompatibility({ system: 'waste', diameter: 4 }, { system: 'vent', diameter: 4 })
14
+ .status,
15
+ ).toBe('incompatible')
16
+ })
17
+ test('size and shape mismatches describe the required adapter', () => {
18
+ expect(connectionCompatibility(supply, { ...supply, diameter: 8 }).label).toContain('Reducer')
19
+ expect(
20
+ connectionCompatibility(supply, { ...supply, shape: 'rect', width: 8, height: 6 }).label,
21
+ ).toContain('Transition')
22
+ expect(
23
+ connectionCompatibility(
24
+ { ...supply, shape: 'rect', width: 8, height: 6 },
25
+ { ...supply, shape: 'rect', width: 8, height: 8 },
26
+ ).status,
27
+ ).toBe('adapter')
28
+ })
29
+ test('missing system or section data cannot claim compatibility', () => {
30
+ expect(connectionCompatibility(supply, { diameter: 6 }).status).toBe('unknown')
31
+ expect(
32
+ connectionCompatibility({ ...supply, shape: 'rect' }, { ...supply, shape: 'rect' }).status,
33
+ ).toBe('unknown')
34
+ })
@@ -0,0 +1,43 @@
1
+ import type { NodePort } from '@pascal-app/core'
2
+
3
+ export type ConnectionProfile = Pick<NodePort, 'system' | 'diameter' | 'shape' | 'width' | 'height'>
4
+ export type ConnectionCompatibility = {
5
+ status: 'match' | 'adapter' | 'incompatible' | 'unknown'
6
+ label: string
7
+ }
8
+
9
+ export function connectionCompatibility(
10
+ source: ConnectionProfile,
11
+ target: ConnectionProfile,
12
+ ): ConnectionCompatibility {
13
+ if (source.system && target.system && source.system !== target.system) {
14
+ return {
15
+ status: 'incompatible',
16
+ label: `Different systems: ${source.system} / ${target.system}`,
17
+ }
18
+ }
19
+ const sourceShape = source.shape ?? 'round'
20
+ const targetShape = target.shape ?? 'round'
21
+ if (sourceShape !== targetShape) {
22
+ return { status: 'adapter', label: `Transition needed: ${sourceShape} / ${targetShape}` }
23
+ }
24
+ const sourceSize = sourceShape === 'round' ? [source.diameter] : [source.width, source.height]
25
+ const targetSize = targetShape === 'round' ? [target.diameter] : [target.width, target.height]
26
+ if (
27
+ [...sourceSize, ...targetSize].some(
28
+ (value) => value === undefined || !Number.isFinite(value) || value <= 0,
29
+ )
30
+ ) {
31
+ return { status: 'unknown', label: 'Connection size unavailable' }
32
+ }
33
+ if (sourceSize.some((value, index) => Math.abs(value! - targetSize[index]!) > 0.001)) {
34
+ return {
35
+ status: 'adapter',
36
+ label: `Reducer needed: ${sourceSize.join(' × ')}″ / ${targetSize.join(' × ')}″`,
37
+ }
38
+ }
39
+ if (!source.system || !target.system) {
40
+ return { status: 'unknown', label: 'Size matches; system unspecified' }
41
+ }
42
+ return { status: 'match', label: `Matching system and size: ${target.system}` }
43
+ }
@@ -0,0 +1,9 @@
1
+ export * from './connection-compatibility'
2
+ export * from './distribution-run-contract'
3
+ export * from './distribution-run-tool'
4
+ export * from './draw-alignment'
5
+ export * from './ports'
6
+ export * from './run-cursor'
7
+ export * from './run-direction-feedback'
8
+ export * from './run-port-snap'
9
+ export * from './run-screen-direction'
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { offsetRunPointFromSurface, type RunSurfaceTarget } from './distribution-run-contract'
3
+
4
+ const wall = (
5
+ hostId: `wall_${string}` = 'wall_1',
6
+ side: 'front' | 'back' = 'front',
7
+ ): RunSurfaceTarget => ({
8
+ kind: 'wall',
9
+ levelId: 'level_1',
10
+ hostId,
11
+ side,
12
+ frame: {
13
+ origin: [2, 1, 0],
14
+ normal: [0, 0, 1],
15
+ tangent: [1, 0, 0],
16
+ bitangent: [0, 1, 0],
17
+ },
18
+ bounds: { minU: 0, maxU: 4, minV: 0, maxV: 2.5 },
19
+ })
20
+
21
+ describe('distribution run interaction contract', () => {
22
+ test('offsets wall geometry along the face normal', () => {
23
+ const target = wall()
24
+ expect(offsetRunPointFromSurface([3, 1, 0], target, 0.1)).toEqual([3, 1, 0.1])
25
+ })
26
+ })
@@ -0,0 +1,94 @@
1
+ import type { AnyNodeId } from '@pascal-app/core'
2
+
3
+ export type RunPoint = [number, number, number]
4
+
5
+ export type RunWallSide = 'front' | 'back'
6
+
7
+ export type RunSurfaceFrame = {
8
+ origin: RunPoint
9
+ normal: RunPoint
10
+ tangent: RunPoint
11
+ bitangent: RunPoint
12
+ }
13
+
14
+ export type RunSurfaceBounds = {
15
+ minU: number
16
+ maxU: number
17
+ minV: number
18
+ maxV: number
19
+ }
20
+
21
+ export type RunWallAttachment = {
22
+ wallId: Extract<AnyNodeId, `wall_${string}`>
23
+ side: RunWallSide
24
+ startUV: [number, number]
25
+ endUV: [number, number]
26
+ offset: number
27
+ }
28
+
29
+ /**
30
+ * The surface selected for the current run. A wall target is semantic: the
31
+ * host id and side are required so later snapping cannot fall back to any
32
+ * other object that happens to be close in the viewport.
33
+ */
34
+ export type RunSurfaceTarget =
35
+ | {
36
+ kind: 'floor' | 'ceiling' | 'surface'
37
+ hostId?: AnyNodeId
38
+ levelId: AnyNodeId
39
+ frame: RunSurfaceFrame
40
+ }
41
+ | {
42
+ kind: 'wall'
43
+ levelId: AnyNodeId
44
+ hostId: AnyNodeId
45
+ side: RunWallSide
46
+ frame: RunSurfaceFrame
47
+ bounds: RunSurfaceBounds
48
+ }
49
+
50
+ /** Move a run centerline clear of a wall face along the captured normal. */
51
+ export function offsetRunPointFromSurface(
52
+ point: RunPoint,
53
+ target: RunSurfaceTarget | null,
54
+ offset: number,
55
+ ): RunPoint {
56
+ if (!target || offset === 0) return [...point]
57
+ return [
58
+ point[0] + target.frame.normal[0] * offset,
59
+ point[1] + target.frame.normal[1] * offset,
60
+ point[2] + target.frame.normal[2] * offset,
61
+ ]
62
+ }
63
+
64
+ export function runPointToSurfaceUV(
65
+ point: RunPoint,
66
+ target: Extract<RunSurfaceTarget, { kind: 'wall' }>,
67
+ ): [number, number] {
68
+ const dx = point[0] - target.frame.origin[0]
69
+ const dy = point[1] - target.frame.origin[1]
70
+ const dz = point[2] - target.frame.origin[2]
71
+ return [
72
+ dx * target.frame.tangent[0] + dy * target.frame.tangent[1] + dz * target.frame.tangent[2],
73
+ dx * target.frame.bitangent[0] +
74
+ dy * target.frame.bitangent[1] +
75
+ dz * target.frame.bitangent[2],
76
+ ]
77
+ }
78
+
79
+ export function createRunWallAttachment(
80
+ wallId: Extract<AnyNodeId, `wall_${string}`>,
81
+ side: RunWallSide,
82
+ start: RunPoint,
83
+ end: RunPoint,
84
+ target: Extract<RunSurfaceTarget, { kind: 'wall' }>,
85
+ offset: number,
86
+ ): RunWallAttachment {
87
+ return {
88
+ wallId,
89
+ side,
90
+ startUV: runPointToSurfaceUV(start, target),
91
+ endUV: runPointToSurfaceUV(end, target),
92
+ offset,
93
+ }
94
+ }