@pascal-app/editor 1.0.7 → 1.1.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 (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. package/src/lib/window-interaction.ts +0 -142
@@ -38,7 +38,6 @@ test('roof trim drag keeps its plane hit over a surface joining and leaving a ba
38
38
  id: 'rseg_batch_drag',
39
39
  width: 10,
40
40
  depth: 10,
41
- metadata: { showTrimPlanes: true },
42
41
  })
43
42
  const source = new THREE.Group()
44
43
  const surface = new THREE.Mesh(new THREE.BoxGeometry(30, 0.25, 30), new THREE.MeshBasicMaterial())
@@ -52,6 +51,9 @@ test('roof trim drag keeps its plane hit over a surface joining and leaving a ba
52
51
  hoveredId: null,
53
52
  selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [segment.id] },
54
53
  })
54
+ useInteractionScope
55
+ .getState()
56
+ .begin({ kind: 'reshaping', reshape: 'trim', nodeId: segment.id, driver: 'tool' })
55
57
  await root.configure({
56
58
  gl: {
57
59
  domElement: canvas,
@@ -99,6 +101,18 @@ test('roof trim drag keeps its plane hit over a surface joining and leaving a ba
99
101
  events.dispatchEvent(new Event('pointercancel'))
100
102
  })
101
103
  expect(useLiveNodeOverrides.getState().overrides.has(segment.id)).toBe(false)
104
+ const fieldEscape = Object.assign(new Event('keydown'), { key: 'Escape' })
105
+ Object.defineProperty(fieldEscape, 'target', {
106
+ value: { closest: () => ({ tagName: 'INPUT' }) },
107
+ })
108
+ await act(async () => {
109
+ events.dispatchEvent(fieldEscape)
110
+ })
111
+ expect(useInteractionScope.getState().scope.kind).toBe('reshaping')
112
+ await act(async () => {
113
+ events.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Escape' }))
114
+ })
115
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
102
116
  } finally {
103
117
  await act(async () => {
104
118
  root.render(null)
@@ -34,6 +34,7 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
34
34
  import { EDITOR_LAYER } from '../../../lib/constants'
35
35
  import { isHistoryShortcut } from '../../../lib/history'
36
36
  import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy'
37
+ import { isTypingTarget } from '../../../lib/typing-target'
37
38
  import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope'
38
39
  import { swallowNextClick } from '../../editor/handles/use-handle-drag'
39
40
 
@@ -1006,15 +1007,6 @@ function getTrimCursor(side: RoofTrimSide): string {
1006
1007
  }
1007
1008
  }
1008
1009
 
1009
- function shouldShowTrimPlanes(metadata: unknown): boolean {
1010
- return (
1011
- typeof metadata === 'object' &&
1012
- metadata !== null &&
1013
- !Array.isArray(metadata) &&
1014
- (metadata as Record<string, unknown>).showTrimPlanes === true
1015
- )
1016
- }
1017
-
1018
1010
  function commitSegmentTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim) {
1019
1011
  const scene = useScene.getState()
1020
1012
  scene.applyNodeChanges({
@@ -1031,6 +1023,28 @@ function RoofTrimHandles() {
1031
1023
  return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null
1032
1024
  })
1033
1025
  const readOnly = useScene((s) => s.readOnly)
1026
+ const showTrimPlanes = useInteractionScope(
1027
+ (s) =>
1028
+ s.scope.kind === 'reshaping' && s.scope.reshape === 'trim' && s.scope.nodeId === selectedId,
1029
+ )
1030
+ useEffect(() => {
1031
+ const end = () =>
1032
+ useInteractionScope
1033
+ .getState()
1034
+ .endIf(
1035
+ (scope) =>
1036
+ scope.kind === 'reshaping' && scope.reshape === 'trim' && scope.nodeId === selectedId,
1037
+ )
1038
+ const onKey = (event: KeyboardEvent) => {
1039
+ if (isTypingTarget(event.target)) return
1040
+ if (event.key === 'Escape') end()
1041
+ }
1042
+ window.addEventListener('keydown', onKey)
1043
+ return () => {
1044
+ window.removeEventListener('keydown', onKey)
1045
+ end()
1046
+ }
1047
+ }, [selectedId])
1034
1048
  const liveOverrideKey = useLiveNodeOverrides((s) =>
1035
1049
  segment ? JSON.stringify(s.overrides.get(segment.id) ?? null) : null,
1036
1050
  )
@@ -1042,7 +1056,10 @@ function RoofTrimHandles() {
1042
1056
  const zoom = camera instanceof THREE.OrthographicCamera ? 1 / camera.zoom : 1
1043
1057
  const handleBaseScale = zoom * TRIM_HANDLE_BASE_SCALE
1044
1058
 
1045
- useEffect(() => () => dragCleanupRef.current?.(), [])
1059
+ useEffect(() => {
1060
+ if (!selectedId || !showTrimPlanes || readOnly) dragCleanupRef.current?.()
1061
+ return () => dragCleanupRef.current?.()
1062
+ }, [selectedId, showTrimPlanes, readOnly])
1046
1063
 
1047
1064
  const liveSegment = useMemo(() => {
1048
1065
  if (!segment) return null
@@ -1059,8 +1076,6 @@ function RoofTrimHandles() {
1059
1076
  groupRef.current.matrixAutoUpdate = false
1060
1077
  })
1061
1078
 
1062
- const showTrimPlanes = shouldShowTrimPlanes(liveSegment?.metadata)
1063
-
1064
1079
  if (readOnly || !segment || !liveSegment || !showTrimPlanes) return null
1065
1080
 
1066
1081
  const trim = normalizeRoofSegmentTrim(liveSegment)
@@ -1314,9 +1329,7 @@ function RoofTrimHandles() {
1314
1329
 
1315
1330
  document.body.style.cursor = getTrimCursor(side)
1316
1331
  setDraggingSide(side)
1317
- useInteractionScope
1318
- .getState()
1319
- .begin({ kind: 'handle-drag', nodeId: segmentId, handle: getTrimLabel(side) })
1332
+
1320
1333
  useViewer.getState().setInputDragging(true)
1321
1334
  useScene.temporal.getState().pause()
1322
1335
 
@@ -1355,9 +1368,7 @@ function RoofTrimHandles() {
1355
1368
  document.body.style.cursor = ''
1356
1369
  }
1357
1370
  useScene.temporal.getState().resume()
1358
- useInteractionScope
1359
- .getState()
1360
- .endIf((scope) => scope.kind === 'handle-drag' && scope.nodeId === segmentId)
1371
+
1361
1372
  useViewer.getState().setInputDragging(false)
1362
1373
  setDraggingSide(null)
1363
1374
  dragCleanupRef.current = null
@@ -1393,7 +1404,7 @@ function RoofTrimHandles() {
1393
1404
  onCancel()
1394
1405
  }
1395
1406
 
1396
- dragCleanupRef.current = cleanup
1407
+ dragCleanupRef.current = onCancel
1397
1408
  window.addEventListener('pointermove', onMove)
1398
1409
  window.addEventListener('pointerup', onUp)
1399
1410
  window.addEventListener('pointercancel', onCancel)
@@ -1,9 +1,19 @@
1
1
  import { expect, test } from 'bun:test'
2
- import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
3
- import { useEditor, useInteractionScope } from '@pascal-app/editor'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ emitter,
6
+ type GridEvent,
7
+ nodeRegistry,
8
+ registerNode,
9
+ useScene,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import { type EditorGridEvent, useEditor, useInteractionScope } from '@pascal-app/editor'
4
13
  import { useThree } from '@react-three/fiber'
5
14
  import { act, create } from '@react-three/test-renderer'
6
- import { useDistributionRunTool } from './distribution-run-tool'
15
+ import { type RunConnection, type RunPoint, useDistributionRunTool } from './distribution-run-tool'
16
+ import { findNearestRunBody3D, type ScenePort } from './ports'
7
17
 
8
18
  test.each(['duct-segment', 'pipe-segment'] as const)(
9
19
  '%s releases its wall plane after an outward commit and retains it along the wall',
@@ -123,3 +133,361 @@ test.each(['duct-segment', 'pipe-segment'] as const)(
123
133
  }
124
134
  },
125
135
  )
136
+
137
+ test.each(['duct-segment', 'pipe-segment'] as const)(
138
+ '%s routes at an exact elevation and updates a vertical target without pointer movement',
139
+ async (toolName) => {
140
+ const previousEditor = useEditor.getState()
141
+ const previousScope = useInteractionScope.getState()
142
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
143
+ const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
144
+ Object.defineProperty(globalThis, 'window', {
145
+ value: new EventTarget(),
146
+ configurable: true,
147
+ writable: true,
148
+ })
149
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
150
+ value: true,
151
+ configurable: true,
152
+ writable: true,
153
+ })
154
+ let tool: ReturnType<typeof useDistributionRunTool> | undefined
155
+ const commits: Array<[number, number, number]> = []
156
+ let routingMode: 'horizontal' | 'vertical' = 'horizontal'
157
+ let targetElevation = 4
158
+ function Host() {
159
+ const { gl } = useThree()
160
+ gl.domElement.getBoundingClientRect = () =>
161
+ ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect
162
+ tool = useDistributionRunTool({
163
+ active: true,
164
+ levelId: 'level_test',
165
+ toolName,
166
+ routingMode,
167
+ elevation: 2,
168
+ targetElevation,
169
+ getPorts: () => [],
170
+ findBody: () => null,
171
+ commit: ({ end }) => {
172
+ commits.push(end)
173
+ return { nextStart: end, nextConnection: { port: null, body: null } }
174
+ },
175
+ })
176
+ return null
177
+ }
178
+ let renderer: Awaited<ReturnType<typeof create>> | undefined
179
+ const event = (x: number): GridEvent => ({
180
+ position: [x, 0, 0],
181
+ localPosition: [x, 0, 0],
182
+ nativeEvent: {} as GridEvent['nativeEvent'],
183
+ })
184
+ try {
185
+ useEditor.setState({ snappingModeByContext: { wall: 'off', polygon: 'off', item: 'off' } })
186
+ useEditor.getState().showView('2d')
187
+ renderer = await create(<Host />)
188
+ await act(async () => {
189
+ emitter.emit('grid:click', event(1))
190
+ })
191
+ expect(tool?.start).toEqual([1, 2, 0])
192
+ await act(async () => {
193
+ emitter.emit('grid:click', event(3))
194
+ })
195
+ expect(commits).toEqual([[3, 2, 0]])
196
+ await act(async () => {
197
+ const planEvent: EditorGridEvent = {
198
+ ...event(7),
199
+ screenProjection: { pointer: [120, 180], localToScreen: [1, 0, 0, 1, 0, 0] },
200
+ }
201
+ emitter.emit('grid:move', planEvent)
202
+ })
203
+ expect(tool?.cursorScreen).toEqual({ x: 120, y: 180, depth: 0 })
204
+ await act(async () => useEditor.getState().showView('3d'))
205
+ expect(tool?.cursorScreen).toBeNull()
206
+ routingMode = 'vertical'
207
+ await renderer.update(<Host />)
208
+ expect(tool?.cursor).toEqual([3, 4, 0])
209
+ targetElevation = 5
210
+ await renderer.update(<Host />)
211
+ expect(tool?.cursor).toEqual([3, 5, 0])
212
+ await act(async () => {
213
+ emitter.emit('grid:click', event(7))
214
+ })
215
+ expect(commits.at(-1)).toEqual([3, 5, 0])
216
+ } finally {
217
+ await renderer?.unmount()
218
+ useEditor.setState(previousEditor)
219
+ useInteractionScope.setState(previousScope)
220
+ if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
221
+ else Reflect.deleteProperty(globalThis, 'window')
222
+ if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct)
223
+ else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
224
+ }
225
+ },
226
+ )
227
+
228
+ test.each(['duct-segment', 'pipe-segment'] as const)(
229
+ '%s previews the vertical riser as soon as its start is placed',
230
+ async (toolName) => {
231
+ const previousEditor = useEditor.getState()
232
+ const previousScope = useInteractionScope.getState()
233
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
234
+ const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
235
+ const keyTarget = Object.assign(new EventTarget(), { closest: () => null })
236
+ Object.defineProperty(globalThis, 'window', {
237
+ value: keyTarget,
238
+ configurable: true,
239
+ writable: true,
240
+ })
241
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
242
+ value: true,
243
+ configurable: true,
244
+ writable: true,
245
+ })
246
+ let tool: ReturnType<typeof useDistributionRunTool> | undefined
247
+ const commits: Array<[number, number, number]> = []
248
+ function Host() {
249
+ const { gl } = useThree()
250
+ gl.domElement.getBoundingClientRect = () =>
251
+ ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect
252
+ tool = useDistributionRunTool({
253
+ active: true,
254
+ levelId: 'level_test',
255
+ toolName,
256
+ routingMode: 'vertical',
257
+ elevation: 2,
258
+ targetElevation: 4,
259
+ getPorts: () => [],
260
+ findBody: () => null,
261
+ commit: ({ end }) => {
262
+ commits.push(end)
263
+ return { nextStart: end, nextConnection: { port: null, body: null } }
264
+ },
265
+ })
266
+ return null
267
+ }
268
+ let renderer: Awaited<ReturnType<typeof create>> | undefined
269
+ const event = (x: number): GridEvent => ({
270
+ position: [x, 0, 0],
271
+ localPosition: [x, 0, 0],
272
+ nativeEvent: {} as GridEvent['nativeEvent'],
273
+ })
274
+ try {
275
+ useEditor.setState({ snappingModeByContext: { wall: 'off', polygon: 'off', item: 'off' } })
276
+ renderer = await create(<Host />)
277
+ await act(async () => {
278
+ emitter.emit('grid:click', event(1))
279
+ })
280
+ expect(tool?.start).toEqual([1, 2, 0])
281
+ expect(tool?.cursor).toEqual([1, 4, 0])
282
+
283
+ await act(async () => {
284
+ keyTarget.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Enter' }))
285
+ })
286
+ expect(commits).toEqual([[1, 4, 0]])
287
+ expect(tool?.start).toEqual([1, 4, 0])
288
+ } finally {
289
+ await renderer?.unmount()
290
+ useEditor.setState(previousEditor)
291
+ useInteractionScope.setState(previousScope)
292
+ if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
293
+ else Reflect.deleteProperty(globalThis, 'window')
294
+ if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct)
295
+ else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
296
+ }
297
+ },
298
+ )
299
+
300
+ test.each(['duct-segment', 'pipe-segment'] as const)(
301
+ '%s drawn from a collar rises to each panel target without a pointer event',
302
+ async (toolName) => {
303
+ const previousEditor = useEditor.getState()
304
+ const previousScope = useInteractionScope.getState()
305
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
306
+ const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
307
+ const keyTarget = Object.assign(new EventTarget(), { closest: () => null })
308
+ Object.defineProperty(globalThis, 'window', {
309
+ value: keyTarget,
310
+ configurable: true,
311
+ writable: true,
312
+ })
313
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
314
+ value: true,
315
+ configurable: true,
316
+ writable: true,
317
+ })
318
+ let tool: ReturnType<typeof useDistributionRunTool> | undefined
319
+ const commits: Array<[number, number, number]> = []
320
+ let targetElevation = 4
321
+ function Host() {
322
+ const { gl } = useThree()
323
+ gl.domElement.getBoundingClientRect = () =>
324
+ ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect
325
+ tool = useDistributionRunTool({
326
+ active: true,
327
+ levelId: 'level_test',
328
+ toolName,
329
+ routingMode: 'vertical',
330
+ elevation: 2,
331
+ targetElevation,
332
+ initialStart: [1, 2, 0],
333
+ getPorts: () => [],
334
+ findBody: () => null,
335
+ commit: ({ end }) => {
336
+ commits.push(end)
337
+ return { nextStart: end, nextConnection: { port: null, body: null } }
338
+ },
339
+ })
340
+ return null
341
+ }
342
+ const pressEnter = () =>
343
+ act(async () => {
344
+ keyTarget.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Enter' }))
345
+ })
346
+ let renderer: Awaited<ReturnType<typeof create>> | undefined
347
+ try {
348
+ useEditor.setState({ snappingModeByContext: { wall: 'off', polygon: 'off', item: 'off' } })
349
+ renderer = await create(<Host />)
350
+ expect(tool?.cursor).toEqual([1, 4, 0])
351
+ targetElevation = 5
352
+ await renderer.update(<Host />)
353
+ expect(tool?.cursor).toEqual([1, 5, 0])
354
+ await pressEnter()
355
+ expect(commits).toEqual([[1, 5, 0]])
356
+
357
+ targetElevation = 3
358
+ await renderer.update(<Host />)
359
+ expect(tool?.cursor).toEqual([1, 3, 0])
360
+ await pressEnter()
361
+ expect(commits).toEqual([
362
+ [1, 5, 0],
363
+ [1, 3, 0],
364
+ ])
365
+ } finally {
366
+ await renderer?.unmount()
367
+ useEditor.setState(previousEditor)
368
+ useInteractionScope.setState(previousScope)
369
+ if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
370
+ else Reflect.deleteProperty(globalThis, 'window')
371
+ if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct)
372
+ else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
373
+ }
374
+ },
375
+ )
376
+
377
+ test('a sloped horizontal route connects at the height its fall reaches', async () => {
378
+ const previousScene = useScene.getState()
379
+ const previousEditor = useEditor.getState()
380
+ const previousScope = useInteractionScope.getState()
381
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
382
+ const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
383
+ const restoreRegistry = nodeRegistry._snapshot()
384
+ Object.defineProperty(globalThis, 'window', {
385
+ value: new EventTarget(),
386
+ configurable: true,
387
+ writable: true,
388
+ })
389
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
390
+ value: true,
391
+ configurable: true,
392
+ writable: true,
393
+ })
394
+ const toolName = 'test:sloped-run'
395
+ registerNode({
396
+ kind: toolName,
397
+ schemaVersion: 1,
398
+ category: 'structure',
399
+ defaults: () => ({}),
400
+ snapProfile: 'structural',
401
+ } as never)
402
+ const collar = (nodeId: string, position: [number, number, number]): ScenePort => ({
403
+ nodeId: nodeId as AnyNodeId,
404
+ id: 'outlet',
405
+ position,
406
+ direction: [-1, 0, 0],
407
+ diameter: 2,
408
+ })
409
+ const run = (id: string, x: number, y: number) =>
410
+ ({
411
+ object: 'node',
412
+ id,
413
+ type: 'pipe-segment',
414
+ parentId: 'level_test',
415
+ path: [
416
+ [x, y, -2],
417
+ [x, y, 2],
418
+ ],
419
+ }) as unknown as AnyNode
420
+ // A 2% fall from [0, 2, 0] reaches 1.9 m at x = 5 and 1.84 m at x = 8.
421
+ const ports = [collar('item_start_height', [5, 2, 0.05]), collar('item_graded', [5, 1.9, 0])]
422
+ let tool: ReturnType<typeof useDistributionRunTool> | undefined
423
+ const commits: Array<{ end: RunPoint; endConnection: RunConnection }> = []
424
+ function Host() {
425
+ const { gl } = useThree()
426
+ gl.domElement.getBoundingClientRect = () =>
427
+ ({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect
428
+ tool = useDistributionRunTool({
429
+ active: true,
430
+ levelId: 'level_test',
431
+ toolName,
432
+ routingMode: 'horizontal',
433
+ elevation: 2,
434
+ getPorts: () => ports,
435
+ findBody: (point) => findNearestRunBody3D(point, 0.3, { kinds: ['pipe-segment'] }),
436
+ resolveFreeEnd: (start, end) => [
437
+ end[0],
438
+ start[1] - Math.hypot(end[0] - start[0], end[2] - start[2]) * 0.02,
439
+ end[2],
440
+ ],
441
+ commit: ({ end, endConnection }) => {
442
+ commits.push({ end, endConnection })
443
+ return { nextStart: end, nextConnection: endConnection }
444
+ },
445
+ })
446
+ return null
447
+ }
448
+ let renderer: Awaited<ReturnType<typeof create>> | undefined
449
+ const planEvent = (x: number, z: number): EditorGridEvent => ({
450
+ position: [x, 0, z],
451
+ localPosition: [x, 0, z],
452
+ screenProjection: { pointer: [x * 100, z * 100], localToScreen: [100, 0, 0, 100, 0, 0] },
453
+ nativeEvent: {} as GridEvent['nativeEvent'],
454
+ })
455
+ try {
456
+ useScene.setState({
457
+ nodes: Object.fromEntries(
458
+ [run('pipe_start_height', 8.05, 1.9), run('pipe_graded', 8, 1.84)].map((node) => [
459
+ node.id,
460
+ node,
461
+ ]),
462
+ ),
463
+ })
464
+ useEditor.setState({
465
+ snappingModeByContext: { ...previousEditor.snappingModeByContext, wall: 'lines' },
466
+ })
467
+ renderer = await create(<Host />)
468
+ await act(async () => {
469
+ emitter.emit('grid:click', planEvent(0, 0))
470
+ })
471
+ expect(tool?.start).toEqual([0, 2, 0])
472
+ await act(async () => {
473
+ emitter.emit('grid:click', planEvent(5, 0))
474
+ })
475
+ expect(commits[0]?.end).toEqual([5, 1.9, 0])
476
+ expect(commits[0]?.endConnection.port?.nodeId).toBe('item_graded' as AnyNodeId)
477
+ await act(async () => {
478
+ emitter.emit('grid:click', planEvent(8, 0.04))
479
+ })
480
+ expect(commits[1]?.endConnection.body?.nodeId).toBe('pipe_graded' as AnyNodeId)
481
+ expect(commits[1]?.end[1]).toBeCloseTo(1.84, 6)
482
+ } finally {
483
+ await renderer?.unmount()
484
+ restoreRegistry()
485
+ useScene.setState(previousScene, true)
486
+ useEditor.setState(previousEditor, true)
487
+ useInteractionScope.setState(previousScope, true)
488
+ if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
489
+ else Reflect.deleteProperty(globalThis, 'window')
490
+ if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct)
491
+ else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
492
+ }
493
+ })