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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,9 +1,10 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import { type AnyNode, CeilingNode, LevelNode, SlabNode } from '@pascal-app/core'
3
3
  import useElevationGuides from '../store/use-elevation-guides'
4
4
  import {
5
5
  clearStructuralElevationGuide,
6
6
  collectElevationSnapTargets,
7
+ publishResolvedElevationGuide,
7
8
  publishStructuralElevationGuide,
8
9
  resolveElevationSnapMatch,
9
10
  resolveStructuralElevationSnap,
@@ -42,6 +43,10 @@ function structuralScene() {
42
43
  }
43
44
 
44
45
  describe('elevation guides', () => {
46
+ afterEach(() => {
47
+ useElevationGuides.setState({ guide: null })
48
+ })
49
+
45
50
  test('collects the level, slab faces, and ceiling plane on the source level', () => {
46
51
  const { level, nodes, slab } = structuralScene()
47
52
  const targets = collectElevationSnapTargets(
@@ -104,4 +109,26 @@ describe('elevation guides', () => {
104
109
  publishStructuralElevationGuide(source, 0.7, nodes)
105
110
  expect(useElevationGuides.getState().guide).toBeNull()
106
111
  })
112
+
113
+ test('publishes an explicitly resolved neighboring datum', () => {
114
+ const { level } = structuralScene()
115
+ useElevationGuides.setState({ guide: null })
116
+
117
+ publishResolvedElevationGuide(
118
+ { nodeId: 'leanto_moving', levelId: level.id, anchor: [2, 1] },
119
+ {
120
+ id: 'leanto_neighbor:high-edge',
121
+ elevation: 3.4,
122
+ anchor: [5, 1],
123
+ label: 'Neighbor shed edge',
124
+ },
125
+ )
126
+
127
+ expect(useElevationGuides.getState().guide).toMatchObject({
128
+ ownerId: 'leanto_moving',
129
+ elevation: 3.4,
130
+ direction: [1, 0],
131
+ label: 'Neighbor shed edge',
132
+ })
133
+ })
107
134
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ type FenceNode,
4
5
  findLevelAncestorId,
5
6
  getWallBaseElevationForNodes,
6
7
  getWallEffectiveHeightForNodes,
@@ -53,8 +54,10 @@ function segmentCenter(
53
54
  // A stale host resolves to the level base — the sculpted ground under the
54
55
  // fence's start point, sampled where the builder samples it, so the guide line
55
56
  // lands on the rail it claims to describe.
56
- function fenceBaseElevation(node: AnyNode, nodes: Record<string, AnyNode>): number {
57
- if (node.type !== 'fence') return 0
57
+ export function getFenceBaseElevationForNodes(
58
+ node: FenceNode,
59
+ nodes: Record<string, AnyNode>,
60
+ ): number {
58
61
  const host = node.supportSlabId ? nodes[node.supportSlabId as AnyNodeId] : undefined
59
62
  const hosted = host?.type === 'slab' && (host.parentId ?? null) === (node.parentId ?? null)
60
63
  const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes)
@@ -161,7 +164,7 @@ export function collectElevationSnapTargets(
161
164
  }
162
165
 
163
166
  if (node.type === 'fence') {
164
- const base = fenceBaseElevation(node, nodes)
167
+ const base = getFenceBaseElevationForNodes(node, nodes)
165
168
  const center = segmentCenter(node.start, node.end)
166
169
  targets.push({
167
170
  id: `${node.id}:base`,
@@ -245,8 +248,20 @@ export function publishStructuralElevationGuide(
245
248
  return
246
249
  }
247
250
 
248
- const dx = match.target.anchor[0] - source.anchor[0]
249
- const dz = match.target.anchor[1] - source.anchor[1]
251
+ publishResolvedElevationGuide(source, match.target)
252
+ }
253
+
254
+ export function publishResolvedElevationGuide(
255
+ source: ElevationGuideSource,
256
+ target: ElevationSnapTarget,
257
+ ): void {
258
+ if (!source.levelId) {
259
+ clearStructuralElevationGuide(source.nodeId)
260
+ return
261
+ }
262
+
263
+ const dx = target.anchor[0] - source.anchor[0]
264
+ const dz = target.anchor[1] - source.anchor[1]
250
265
  const length = Math.hypot(dx, dz)
251
266
  const direction: [number, number] = length > 1e-6 ? [dx / length, dz / length] : [1, 0]
252
267
 
@@ -255,8 +270,8 @@ export function publishStructuralElevationGuide(
255
270
  levelId: source.levelId,
256
271
  center: source.anchor,
257
272
  direction,
258
- elevation: match.elevation,
259
- label: match.target.label,
273
+ elevation: target.elevation,
274
+ label: target.label,
260
275
  })
261
276
  }
262
277
 
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { OrthographicCamera, PerspectiveCamera, Vector3 } from 'three'
3
+ import { getFloatingMenuScale } from './floating-menu-scale'
4
+
5
+ describe('getFloatingMenuScale', () => {
6
+ test('uses the standard orthographic zoom scale and clamps its range', () => {
7
+ const camera = new OrthographicCamera()
8
+ const anchor = new Vector3()
9
+
10
+ camera.zoom = 15
11
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
12
+ camera.zoom = 2
13
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
14
+ camera.zoom = 40
15
+ expect(getFloatingMenuScale(camera, anchor)).toBe(1)
16
+ })
17
+
18
+ test('uses inverse perspective-camera distance and clamps its range', () => {
19
+ const camera = new PerspectiveCamera()
20
+ const anchor = new Vector3()
21
+
22
+ camera.position.set(0, 0, 16)
23
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.75)
24
+ camera.position.set(0, 0, 48)
25
+ expect(getFloatingMenuScale(camera, anchor)).toBe(0.5)
26
+ camera.position.set(0, 0, 6)
27
+ expect(getFloatingMenuScale(camera, anchor)).toBe(1)
28
+ })
29
+ })
@@ -0,0 +1,14 @@
1
+ import { type Camera, OrthographicCamera, type Vector3 } from 'three'
2
+
3
+ const MIN_MENU_SCALE = 0.5
4
+ const MAX_MENU_SCALE = 1
5
+ const REF_ORTHO_ZOOM = 20
6
+ const REF_CAMERA_DISTANCE = 12
7
+
8
+ export function getFloatingMenuScale(camera: Camera, anchor: Vector3): number {
9
+ const raw =
10
+ camera instanceof OrthographicCamera
11
+ ? camera.zoom / REF_ORTHO_ZOOM
12
+ : REF_CAMERA_DISTANCE / Math.max(camera.position.distanceTo(anchor), 0.001)
13
+ return Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
14
+ }
@@ -1,8 +1,23 @@
1
- import { afterEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { LevelNode, useScene, WallNode } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
2
4
  import useAlignmentGuides from '../../store/use-alignment-guides'
3
- import { applyFloorplanAlignment } from './apply-alignment'
5
+ import { alignFloorplanDraftPoint, applyFloorplanAlignment } from './apply-alignment'
4
6
 
5
7
  describe('applyFloorplanAlignment', () => {
8
+ beforeEach(() => {
9
+ // These tests assume no active building (alignment runs on world axes).
10
+ // The scene/viewer stores are process-wide singletons, so an earlier test
11
+ // FILE can leak a selected building fixture into them — under bun's
12
+ // platform-dependent file order that turned into an order-dependent
13
+ // failure (getActiveBuildingPose reading a fixture building without a
14
+ // rotation array). Pin the empty-scene context explicitly.
15
+ useScene.setState({ nodes: {} } as never)
16
+ useViewer.setState({
17
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
18
+ } as never)
19
+ })
20
+
6
21
  afterEach(() => {
7
22
  useAlignmentGuides.getState().clear()
8
23
  })
@@ -22,4 +37,34 @@ describe('applyFloorplanAlignment', () => {
22
37
  expect(result.guides).toHaveLength(1)
23
38
  expect(useAlignmentGuides.getState().guides).toHaveLength(1)
24
39
  })
40
+
41
+ test('can restrict candidates to the active level', () => {
42
+ const lowerLevel = LevelNode.parse({ id: 'level_lower', level: 0 })
43
+ const upperLevel = LevelNode.parse({ id: 'level_upper', level: 1 })
44
+ const lowerWall = WallNode.parse({
45
+ id: 'wall_lower',
46
+ parentId: lowerLevel.id,
47
+ start: [10, 10],
48
+ end: [14, 10],
49
+ })
50
+ const upperWall = WallNode.parse({
51
+ id: 'wall_upper',
52
+ parentId: upperLevel.id,
53
+ start: [0, 0],
54
+ end: [4, 0],
55
+ })
56
+ useScene.setState({
57
+ nodes: Object.fromEntries(
58
+ [lowerLevel, upperLevel, lowerWall, upperWall].map((node) => [node.id, node]),
59
+ ),
60
+ } as never)
61
+
62
+ expect(
63
+ alignFloorplanDraftPoint([0.04, 0], {
64
+ applySnap: true,
65
+ levelId: lowerLevel.id,
66
+ }),
67
+ ).toEqual([0.04, 0])
68
+ expect(useAlignmentGuides.getState().guides).toHaveLength(0)
69
+ })
25
70
  })
@@ -2,6 +2,7 @@ import {
2
2
  type AlignmentAnchor,
3
3
  type AlignmentGuide,
4
4
  collectAlignmentAnchors,
5
+ resolveLevelId,
5
6
  useScene,
6
7
  } from '@pascal-app/core'
7
8
  import useAlignmentGuides from '../../store/use-alignment-guides'
@@ -96,13 +97,21 @@ export function alignFloorplanDraftPoint(
96
97
  bypass?: boolean
97
98
  threshold?: number
98
99
  excludeIds?: readonly string[]
100
+ levelId?: string | null
99
101
  },
100
102
  ): [number, number] {
101
103
  if (opts?.bypass) {
102
104
  useAlignmentGuides.getState().clear()
103
105
  return [point[0], point[1]]
104
106
  }
105
- let candidates = collectAlignmentAnchors(useScene.getState().nodes, FLOORPLAN_DRAFT_ALIGN_ID)
107
+ const nodes = useScene.getState().nodes
108
+ let candidates = collectAlignmentAnchors(nodes, FLOORPLAN_DRAFT_ALIGN_ID)
109
+ if (opts && 'levelId' in opts) {
110
+ candidates = candidates.filter((anchor) => {
111
+ const candidate = nodes[anchor.nodeId as keyof typeof nodes]
112
+ return candidate ? resolveLevelId(candidate, nodes) === opts.levelId : false
113
+ })
114
+ }
106
115
  if (opts?.excludeIds?.length) {
107
116
  const excluded = new Set(opts.excludeIds)
108
117
  candidates = candidates.filter((anchor) => !excluded.has(anchor.nodeId))
@@ -1,11 +1,24 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import type { FloorplanGeometry } from '@pascal-app/core'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type FloorplanGeometry,
6
+ type GeometryContext,
7
+ loadPlugin,
8
+ type NodeCategory,
9
+ nodeRegistry,
10
+ registerNode,
11
+ } from '@pascal-app/core'
12
+ import PDFDocument from 'pdfkit'
13
+ import { z } from 'zod'
3
14
  import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
4
15
  import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
5
16
  import {
17
+ collectFloorplanGeometry,
6
18
  filterFloorplanExportOverlay,
7
19
  fitPlanToBox,
8
20
  isFloorplanExportAnnotationGeometry,
21
+ isFloorplanNodeInExportScope,
9
22
  partitionFloorplanExportOverlay,
10
23
  resolveFloorplanExportAnnotationVisibility,
11
24
  resolveFloorplanExportNodeGeometry,
@@ -19,6 +32,63 @@ import {
19
32
  rotateFloorplanExportBounds,
20
33
  } from './floorplan-export'
21
34
  import { floorplanGeometryMetadata } from './floorplan-extension'
35
+ import { FloorplanPdfDocument } from './floorplan-pdfkit-document'
36
+ import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
37
+
38
+ type GroupGeometry = Extract<FloorplanGeometry, { kind: 'group' }>
39
+ type GroupTransform = NonNullable<GroupGeometry['transform']>
40
+
41
+ function flattenGeometry(geometry: FloorplanGeometry | null): FloorplanGeometry[] {
42
+ if (!geometry) return []
43
+ if (geometry.kind !== 'group') return [geometry]
44
+ return [geometry, ...geometry.children.flatMap(flattenGeometry)]
45
+ }
46
+
47
+ function applyGeometryTransforms(
48
+ point: readonly [number, number],
49
+ transforms: readonly GroupTransform[],
50
+ ): [number, number] {
51
+ let x = point[0]
52
+ let y = point[1]
53
+ for (let index = transforms.length - 1; index >= 0; index -= 1) {
54
+ const transform = transforms[index]!
55
+ if (transform.rotate !== undefined) {
56
+ const cos = Math.cos(transform.rotate)
57
+ const sin = Math.sin(transform.rotate)
58
+ const rotatedX = x * cos - y * sin
59
+ y = x * sin + y * cos
60
+ x = rotatedX
61
+ }
62
+ if (transform.translate) {
63
+ x += transform.translate[0]
64
+ y += transform.translate[1]
65
+ }
66
+ }
67
+ return [x, y]
68
+ }
69
+
70
+ function projectedGeometryPoint(
71
+ geometry: FloorplanGeometry,
72
+ target: 'circle' | 'image',
73
+ transforms: readonly GroupTransform[] = [],
74
+ ): [number, number] | null {
75
+ if (geometry.kind === target) {
76
+ const point =
77
+ geometry.kind === 'circle'
78
+ ? ([geometry.cx, geometry.cy] as const)
79
+ : geometry.kind === 'image'
80
+ ? geometry.center
81
+ : null
82
+ return point ? applyGeometryTransforms(point, transforms) : null
83
+ }
84
+ if (geometry.kind !== 'group') return null
85
+ const nestedTransforms = geometry.transform ? [...transforms, geometry.transform] : transforms
86
+ for (const child of geometry.children) {
87
+ const point = projectedGeometryPoint(child, target, nestedTransforms)
88
+ if (point) return point
89
+ }
90
+ return null
91
+ }
22
92
 
23
93
  describe('filterFloorplanExportOverlay', () => {
24
94
  test('preserves annotation metadata while splitting geometry passes', () => {
@@ -317,3 +387,329 @@ describe('resolveFloorplanPageLayout', () => {
317
387
  })
318
388
  })
319
389
  })
390
+
391
+ describe('isFloorplanNodeInExportScope', () => {
392
+ const definition = (category?: NodeCategory) => ({ category })
393
+
394
+ test('includes structure-category nodes under structure and full', () => {
395
+ expect(isFloorplanNodeInExportScope(definition('structure'), 'structure')).toBe(true)
396
+ expect(isFloorplanNodeInExportScope(definition('structure'), 'full')).toBe(true)
397
+ })
398
+
399
+ test('excludes utility-category nodes under structure', () => {
400
+ expect(isFloorplanNodeInExportScope(definition('utility'), 'full')).toBe(true)
401
+ expect(isFloorplanNodeInExportScope(definition('utility'), 'structure')).toBe(false)
402
+ })
403
+
404
+ test('includes furnish-category nodes only under full', () => {
405
+ expect(isFloorplanNodeInExportScope(definition('furnish'), 'full')).toBe(true)
406
+ expect(isFloorplanNodeInExportScope(definition('furnish'), 'structure')).toBe(false)
407
+ })
408
+
409
+ test('includes analysis and site-category nodes only under full', () => {
410
+ for (const category of ['analysis', 'site'] as const) {
411
+ expect(isFloorplanNodeInExportScope(definition(category), 'full')).toBe(true)
412
+ expect(isFloorplanNodeInExportScope(definition(category), 'structure')).toBe(false)
413
+ }
414
+ })
415
+
416
+ test('excludes nodes with no category except under full', () => {
417
+ expect(isFloorplanNodeInExportScope(definition(undefined), 'full')).toBe(true)
418
+ expect(isFloorplanNodeInExportScope(definition(undefined), 'structure')).toBe(false)
419
+ })
420
+
421
+ test('handles an undefined definition like a no-category node', () => {
422
+ expect(isFloorplanNodeInExportScope(undefined, 'full')).toBe(true)
423
+ expect(isFloorplanNodeInExportScope(undefined, 'structure')).toBe(false)
424
+ })
425
+ })
426
+
427
+ describe('collectFloorplanGeometry', () => {
428
+ test('collects the active Site once below architecture with semantic context and projection', async () => {
429
+ const restoreRegistry = nodeRegistry._snapshot()
430
+ const activeSiteId = 'site_active'
431
+ const otherSiteId = 'site_other'
432
+ const enabledPluginId = 'test:site-pdf-enabled'
433
+ const disabledPluginId = 'test:site-pdf-disabled'
434
+ const enabledKind = 'test:site-pdf-overlay'
435
+ const disabledKind = 'test:site-pdf-disabled-overlay'
436
+ const architectureKind = 'test:level-architecture'
437
+ const semanticChildId = 'site_overlay_detail'
438
+ const referencedNodeId = 'site_reference'
439
+ const inlinePng =
440
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
441
+
442
+ const siteDefinition = (
443
+ kind: string,
444
+ floorplan: (node: Record<string, unknown>, context: GeometryContext) => FloorplanGeometry,
445
+ ): AnyNodeDefinition =>
446
+ ({
447
+ kind,
448
+ schemaVersion: 1,
449
+ schema: z.object({ type: z.literal(kind) }) as never,
450
+ category: 'utility',
451
+ defaults: () => ({}) as never,
452
+ capabilities: {},
453
+ floorplanScope: 'site',
454
+ floorplan,
455
+ }) as AnyNodeDefinition
456
+
457
+ const enabledDefinition = siteDefinition(enabledKind, (siteOverlay, context) => ({
458
+ kind: 'group',
459
+ children: [
460
+ {
461
+ kind: 'rect',
462
+ x: 10,
463
+ y: 3,
464
+ width: context.children[0]?.id === semanticChildId ? 3 : -3,
465
+ height: context.siblings.some(({ id }) => id === 'site_overlay_hidden') ? 4 : -4,
466
+ fill:
467
+ siteOverlay.id === 'site_overlay' &&
468
+ context.parent?.id === activeSiteId &&
469
+ context.resolve(referencedNodeId)?.id === referencedNodeId
470
+ ? '#102030'
471
+ : '#ff0000',
472
+ },
473
+ {
474
+ kind: 'path',
475
+ d: 'M0,0H4V4H0ZM1,1H3V3H1Z',
476
+ fill: '#3f6b2f',
477
+ fillRule: 'evenodd',
478
+ },
479
+ {
480
+ kind: 'image',
481
+ url: inlinePng,
482
+ center: [10, 3],
483
+ width: 1,
484
+ height: 1,
485
+ },
486
+ { kind: 'circle', cx: 12, cy: 5, r: 0.5 },
487
+ ],
488
+ }))
489
+
490
+ try {
491
+ nodeRegistry._reset()
492
+ registerNode({
493
+ kind: architectureKind,
494
+ schemaVersion: 1,
495
+ schema: z.object({ type: z.literal(architectureKind) }) as never,
496
+ category: 'structure',
497
+ defaults: () => ({}) as never,
498
+ capabilities: {},
499
+ floorplan: () => ({
500
+ kind: 'polygon',
501
+ points: [
502
+ [0, 0],
503
+ [4, 0],
504
+ [4, 4],
505
+ ],
506
+ }),
507
+ } as AnyNodeDefinition)
508
+ await loadPlugin({
509
+ id: enabledPluginId,
510
+ apiVersion: 1,
511
+ nodes: [enabledDefinition],
512
+ })
513
+ await loadPlugin({
514
+ id: disabledPluginId,
515
+ apiVersion: 1,
516
+ nodes: [siteDefinition(disabledKind, () => ({ kind: 'circle', cx: 0, cy: 0, r: 1 }))],
517
+ })
518
+
519
+ const activeSite = {
520
+ id: activeSiteId,
521
+ type: 'site',
522
+ parentId: null,
523
+ visible: true,
524
+ children: [
525
+ 'building_active',
526
+ 'site_overlay',
527
+ 'site_overlay_hidden',
528
+ 'site_overlay_disabled',
529
+ ],
530
+ } as unknown as AnyNode
531
+ const activeBuilding = {
532
+ id: 'building_active',
533
+ type: 'building',
534
+ parentId: activeSiteId,
535
+ children: ['level_active', 'level_upper'],
536
+ position: [10, 0, 5],
537
+ rotation: [0, Math.PI / 2, 0],
538
+ } as unknown as AnyNode
539
+ const activeLevel = {
540
+ id: 'level_active',
541
+ type: 'level',
542
+ parentId: activeBuilding.id,
543
+ children: ['level_architecture'],
544
+ } as unknown as AnyNode
545
+ const upperLevel = {
546
+ id: 'level_upper',
547
+ type: 'level',
548
+ parentId: activeBuilding.id,
549
+ children: ['level_upper_architecture'],
550
+ } as unknown as AnyNode
551
+ const nodes = {
552
+ [activeSite.id]: activeSite,
553
+ [activeBuilding.id]: activeBuilding,
554
+ [activeLevel.id]: activeLevel,
555
+ [upperLevel.id]: upperLevel,
556
+ level_upper_architecture: {
557
+ id: 'level_upper_architecture',
558
+ type: architectureKind,
559
+ parentId: upperLevel.id,
560
+ visible: true,
561
+ } as unknown as AnyNode,
562
+ level_architecture: {
563
+ id: 'level_architecture',
564
+ type: architectureKind,
565
+ parentId: activeLevel.id,
566
+ visible: true,
567
+ } as unknown as AnyNode,
568
+ site_overlay: {
569
+ id: 'site_overlay',
570
+ type: enabledKind,
571
+ parentId: null,
572
+ children: [semanticChildId],
573
+ visible: true,
574
+ } as unknown as AnyNode,
575
+ [semanticChildId]: {
576
+ id: semanticChildId,
577
+ type: 'test:site-detail',
578
+ parentId: 'site_overlay',
579
+ } as unknown as AnyNode,
580
+ [referencedNodeId]: {
581
+ id: referencedNodeId,
582
+ type: 'test:site-reference',
583
+ parentId: activeSiteId,
584
+ } as unknown as AnyNode,
585
+ site_overlay_hidden: {
586
+ id: 'site_overlay_hidden',
587
+ type: enabledKind,
588
+ parentId: activeSiteId,
589
+ visible: false,
590
+ } as unknown as AnyNode,
591
+ site_overlay_disabled: {
592
+ id: 'site_overlay_disabled',
593
+ type: disabledKind,
594
+ parentId: activeSiteId,
595
+ visible: true,
596
+ } as unknown as AnyNode,
597
+ [otherSiteId]: {
598
+ id: otherSiteId,
599
+ type: 'site',
600
+ parentId: null,
601
+ children: ['site_overlay_other'],
602
+ } as unknown as AnyNode,
603
+ site_overlay_other: {
604
+ id: 'site_overlay_other',
605
+ type: enabledKind,
606
+ parentId: otherSiteId,
607
+ visible: true,
608
+ } as unknown as AnyNode,
609
+ }
610
+
611
+ const full = collectFloorplanGeometry(
612
+ nodes,
613
+ activeLevel.id,
614
+ 'full',
615
+ 'metric',
616
+ 'meters',
617
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
618
+ 'floor-plan',
619
+ 'finished-faces',
620
+ [enabledPluginId],
621
+ )
622
+
623
+ expect(full.map(({ id }) => id)).toEqual(['site_overlay', 'level_architecture'])
624
+ const siteModel = full[0]?.model
625
+ if (!siteModel) throw new Error('Expected Site geometry')
626
+ const siteParts = flattenGeometry(siteModel)
627
+ expect(siteParts).toEqual(
628
+ expect.arrayContaining([
629
+ expect.objectContaining({
630
+ kind: 'rect',
631
+ width: 3,
632
+ height: 4,
633
+ fill: '#102030',
634
+ }),
635
+ expect.objectContaining({ kind: 'path', fillRule: 'evenodd' }),
636
+ expect.objectContaining({ kind: 'image', url: inlinePng }),
637
+ ]),
638
+ )
639
+ expect(projectedGeometryPoint(siteModel, 'image')).toEqual([
640
+ expect.closeTo(2),
641
+ expect.closeTo(0),
642
+ ])
643
+ expect(projectedGeometryPoint(siteModel, 'circle')).toEqual([
644
+ expect.closeTo(0),
645
+ expect.closeTo(2),
646
+ ])
647
+
648
+ const rawPdf = new PDFDocument({ autoFirstPage: false, compress: false })
649
+ const chunks: Buffer[] = []
650
+ rawPdf.on('data', (chunk: Buffer) => chunks.push(chunk))
651
+ const completedPdf = Promise.withResolvers<string>()
652
+ rawPdf.on('end', () => completedPdf.resolve(Buffer.concat(chunks).toString('latin1')))
653
+ const pdf = new FloorplanPdfDocument(rawPdf, [200, 200])
654
+ pdf.addPage()
655
+ for (const { model } of full) {
656
+ if (!model) continue
657
+ await renderFloorplanGeometryToPdfKit(pdf, model, {
658
+ annotationLayer: false,
659
+ placement: { x: 20, y: 20, width: 100, height: 100 },
660
+ rotationDeg: 0,
661
+ viewport: { x: -4, y: -4, width: 20, height: 20 },
662
+ })
663
+ }
664
+ rawPdf.end()
665
+ const renderedPdf = await completedPdf.promise
666
+ expect(renderedPdf).toMatch(/f\*/)
667
+ expect(renderedPdf).toContain('/Subtype /Image')
668
+
669
+ const upper = collectFloorplanGeometry(
670
+ nodes,
671
+ upperLevel.id,
672
+ 'full',
673
+ 'metric',
674
+ 'meters',
675
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
676
+ 'floor-plan',
677
+ 'finished-faces',
678
+ [enabledPluginId],
679
+ )
680
+ expect(upper.map(({ id }) => id)).toEqual(['site_overlay', 'level_upper_architecture'])
681
+
682
+ const hiddenSiteNodes = {
683
+ ...nodes,
684
+ [activeSite.id]: { ...activeSite, visible: false } as AnyNode,
685
+ }
686
+ const hiddenSite = collectFloorplanGeometry(
687
+ hiddenSiteNodes,
688
+ activeLevel.id,
689
+ 'full',
690
+ 'metric',
691
+ 'meters',
692
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
693
+ 'floor-plan',
694
+ 'finished-faces',
695
+ [enabledPluginId],
696
+ )
697
+ expect(hiddenSite.map(({ id }) => id)).toEqual(['level_architecture'])
698
+
699
+ const structure = collectFloorplanGeometry(
700
+ nodes,
701
+ activeLevel.id,
702
+ 'structure',
703
+ 'metric',
704
+ 'meters',
705
+ DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
706
+ 'floor-plan',
707
+ 'finished-faces',
708
+ [enabledPluginId],
709
+ )
710
+ expect(structure.map(({ id }) => id)).toEqual(['level_architecture'])
711
+ } finally {
712
+ restoreRegistry()
713
+ }
714
+ })
715
+ })