@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
@@ -301,7 +301,7 @@ function StructureThicknessHandle({
301
301
  const structureHeight =
302
302
  node.type === 'fence'
303
303
  ? (node.height ?? 1.8)
304
- : getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
304
+ : getWallEffectiveHeightForNodes(node, useScene.getState().nodes, 0.5)
305
305
  const outward = new Vector2(frame.normal.x * side, frame.normal.y * side)
306
306
  const faceOffset = getWallLocalFaceZ({ ...node, thickness }, side > 0 ? 'a' : 'b') * side + 0.006
307
307
  const position: [number, number, number] = [
@@ -375,7 +375,7 @@ function StructureThicknessHandle({
375
375
  const initialStructureHeight =
376
376
  initialNode.type === 'fence'
377
377
  ? (initialNode.height ?? 1.8)
378
- : getWallEffectiveHeightForNodes(initialNode, useScene.getState().nodes)
378
+ : getWallEffectiveHeightForNodes(initialNode, useScene.getState().nodes, 0.5)
379
379
  const initialStructureThickness =
380
380
  initialNode.type === 'fence'
381
381
  ? (initialNode.thickness ?? 0.08)
@@ -578,7 +578,11 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
578
578
  const corner = endpoint === 'start' ? wall.start : wall.end
579
579
  const x = corner[0]
580
580
  const z = corner[1]
581
- const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
581
+ const wallHeight = getWallEffectiveHeightForNodes(
582
+ wall,
583
+ useScene.getState().nodes,
584
+ endpoint === 'start' ? 0 : 1,
585
+ )
582
586
 
583
587
  const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
584
588
  const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
@@ -784,7 +788,7 @@ function WallBaseElevationHandle({
784
788
  levelObject.updateWorldMatrix(true, false)
785
789
  const initialBase = getWallBaseElevationForNodes(initialNode, sceneApi.nodes())
786
790
  const supportBase = initialBase - (initialNode.supportOffset ?? 0)
787
- const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes())
791
+ const initialHeight = getWallEffectiveHeightForNodes(initialNode, sceneApi.nodes(), 0)
788
792
  const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4(
789
793
  levelObject.matrixWorld,
790
794
  )
@@ -920,7 +924,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
920
924
  const wallAngle = Math.atan2(-dirZ, dirX)
921
925
  // `wall` is the override-merged effective wall (see
922
926
  // WallMoveSideHandlesForWall), so this height is already live during a drag.
923
- const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
927
+ const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes, 0.5)
924
928
  const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
925
929
 
926
930
  const activateHeightResize = (event: ThreeEvent<PointerEvent>) => {
@@ -973,7 +977,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
973
977
 
974
978
  // Dragging the top makes the wall custom-height; seed from the resolved
975
979
  // effective height so a plane-bound wall's drag starts at its real top.
976
- const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes)
980
+ const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes, 0)
977
981
  const wallId = wall.id as AnyNodeId
978
982
  let pendingHeight = initialHeight
979
983
  let releaseSpatialCapture: (() => void) | null = null
@@ -203,7 +203,7 @@ function WallRegionHandlesForWall({ wall }: { wall: WallNode }) {
203
203
  const frame: WallFrame = {
204
204
  wall: effectiveWall,
205
205
  baseElevation: getWallBaseElevationForNodes(effectiveWall, nodes),
206
- wallHeight: getWallEffectiveHeightForNodes(effectiveWall, nodes),
206
+ wallHeight: getWallEffectiveHeightForNodes(effectiveWall, nodes, 0.5),
207
207
  angle: Math.atan2(
208
208
  effectiveWall.end[1] - effectiveWall.start[1],
209
209
  effectiveWall.end[0] - effectiveWall.start[0],
@@ -426,7 +426,7 @@ function RegionEdgeHandle({
426
426
  const face = region.face
427
427
  const faceSurface = getWallFaceSurface(initialNode, face)
428
428
  const baseAt = faceBaseSampler(initialNode.id, face)
429
- const wallHeight = getWallEffectiveHeightForNodes(initialNode, nodes)
429
+ const wallHeight = getWallEffectiveHeightForNodes(initialNode, nodes, 0.5)
430
430
  const faceHeight = regionFaceHeight(region, faceSurface.length, wallHeight, baseAt)
431
431
  const bounds = resolveRegionBounds(region, faceSurface.length, faceHeight)
432
432
  const midU = (bounds.u0 + bounds.u1) / 2
@@ -165,9 +165,10 @@ type WallTopHighlightSegment = {
165
165
  angle: number
166
166
  center: [number, number]
167
167
  length: number
168
+ tCenter: number
168
169
  }
169
170
 
170
- function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
171
+ function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>, t = 0.5): number {
171
172
  const levelId = resolveLevelId(wall, nodes as Record<string, AnyNode>)
172
173
  const support = spatialGridManager.getSlabSupportForWall(
173
174
  levelId,
@@ -181,12 +182,16 @@ function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
181
182
  wall.justification,
182
183
  )
183
184
  return (
184
- getWallTop(wall, levelId, nodes as Record<string, AnyNode>, support.elevation) +
185
+ getWallTop(wall, levelId, nodes as Record<string, AnyNode>, support.elevation, t) +
185
186
  WALL_TOP_HIGHLIGHT_LIFT
186
187
  )
187
188
  }
188
189
 
189
- function buildHighlightSegment(start: [number, number], end: [number, number]) {
190
+ function buildHighlightSegment(
191
+ start: [number, number],
192
+ end: [number, number],
193
+ tCenter = 0.5,
194
+ ): WallTopHighlightSegment | null {
190
195
  const dx = end[0] - start[0]
191
196
  const dz = end[1] - start[1]
192
197
  const length = Math.hypot(dx, dz)
@@ -196,15 +201,20 @@ function buildHighlightSegment(start: [number, number], end: [number, number]) {
196
201
  angle: -Math.atan2(dz, dx),
197
202
  center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number],
198
203
  length,
204
+ tCenter,
199
205
  }
200
206
  }
201
207
 
202
208
  function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] {
203
209
  if (!isCurvedWall(wall)) {
204
- const segment = buildHighlightSegment(wall.start, wall.end)
210
+ const segment = buildHighlightSegment(wall.start, wall.end, 0.5)
205
211
  return segment ? [segment] : []
206
212
  }
207
213
 
214
+ const dx = wall.end[0] - wall.start[0]
215
+ const dz = wall.end[1] - wall.start[1]
216
+ const chordLenSq = dx * dx + dz * dz
217
+
208
218
  const sampleCount = Math.max(
209
219
  8,
210
220
  Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH),
@@ -213,7 +223,16 @@ function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[
213
223
  let previous = getWallCurveFrameAt(wall, 0).point
214
224
  for (let index = 1; index <= sampleCount; index += 1) {
215
225
  const current = getWallCurveFrameAt(wall, index / sampleCount).point
216
- const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y])
226
+ const midX = (previous.x + current.x) / 2
227
+ const midY = (previous.y + current.y) / 2
228
+ const tCenter =
229
+ chordLenSq > 1e-12
230
+ ? Math.max(
231
+ 0,
232
+ Math.min(1, ((midX - wall.start[0]) * dx + (midY - wall.start[1]) * dz) / chordLenSq),
233
+ )
234
+ : 0.5
235
+ const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y], tCenter)
217
236
  if (segment) segments.push(segment)
218
237
  previous = current
219
238
  }
@@ -228,38 +247,40 @@ function WallTopHighlight({
228
247
  wall: WallNode
229
248
  }) {
230
249
  const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall])
231
- const y = getWallTopY(wall, nodes)
232
250
  const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24)
233
251
  const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42)
234
252
 
235
253
  return (
236
254
  <>
237
- {segments.map((segment, index) => (
238
- <group key={`${wall.id}:${index}`}>
239
- <mesh
240
- frustumCulled={false}
241
- geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
242
- layers={EDITOR_LAYER}
243
- material={wallTopHighlightGlowMaterial}
244
- position={[segment.center[0], y - 0.003, segment.center[1]]}
245
- raycast={NO_RAYCAST}
246
- renderOrder={1003}
247
- rotation={[0, segment.angle, 0]}
248
- scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
249
- />
250
- <mesh
251
- frustumCulled={false}
252
- geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
253
- layers={EDITOR_LAYER}
254
- material={wallTopHighlightMaterial}
255
- position={[segment.center[0], y + 0.002, segment.center[1]]}
256
- raycast={NO_RAYCAST}
257
- renderOrder={1004}
258
- rotation={[0, segment.angle, 0]}
259
- scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
260
- />
261
- </group>
262
- ))}
255
+ {segments.map((segment, index) => {
256
+ const y = getWallTopY(wall, nodes, segment.tCenter)
257
+ return (
258
+ <group key={`${wall.id}:${index}`}>
259
+ <mesh
260
+ frustumCulled={false}
261
+ geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
262
+ layers={EDITOR_LAYER}
263
+ material={wallTopHighlightGlowMaterial}
264
+ position={[segment.center[0], y - 0.003, segment.center[1]]}
265
+ raycast={NO_RAYCAST}
266
+ renderOrder={1003}
267
+ rotation={[0, segment.angle, 0]}
268
+ scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
269
+ />
270
+ <mesh
271
+ frustumCulled={false}
272
+ geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
273
+ layers={EDITOR_LAYER}
274
+ material={wallTopHighlightMaterial}
275
+ position={[segment.center[0], y + 0.002, segment.center[1]]}
276
+ raycast={NO_RAYCAST}
277
+ renderOrder={1004}
278
+ rotation={[0, segment.angle, 0]}
279
+ scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
280
+ />
281
+ </group>
282
+ )
283
+ })}
263
284
  </>
264
285
  )
265
286
  }
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_ANGLE_STEP,
9
9
  type FloorplanGeometry,
10
10
  type FloorplanPalette,
11
+ formatLinearMeasurement,
11
12
  pauseSceneHistory,
12
13
  pauseSpaceDetection,
13
14
  resumeSceneHistory,
@@ -23,7 +24,6 @@ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contex
23
24
  import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
24
25
  import { clientToPlan } from '../../lib/floorplan/plan-coords'
25
26
  import { isHistoryShortcut } from '../../lib/history'
26
- import { formatLinearMeasurement } from '../../lib/measurements'
27
27
  import type { WallGroupPush } from '../../lib/room-handle-drag'
28
28
  import { useRoomHandleDrag } from '../../lib/room-handle-drag'
29
29
  import { sfxEmitter } from '../../lib/sfx-bus'
@@ -3,11 +3,17 @@
3
3
  import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
+ buildMeasurementAngleArcPoints,
6
7
  closestMeasurementFeatureBinding,
7
8
  collectAlignmentAnchors,
8
9
  emitter,
10
+ formatAngleRadians,
11
+ formatAreaLabel,
12
+ formatLinearMeasurement,
9
13
  formatNumber,
14
+ formatVolumeLabel,
10
15
  type GeometryContext,
16
+ getLinearUnitLabel,
11
17
  type MeasurementFeatureAnchor,
12
18
  type MeasurementSnapKind,
13
19
  measurementAngle,
@@ -16,23 +22,18 @@ import {
16
22
  measurementFeatureLength,
17
23
  measurementPerimeter,
18
24
  measurementPrismVolume,
25
+ metersToLinearUnit,
19
26
  nodeRegistry,
27
+ parseLinearMeasurementInput,
20
28
  useLocale,
21
29
  useScene,
22
30
  } from '@pascal-app/core'
23
- import { useViewer } from '@pascal-app/viewer'
24
- import { useEffect, useMemo, useRef, useState } from 'react'
25
- import { measurementPolygonLabelAnchor } from '../../lib/measurement-label'
26
31
  import {
27
- buildMeasurementAngleArcPoints,
28
- formatAreaLabel,
29
- formatLinearMeasurement,
30
- formatVolumeLabel,
31
- getLinearUnitLabel,
32
32
  MEASUREMENT_ACTIVE_COLOR,
33
- metersToLinearUnit,
34
- parseLinearMeasurementInput,
35
- } from '../../lib/measurements'
33
+ measurementPolygonLabelAnchor,
34
+ useViewer,
35
+ } from '@pascal-app/viewer'
36
+ import { useEffect, useMemo, useRef, useState } from 'react'
36
37
  import {
37
38
  clearSurfacePlanSnapFeedback,
38
39
  resolveSurfacePlanPointSnap,
@@ -45,7 +46,6 @@ import {
45
46
  measurementPolygonMidpoints,
46
47
  useMeasurementDraft,
47
48
  } from '../../store/use-measurement-draft'
48
- import { formatAngleRadians } from '../tools/shared/segment-angle'
49
49
  import { FloorplanQuickMeasureLayer } from './floorplan-quick-measure-layer'
50
50
  import { useFloorplanRender } from './floorplan-render-context'
51
51
  import { resolveFloorplanLabelAngle } from './renderers/floorplan-label-angle'
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import type { AnyNodeId, OpenWallEnd } from '@pascal-app/core'
4
- import { useViewer } from '@pascal-app/viewer'
4
+ import { MEASUREMENT_DANGLING_COLOR, useViewer } from '@pascal-app/viewer'
5
5
  import { memo, useEffect, useState } from 'react'
6
6
  import { createPortal } from 'react-dom'
7
7
  import {
@@ -10,7 +10,6 @@ import {
10
10
  useOpenWallEndsSuppressed,
11
11
  } from '../../hooks/use-open-wall-ends'
12
12
  import { openWallEndKey, openWallEndLabel } from '../../lib/floorplan/open-wall-ends'
13
- import { MEASUREMENT_DANGLING_COLOR } from '../../lib/measurements'
14
13
  import useEditor from '../../store/use-editor'
15
14
  import { isViewVisible, VIEW_2D } from '../../store/view-layout'
16
15
  import { JoinWallsPill } from '../editor/join-walls-pill'
@@ -7,6 +7,7 @@ import {
7
7
  collectionIdsOf,
8
8
  createSceneApi,
9
9
  getWallMidpointHandlePoint,
10
+ isNodeDuplicable,
10
11
  type NodeQuickAction,
11
12
  nodeRegistry,
12
13
  runAsSingleSceneHistoryStep,
@@ -255,7 +256,7 @@ export function FloorplanRegistryActionMenu() {
255
256
  const canMove =
256
257
  !registryMoveDisabled(node) &&
257
258
  (!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move)
258
- const canDuplicate = def.capabilities.duplicable !== false
259
+ const canDuplicate = isNodeDuplicable(node)
259
260
  const canDelete = def.capabilities.deletable !== false
260
261
  const canAddHole = node.type === 'slab' || node.type === 'ceiling'
261
262
 
@@ -850,14 +850,25 @@ export function FloorplanRegistryMoveOverlay() {
850
850
  }
851
851
  boxEl.setAttribute('transform', transform)
852
852
  const oldY = originalPosition[1]
853
+ const position = nodeRegistry.get(movingNode.type)?.capabilities?.movable?.resolvePosition?.({
854
+ node: useScene.getState().nodes[movingNode.id] ?? movingNode,
855
+ position: [finalX, oldY, finalZ],
856
+ nodes: useScene.getState().nodes,
857
+ }) ?? [finalX, oldY, finalZ]
858
+ if (def?.capabilities?.movable?.resolvePosition) {
859
+ useLiveTransforms.getState().set(movingNode.id, {
860
+ position: position as [number, number, number],
861
+ rotation: currentRotation,
862
+ })
863
+ }
853
864
  lastPositionValid = movableValidityConfig?.isValidPosition
854
865
  ? movableValidityConfig.isValidPosition({
855
866
  node: {
856
867
  ...movingNode,
857
- position: [finalX, oldY, finalZ],
868
+ position,
858
869
  rotation: currentRotation,
859
870
  } as AnyNode,
860
- position: [finalX, oldY, finalZ],
871
+ position: position as [number, number, number],
861
872
  rotation: currentRotation,
862
873
  levelId:
863
874
  (useViewer.getState().selection.levelId as AnyNodeId | null) ??
@@ -885,6 +896,11 @@ export function FloorplanRegistryMoveOverlay() {
885
896
  (storedRotation as [number?, number?, number?])[2] ?? 0,
886
897
  ]
887
898
  : snapped.rotation
899
+ const position = nodeRegistry.get(movingNode.type)?.capabilities?.movable?.resolvePosition?.({
900
+ node: useScene.getState().nodes[movingNode.id] ?? movingNode,
901
+ position: [sx, oldY, sz],
902
+ nodes: useScene.getState().nodes,
903
+ }) ?? [sx, oldY, sz]
888
904
  const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
889
905
  const atomicPreview =
890
906
  isFreshPlacement &&
@@ -937,7 +953,7 @@ export function FloorplanRegistryMoveOverlay() {
937
953
  const committedId = commitFreshPlacementSubtree(
938
954
  movingNode.id as AnyNodeId,
939
955
  {
940
- position: [sx, oldY, sz],
956
+ position,
941
957
  ...rotationPatch,
942
958
  metadata: stripPlacementMetadataFlags((movingNode as { metadata?: unknown }).metadata),
943
959
  visible: true,
@@ -953,7 +969,7 @@ export function FloorplanRegistryMoveOverlay() {
953
969
  useScene.getState().updateNode(
954
970
  movingNode.id as AnyNodeId,
955
971
  {
956
- position: [sx, oldY, sz],
972
+ position,
957
973
  ...rotationPatch,
958
974
  } as Partial<AnyNode>,
959
975
  )
@@ -1005,6 +1021,8 @@ export function FloorplanRegistryMoveOverlay() {
1005
1021
  for (const relatedEntry of relatedEntries) {
1006
1022
  relatedEntry.style.visibility = ''
1007
1023
  }
1024
+ if (def?.capabilities?.movable?.resolvePosition)
1025
+ useLiveTransforms.getState().clear(movingNode.id)
1008
1026
  boxEl.remove()
1009
1027
  useAlignmentGuides.getState().clear()
1010
1028
  }
@@ -19,6 +19,7 @@ export type DraftMeasurement = {
19
19
  }
20
20
 
21
21
  export function FloorplanDraftMeasurement({
22
+ lengthHidden = false,
22
23
  measurement,
23
24
  measurementStroke,
24
25
  labelBackground,
@@ -26,6 +27,8 @@ export function FloorplanDraftMeasurement({
26
27
  sceneRotationDeg,
27
28
  unitsPerPixel,
28
29
  }: {
30
+ /** Leave the length plate to something drawn in its place (the typed-entry HUD). */
31
+ lengthHidden?: boolean
29
32
  measurement: DraftMeasurement
30
33
  measurementStroke: string
31
34
  labelBackground: string
@@ -68,7 +71,10 @@ export function FloorplanDraftMeasurement({
68
71
 
69
72
  return (
70
73
  <g pointerEvents="none">
71
- <g transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}>
74
+ <g
75
+ display={lengthHidden ? 'none' : undefined}
76
+ transform={`translate(${cx} ${cy}) rotate(${labelAngleDeg})`}
77
+ >
72
78
  <rect
73
79
  fill={labelBg}
74
80
  height={lengthPlateH}
@@ -4,17 +4,19 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type FloorplanGeometry,
7
+ formatLinearMeasurement,
7
8
  type GeometryContext,
8
9
  nodeRegistry,
9
10
  useScene,
10
11
  } from '@pascal-app/core'
11
12
  import { useViewer } from '@pascal-app/viewer'
12
13
  import { memo, useMemo } from 'react'
13
- import { formatLinearMeasurement } from '../../../lib/measurements'
14
14
  import usePlacementPreview from '../../../store/use-placement-preview'
15
+ import { usePlacementTyping } from '../../../store/use-placement-typing'
15
16
  import { useFloorplanRender, useFloorplanSceneRotation } from '../floorplan-render-context'
16
17
  import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
17
18
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
19
+ import { FloorplanPlacementTypingHud } from './floorplan-placement-typing-hud'
18
20
 
19
21
  export interface FloorplanNodePreviewProps {
20
22
  node: AnyNode
@@ -139,14 +141,24 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
139
141
  const dimensions = usePlacementPreview((s) => s.dimensions)
140
142
  const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId)
141
143
  const dimensionInput = usePlacementPreview((s) => s.dimensionInput)
144
+ const typingProjectedPosition = usePlacementTyping((s) => s.projectedPosition)
142
145
  const unit = useViewer((s) => s.unit)
143
146
  const metricNotation = useViewer((s) => s.metricNotation)
144
147
  const sceneRotationDeg = useFloorplanSceneRotation()
148
+ const unitsPerPixel = useFloorplanRender()?.unitsPerPixel ?? 1
145
149
  if (!node) return null
146
150
 
147
151
  return (
148
152
  <g data-floorplan-placement-preview>
149
153
  <FloorplanNodePreview contextNodes={contextNodes} node={node} parentNode={parentNode} />
154
+ {typingProjectedPosition ? (
155
+ <FloorplanPlacementTypingHud
156
+ sceneRotationDeg={sceneRotationDeg}
157
+ unitsPerPixel={unitsPerPixel}
158
+ x={typingProjectedPosition[0]}
159
+ y={typingProjectedPosition[2]}
160
+ />
161
+ ) : null}
150
162
  <g data-floorplan-placement-dimensions>
151
163
  {dimensions
152
164
  .filter((dimension) => dimension.renderInFloorplan !== false)
@@ -0,0 +1,35 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import {
3
+ PLACEMENT_TYPING_LENGTH_ANGLE,
4
+ usePlacementTyping,
5
+ } from '../../../store/use-placement-typing'
6
+ import { renderCurrentMarkup } from '../../../test-utils/render-current-markup'
7
+ import { FloorplanPlacementTypingHud } from './floorplan-placement-typing-hud'
8
+
9
+ afterEach(() => {
10
+ usePlacementTyping.getState().clear()
11
+ })
12
+
13
+ test('sizes and counter-rotates the HUD from the props it is given', () => {
14
+ usePlacementTyping.getState().begin({ labels: PLACEMENT_TYPING_LENGTH_ANGLE, activeField: 0 })
15
+
16
+ const markup = renderCurrentMarkup(
17
+ <svg>
18
+ <FloorplanPlacementTypingHud sceneRotationDeg={30} unitsPerPixel={0.02} x={1} y={2} />
19
+ </svg>,
20
+ )
21
+
22
+ expect(markup).toContain('transform="translate(1 2) rotate(-30)"')
23
+ expect(markup).toMatch(/<foreignObject[^>]*width="4.72"/)
24
+ expect(markup).toContain('scale(0.02)')
25
+ })
26
+
27
+ test('renders nothing without a typing session', () => {
28
+ const markup = renderCurrentMarkup(
29
+ <svg>
30
+ <FloorplanPlacementTypingHud sceneRotationDeg={0} unitsPerPixel={0.02} x={0} y={0} />
31
+ </svg>,
32
+ )
33
+
34
+ expect(markup).not.toContain('foreignObject')
35
+ })
@@ -0,0 +1,65 @@
1
+ 'use client'
2
+
3
+ import { usePlacementTyping } from '../../../store/use-placement-typing'
4
+ import { PlacementCoordinateInput } from '../../tools/shared/placement-coordinate-input'
5
+
6
+ const HUD_WIDTH_PX = 236
7
+ const HUD_HEIGHT_PX = 64
8
+ const HUD_LIFT_PX = 14
9
+
10
+ /**
11
+ * The typed-entry HUD placed in the plan at `x`/`y` (level-local metres).
12
+ * HTML inside a `foreignObject` is laid out in CSS pixels equal to SVG user
13
+ * units, and the plan's `<g>` is zoomed and rotated, so the box is scaled by
14
+ * `unitsPerPixel` and counter-rotated to stay screen-sized and upright. Both
15
+ * come from the caller: the wall draft renders outside `FloorplanRenderProvider`.
16
+ * It sits just above its anchor, and only the HUD itself takes the pointer, so
17
+ * clicks on the ghost or the draft under it still land on the plan.
18
+ */
19
+ export function FloorplanPlacementTypingHud({
20
+ placeholders,
21
+ sceneRotationDeg,
22
+ unitsPerPixel,
23
+ x,
24
+ y,
25
+ }: {
26
+ placeholders?: readonly string[]
27
+ sceneRotationDeg: number
28
+ unitsPerPixel: number
29
+ x: number
30
+ y: number
31
+ }) {
32
+ const isActive = usePlacementTyping((state) => state.isActive)
33
+ const scale = Math.max(unitsPerPixel, 1e-6)
34
+ if (!isActive) return null
35
+
36
+ return (
37
+ <g transform={`translate(${x} ${y}) rotate(${-sceneRotationDeg})`}>
38
+ <foreignObject
39
+ height={HUD_HEIGHT_PX * scale}
40
+ overflow="visible"
41
+ pointerEvents="none"
42
+ style={{ overflow: 'visible' }}
43
+ width={HUD_WIDTH_PX * scale}
44
+ x={(-HUD_WIDTH_PX / 2) * scale}
45
+ y={-(HUD_HEIGHT_PX + HUD_LIFT_PX) * scale}
46
+ >
47
+ <div
48
+ style={{
49
+ alignItems: 'flex-end',
50
+ display: 'flex',
51
+ height: HUD_HEIGHT_PX,
52
+ justifyContent: 'center',
53
+ overflow: 'visible',
54
+ pointerEvents: 'none',
55
+ transform: `scale(${scale})`,
56
+ transformOrigin: 'top left',
57
+ width: HUD_WIDTH_PX,
58
+ }}
59
+ >
60
+ <PlacementCoordinateInput placeholders={placeholders} />
61
+ </div>
62
+ </foreignObject>
63
+ </g>
64
+ )
65
+ }
@@ -11,11 +11,13 @@ import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pa
11
11
  import { createElement } from 'react'
12
12
  import { renderToStaticMarkup } from 'react-dom/server'
13
13
  import { z } from 'zod'
14
+ import { AXIS_MOVE_DRAG_LABEL, PATH_POINT_DRAG_LABEL } from '../../../lib/contextual-help'
14
15
  import {
15
16
  FLOORPLAN_NODE_EXTENSION_KEY,
16
17
  floorplanGeometryMetadata,
17
18
  } from '../../../lib/floorplan/floorplan-extension'
18
19
  import type { SessionWrites } from '../../../lib/session-writes'
20
+ import { snapContextOf } from '../../../lib/snapping-mode'
19
21
  import {
20
22
  buildFloorplanEntryGeometry,
21
23
  cancelFloorplanAffordanceDrag,
@@ -276,6 +278,26 @@ describe('floorplan affordance ownership', () => {
276
278
  driver: 'floorplan',
277
279
  })
278
280
  })
281
+
282
+ // With no scope, Select mode reads every snap mode as off, so these drags
283
+ // never snapped to the grid on the plan.
284
+ test('gives the MEP run point and segment drags a handle-drag scope in the run snap context', () => {
285
+ const point = floorplanAffordanceReshapeScope('move-path-point', 'duct_1', { pointIndex: 0 })
286
+ const segment = floorplanAffordanceReshapeScope('move-segment', 'duct_1', { segmentIndex: 0 })
287
+ expect(point).toEqual({ kind: 'handle-drag', nodeId: 'duct_1', handle: PATH_POINT_DRAG_LABEL })
288
+ expect(segment).toEqual({ kind: 'handle-drag', nodeId: 'duct_1', handle: AXIS_MOVE_DRAG_LABEL })
289
+ for (const scope of [point, segment]) {
290
+ expect(
291
+ snapContextOf({
292
+ scope: scope!,
293
+ mode: 'select',
294
+ tool: null,
295
+ profileOf: () => undefined,
296
+ profileOfNode: () => 'structural',
297
+ }),
298
+ ).toBe('polygon')
299
+ }
300
+ })
279
301
  })
280
302
 
281
303
  function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
@@ -59,7 +59,11 @@ import {
59
59
  useRoomRecords,
60
60
  } from '../../../hooks/use-selected-room'
61
61
  import { useCeilingEditSession } from '../../../lib/ceiling-edit-session'
62
- import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
62
+ import {
63
+ AXIS_MOVE_DRAG_LABEL,
64
+ PATH_POINT_DRAG_LABEL,
65
+ ROTATE_HANDLE_DRAG_LABEL,
66
+ } from '../../../lib/contextual-help'
63
67
  import {
64
68
  canDirectRotateNode,
65
69
  resolveDirectManipulationNode,
@@ -321,7 +325,7 @@ export function subscribeFloorplanAffordanceToolCancel(
321
325
  // edits that set a direction/shape map to a scope; resize / rotate / body-move
322
326
  // affordances return `null` (no polygon/wall snapping chip). Keyed off the
323
327
  // affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex`
324
- // / `curve` / `move-endpoint`).
328
+ // / `curve` / `move-endpoint` / `move-path-point` / `move-segment`).
325
329
  export function floorplanAffordanceReshapeScope(
326
330
  affordance: string,
327
331
  nodeId: string,
@@ -362,6 +366,14 @@ export function floorplanAffordanceReshapeScope(
362
366
  if (affordance.includes('rotate')) {
363
367
  return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
364
368
  }
369
+ // MEP run point and run segment drags: the same handle-drag scopes as their
370
+ // 3D arrows, so they snap in the kind's own context (polygon for runs).
371
+ if (affordance === 'move-path-point') {
372
+ return { kind: 'handle-drag', nodeId, handle: PATH_POINT_DRAG_LABEL }
373
+ }
374
+ if (affordance === 'move-segment') {
375
+ return { kind: 'handle-drag', nodeId, handle: AXIS_MOVE_DRAG_LABEL }
376
+ }
365
377
  return null
366
378
  }
367
379