@pascal-app/editor 0.9.2 → 1.0.0-beta.2

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +265 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -12,3 +12,95 @@ export function resolveFloorplanLabelAngle(
12
12
  else if (screenAngleDeg <= -90) localAngleDeg += 180
13
13
  return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
14
14
  }
15
+
16
+ export function shouldUpdateFloorplanLabelRotation(
17
+ previousRotationDeg: number | null,
18
+ nextRotationDeg: number,
19
+ minimumDeltaDeg = 1,
20
+ ): boolean {
21
+ if (previousRotationDeg === null) return true
22
+ const deltaDeg = ((((nextRotationDeg - previousRotationDeg + 180) % 360) + 360) % 360) - 180
23
+ return Math.abs(deltaDeg) >= minimumDeltaDeg
24
+ }
25
+
26
+ export function resolveFloorplanAnnotationUpdate({
27
+ layoutInputsChanged,
28
+ previousRotationDeg,
29
+ nextRotationDeg,
30
+ }: {
31
+ layoutInputsChanged: boolean
32
+ previousRotationDeg: number | null
33
+ nextRotationDeg: number
34
+ }): {
35
+ resolveCollisions: boolean
36
+ updateLabelPresentation: boolean
37
+ } {
38
+ return {
39
+ resolveCollisions: layoutInputsChanged,
40
+ updateLabelPresentation:
41
+ layoutInputsChanged ||
42
+ shouldUpdateFloorplanLabelRotation(previousRotationDeg, nextRotationDeg),
43
+ }
44
+ }
45
+
46
+ export function resolveFloorplanAnnotationLabelTransform({
47
+ angleRadians,
48
+ sceneRotationDeg,
49
+ screenUpright,
50
+ beforeRotation,
51
+ afterRotation,
52
+ layoutDx = 0,
53
+ layoutDy = 0,
54
+ }: {
55
+ angleRadians: number
56
+ sceneRotationDeg: number
57
+ screenUpright: boolean
58
+ beforeRotation: string
59
+ afterRotation: string
60
+ layoutDx?: number
61
+ layoutDy?: number
62
+ }): {
63
+ defaultTransform: string
64
+ transform: string
65
+ } {
66
+ const degrees = resolveFloorplanLabelAngle(angleRadians, sceneRotationDeg, screenUpright)
67
+ const defaultTransform = [beforeRotation, `rotate(${degrees})`, afterRotation]
68
+ .filter(Boolean)
69
+ .join(' ')
70
+ return {
71
+ defaultTransform,
72
+ transform:
73
+ layoutDx === 0 && layoutDy === 0
74
+ ? defaultTransform
75
+ : `${defaultTransform} translate(${layoutDx} ${layoutDy})`,
76
+ }
77
+ }
78
+
79
+ export function updateSvgFloorplanLabelOrientations(
80
+ labels: Iterable<SVGGElement>,
81
+ sceneRotationDeg: number,
82
+ ): number {
83
+ let updated = 0
84
+
85
+ for (const label of labels) {
86
+ const angleRadians = Number(label.dataset.floorplanAnnotationAngleRadians)
87
+ if (!Number.isFinite(angleRadians)) continue
88
+
89
+ const { defaultTransform, transform } = resolveFloorplanAnnotationLabelTransform({
90
+ angleRadians,
91
+ sceneRotationDeg,
92
+ screenUpright: label.dataset.floorplanAnnotationScreenUpright === 'true',
93
+ beforeRotation: label.dataset.floorplanAnnotationTransformBeforeRotation ?? '',
94
+ afterRotation: label.dataset.floorplanAnnotationTransformAfterRotation ?? '',
95
+ layoutDx: Number(label.dataset.floorplanAnnotationLayoutDx ?? 0),
96
+ layoutDy: Number(label.dataset.floorplanAnnotationLayoutDy ?? 0),
97
+ })
98
+ if (label.getAttribute('transform') === transform) continue
99
+
100
+ label.dataset.floorplanAnnotationDefaultTransform = defaultTransform
101
+ label.setAttribute('transform', transform)
102
+ updated += 1
103
+ }
104
+
105
+ return updated
106
+ }
@@ -8,10 +8,98 @@ import {
8
8
  nodeRegistry,
9
9
  useScene,
10
10
  } from '@pascal-app/core'
11
- import { memo } from 'react'
11
+ import { useViewer } from '@pascal-app/viewer'
12
+ import { memo, useMemo } from 'react'
12
13
  import usePlacementPreview from '../../../store/use-placement-preview'
14
+ import { useFloorplanRender } from '../floorplan-render-context'
13
15
  import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
14
16
 
17
+ export interface FloorplanNodePreviewProps {
18
+ node: AnyNode
19
+ parentNode?: AnyNode | null
20
+ opacity?: number
21
+ className?: string
22
+ selected?: boolean
23
+ highlighted?: boolean
24
+ hovered?: boolean
25
+ moving?: boolean
26
+ }
27
+
28
+ /**
29
+ * Stateless floor-plan ghost for an already-positioned node. Hosts can use
30
+ * this to render host-owned placement previews without publishing transient
31
+ * state into the editor's local placement-preview store.
32
+ */
33
+ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
34
+ node,
35
+ parentNode = null,
36
+ opacity = 0.5,
37
+ className,
38
+ selected = false,
39
+ highlighted = false,
40
+ hovered = false,
41
+ moving = false,
42
+ }: FloorplanNodePreviewProps) {
43
+ const nodes = useScene((state) => state.nodes)
44
+ const unit = useViewer((state) => state.unit)
45
+ const renderContext = useFloorplanRender()
46
+
47
+ const geometry = useMemo(() => {
48
+ const definition = nodeRegistry.get(node.type)
49
+ const builder = definition?.floorplan
50
+ if (!builder) return null
51
+
52
+ const contextNodes: Record<string, AnyNode> = {
53
+ ...(nodes as Record<string, AnyNode>),
54
+ [node.id]: node,
55
+ }
56
+ if (parentNode) contextNodes[parentNode.id] = parentNode
57
+ const resolvedParent =
58
+ parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null)
59
+ const childIds = (node as AnyNode & { children?: AnyNodeId[] }).children ?? []
60
+ const children = childIds.flatMap((id) => {
61
+ const child = contextNodes[id]
62
+ return child ? [child] : []
63
+ })
64
+ const siblings = Object.values(contextNodes).filter(
65
+ (candidate) =>
66
+ candidate.id !== node.id &&
67
+ candidate.type === node.type &&
68
+ candidate.parentId === node.parentId,
69
+ )
70
+ const levelData = definition.computeFloorplanLevelData?.({
71
+ siblings: [node, ...siblings],
72
+ nodes: contextNodes,
73
+ })
74
+ const ctx: GeometryContext = {
75
+ resolve: <N = AnyNode>(id: AnyNodeId) => contextNodes[id] as N | undefined,
76
+ children,
77
+ siblings,
78
+ parent: resolvedParent,
79
+ levelData,
80
+ viewState: renderContext
81
+ ? {
82
+ selected,
83
+ unit,
84
+ highlighted,
85
+ hovered,
86
+ moving,
87
+ palette: renderContext.palette,
88
+ }
89
+ : undefined,
90
+ }
91
+
92
+ return (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(node, ctx)
93
+ }, [highlighted, hovered, moving, node, nodes, parentNode, renderContext, selected, unit])
94
+ if (!geometry) return null
95
+
96
+ return (
97
+ <g className={className} opacity={opacity} pointerEvents="none">
98
+ <FloorplanGeometryRenderer geometry={geometry} pointerEventsOverride="none" />
99
+ </g>
100
+ )
101
+ })
102
+
15
103
  /**
16
104
  * Renders a faint, non-interactive ghost of the node being placed by a
17
105
  * registry placement tool (e.g. column), following the cursor in the floor
@@ -20,7 +108,8 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
20
108
  * cursor dot + alignment guides — no sense of the footprint they were about
21
109
  * to drop. The placement tool publishes a transient, already-positioned +
22
110
  * aligned node to `usePlacementPreview`; we build its `def.floorplan`
23
- * footprint with a minimal (unselected) context and render it.
111
+ * footprint with active sibling, level-data, and theme context so kind-specific
112
+ * shapes match the committed renderer.
24
113
  *
25
114
  * Mounted inside the floor-plan scene `<g>` so the geometry's level-local
26
115
  * meters get the same world→SVG transform every other entry does.
@@ -30,33 +119,9 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
30
119
  const parentNode = usePlacementPreview((s) => s.parentNode)
31
120
  if (!node) return null
32
121
 
33
- const builder = nodeRegistry.get(node.type)?.floorplan
34
- if (!builder) return null
35
-
36
- // Minimal, unselected context — preview never shows selection chrome
37
- // (move handles / resize arrows / hatch live behind `viewState.selected`).
38
- // `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
39
- // and `parent: null` short-circuits the elevator's level walk) so the layer
40
- // never subscribes to / bulk-reads the nodes map during render.
41
- // `parentNode` is the synthetic wall for an off-wall door/window ghost so
42
- // its builder draws the real swing-arc / pane symbol (see use-placement-preview).
43
- const ctx = {
44
- resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
45
- children: [],
46
- siblings: [],
47
- parent: parentNode ?? null,
48
- viewState: undefined,
49
- } as unknown as GeometryContext
50
-
51
- const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
52
- node,
53
- ctx,
54
- )
55
- if (!geometry) return null
56
-
57
122
  return (
58
- <g data-floorplan-placement-preview opacity={0.5} pointerEvents="none">
59
- <FloorplanGeometryRenderer geometry={geometry} />
123
+ <g data-floorplan-placement-preview>
124
+ <FloorplanNodePreview node={node} parentNode={parentNode} />
60
125
  </g>
61
126
  )
62
127
  })
@@ -3,18 +3,100 @@ import type {
3
3
  AnyNode,
4
4
  AnyNodeId,
5
5
  FloorplanAffordanceSession,
6
+ FloorplanGeometry,
7
+ FloorplanPalette,
6
8
  LiveNodeOverrides,
7
9
  } from '@pascal-app/core'
8
10
  import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
11
+ import { createElement } from 'react'
12
+ import { renderToStaticMarkup } from 'react-dom/server'
9
13
  import { z } from 'zod'
14
+ import {
15
+ FLOORPLAN_NODE_EXTENSION_KEY,
16
+ floorplanGeometryMetadata,
17
+ } from '../../../lib/floorplan/floorplan-extension'
10
18
  import {
11
19
  cancelFloorplanAffordanceDrag,
12
20
  collectFloorplanDependencyNodes,
21
+ collectFloorplanLinkedLevelNodes,
13
22
  computeAffectedSiblingIds,
23
+ floorplanAffordanceReshapeScope,
14
24
  floorplanHandleDoubleClickAffordance,
25
+ InteractiveGeometry,
26
+ isFloorplanOpeningPlacementState,
27
+ resolveFloorplanHandleUnitsPerPixel,
28
+ splitFloorplanOverlay,
15
29
  subscribeFloorplanAffordanceToolCancel,
16
30
  } from './floorplan-registry-layer'
17
31
 
32
+ describe('floorplan selection handle sizing', () => {
33
+ test('caps visual handle growth at extreme zoom-out', () => {
34
+ expect(resolveFloorplanHandleUnitsPerPixel(0.01)).toBe(0.01)
35
+ expect(resolveFloorplanHandleUnitsPerPixel(0.1)).toBe(0.015)
36
+
37
+ const palette = {
38
+ selectedStroke: '#111111',
39
+ selectedFill: '#ffffff',
40
+ selectedHatch: '#111111',
41
+ wallHoverStroke: '#111111',
42
+ endpointHandleFill: '#ffffff',
43
+ endpointHandleStroke: '#111111',
44
+ endpointHandleHoverStroke: '#222222',
45
+ endpointHandleActiveFill: '#333333',
46
+ endpointHandleActiveStroke: '#444444',
47
+ curveHandleFill: '#ffffff',
48
+ curveHandleStroke: '#008080',
49
+ curveHandleHoverStroke: '#00aaaa',
50
+ measurementStroke: '#111111',
51
+ measurementLabelBackground: '#ffffff',
52
+ measurementLabelText: '#111111',
53
+ } satisfies FloorplanPalette
54
+ const noop = () => {}
55
+ const markup = renderToStaticMarkup(
56
+ createElement(
57
+ 'svg',
58
+ null,
59
+ createElement(InteractiveGeometry, {
60
+ activeDragId: null,
61
+ activeRotateNodeId: null,
62
+ geometry: {
63
+ kind: 'endpoint-handle',
64
+ point: [0, 0],
65
+ state: 'idle',
66
+ affordance: 'move-endpoint',
67
+ payload: { endpoint: 'start' },
68
+ },
69
+ hatchPatternId: undefined,
70
+ hoveredHandleId: null,
71
+ isMarqueeSelectionActive: false,
72
+ nodeId: 'wall_test' as AnyNodeId,
73
+ onHandleDoubleClick: noop,
74
+ onHandleHoverChange: noop,
75
+ onHandlePointerDown: noop,
76
+ onMoveHandlePointerDown: noop,
77
+ palette,
78
+ sceneRotationDeg: 0,
79
+ unitsPerPixel: 0.1,
80
+ }),
81
+ ),
82
+ )
83
+
84
+ expect(markup).toContain('r="0.12"')
85
+ expect(markup).not.toContain('r="0.8"')
86
+ })
87
+ })
88
+
89
+ describe('floorplan affordance ownership', () => {
90
+ test('keeps the wall center curve drag owned by the floorplan dispatcher', () => {
91
+ expect(floorplanAffordanceReshapeScope('wall-curve', 'wall_1', undefined)).toEqual({
92
+ kind: 'reshaping',
93
+ nodeId: 'wall_1',
94
+ reshape: 'curve',
95
+ driver: 'floorplan',
96
+ })
97
+ })
98
+ })
99
+
18
100
  function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
19
101
  return {
20
102
  id,
@@ -185,6 +267,35 @@ describe('floorplan affordance cancellation', () => {
185
267
  })
186
268
  })
187
269
 
270
+ describe('floorplan opening placement interaction routing', () => {
271
+ test('passes entries through only while an opening tool or moving opening is active', () => {
272
+ expect(
273
+ isFloorplanOpeningPlacementState({
274
+ phase: 'structure',
275
+ mode: 'build',
276
+ tool: 'window',
277
+ movingNodeHasWallOpeningPlacement: false,
278
+ }),
279
+ ).toBe(true)
280
+ expect(
281
+ isFloorplanOpeningPlacementState({
282
+ phase: 'structure',
283
+ mode: 'select',
284
+ tool: null,
285
+ movingNodeHasWallOpeningPlacement: true,
286
+ }),
287
+ ).toBe(true)
288
+ expect(
289
+ isFloorplanOpeningPlacementState({
290
+ phase: 'structure',
291
+ mode: 'select',
292
+ tool: null,
293
+ movingNodeHasWallOpeningPlacement: false,
294
+ }),
295
+ ).toBe(false)
296
+ })
297
+ })
298
+
188
299
  describe('floorplan vertex double-click routing', () => {
189
300
  test('routes polygon vertex handles to the kind-owned delete affordance', () => {
190
301
  expect(
@@ -209,6 +320,132 @@ describe('floorplan vertex double-click routing', () => {
209
320
  })
210
321
  })
211
322
 
323
+ describe('floorplan annotation overlay routing', () => {
324
+ test('keeps explicitly layered selection chrome above selected body fills', () => {
325
+ const selectionHatch = {
326
+ kind: 'line',
327
+ x1: 0,
328
+ y1: 0,
329
+ x2: 0.2,
330
+ y2: 0.2,
331
+ stroke: '#3b82f6',
332
+ metadata: floorplanGeometryMetadata({ renderPass: 'overlay' }),
333
+ } satisfies FloorplanGeometry
334
+
335
+ expect(splitFloorplanOverlay(selectionHatch)).toEqual({
336
+ base: null,
337
+ overlay: selectionHatch,
338
+ })
339
+ })
340
+
341
+ test('registers upright zone labels for rotation-only presentation updates', () => {
342
+ const noop = () => {}
343
+ const markup = renderToStaticMarkup(
344
+ createElement(
345
+ 'svg',
346
+ null,
347
+ createElement(InteractiveGeometry, {
348
+ activeDragId: null,
349
+ activeRotateNodeId: null,
350
+ geometry: {
351
+ kind: 'text',
352
+ x: 4,
353
+ y: 6,
354
+ text: 'Kitchen',
355
+ fontSize: 0.2,
356
+ upright: true,
357
+ },
358
+ hatchPatternId: undefined,
359
+ hoveredHandleId: null,
360
+ isMarqueeSelectionActive: false,
361
+ nodeId: 'zone_test' as AnyNodeId,
362
+ onHandleDoubleClick: noop,
363
+ onHandleHoverChange: noop,
364
+ onHandlePointerDown: noop,
365
+ onMoveHandlePointerDown: noop,
366
+ palette: undefined,
367
+ sceneRotationDeg: 180,
368
+ unitsPerPixel: 0.01,
369
+ }),
370
+ ),
371
+ )
372
+
373
+ expect(markup).not.toContain('data-floorplan-annotation-label=""')
374
+ expect(markup).toContain('data-floorplan-annotation-angle-radians="0"')
375
+ expect(markup).toContain('data-floorplan-annotation-screen-upright="true"')
376
+ expect(markup).toContain('data-floorplan-annotation-transform-before-rotation="translate(4 6)"')
377
+ expect(markup).toContain('transform="translate(4 6) rotate(-180)"')
378
+ })
379
+
380
+ test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
381
+ const noop = () => {}
382
+ const renderAt180Degrees = (geometry: FloorplanGeometry) =>
383
+ renderToStaticMarkup(
384
+ createElement(
385
+ 'svg',
386
+ null,
387
+ createElement(InteractiveGeometry, {
388
+ activeDragId: null,
389
+ activeRotateNodeId: null,
390
+ geometry,
391
+ hatchPatternId: undefined,
392
+ hoveredHandleId: null,
393
+ isMarqueeSelectionActive: false,
394
+ nodeId: 'wall_test' as AnyNodeId,
395
+ onHandleDoubleClick: noop,
396
+ onHandleHoverChange: noop,
397
+ onHandlePointerDown: noop,
398
+ onMoveHandlePointerDown: noop,
399
+ palette: undefined,
400
+ sceneRotationDeg: 180,
401
+ unitsPerPixel: 0.01,
402
+ }),
403
+ ),
404
+ )
405
+ const dimensionString = (
406
+ end: readonly [number, number],
407
+ offsetNormal: readonly [number, number],
408
+ ): FloorplanGeometry => ({
409
+ kind: 'dimension-string',
410
+ segments: [{ start: [0, 0], end, text: '2m' }],
411
+ offsetNormal,
412
+ offsetDistance: 0.55,
413
+ extensionOvershoot: 0.12,
414
+ textPosition: 'above',
415
+ })
416
+
417
+ expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
418
+ expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
419
+ })
420
+
421
+ test('keeps a fixed mark pill together in the overlay pass', () => {
422
+ const mark = {
423
+ kind: 'group',
424
+ metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
425
+ children: [
426
+ { kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
427
+ { kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
428
+ { kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
429
+ ],
430
+ } satisfies FloorplanGeometry
431
+
432
+ expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
433
+ })
434
+
435
+ test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
436
+ const columnCenter = {
437
+ kind: 'line',
438
+ x1: 0,
439
+ y1: 0,
440
+ x2: 1,
441
+ y2: 0,
442
+ metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
443
+ } satisfies FloorplanGeometry
444
+
445
+ expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
446
+ })
447
+ })
448
+
212
449
  describe('computeAffectedSiblingIds', () => {
213
450
  beforeEach(() => {
214
451
  nodeRegistry._reset()
@@ -306,3 +543,45 @@ describe('collectFloorplanDependencyNodes', () => {
306
543
  ])
307
544
  })
308
545
  })
546
+
547
+ describe('collectFloorplanLinkedLevelNodes', () => {
548
+ test('projects a node onto a linked destination level with its real children', () => {
549
+ nodeRegistry._reset()
550
+ registerNode({
551
+ kind: 'linked-floorplan-test',
552
+ schemaVersion: 1,
553
+ schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
554
+ category: 'structure',
555
+ defaults: () => ({}) as never,
556
+ floorplan: () => null,
557
+ extensions: {
558
+ [FLOORPLAN_NODE_EXTENSION_KEY]: {
559
+ linkedLevelIds: () => ['level_upper' as AnyNodeId],
560
+ },
561
+ },
562
+ } as unknown as AnyNodeDefinition)
563
+ const child = {
564
+ id: 'linked_child',
565
+ type: 'linked-child',
566
+ parentId: 'linked_parent',
567
+ } as unknown as AnyNode
568
+ const parent = {
569
+ id: 'linked_parent',
570
+ type: 'linked-floorplan-test',
571
+ parentId: 'level_lower',
572
+ children: [child.id],
573
+ } as unknown as AnyNode
574
+ const nodes = { [parent.id]: parent, [child.id]: child }
575
+
576
+ expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
577
+ { id: parent.id, node: parent, children: [child] },
578
+ ])
579
+ expect(
580
+ collectFloorplanLinkedLevelNodes(
581
+ nodes,
582
+ 'level_upper' as AnyNodeId,
583
+ new Set([parent.id as AnyNodeId]),
584
+ ),
585
+ ).toEqual([])
586
+ })
587
+ })