@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
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { measurementHint, parseMeasurement } from '../index'
3
2
  import {
4
3
  buildMeasurementAngleArcPoints,
5
4
  cubicMetersToVolumeUnit,
@@ -11,16 +10,18 @@ import {
11
10
  getVolumeUnitLabel,
12
11
  linearControlValueToMeters,
13
12
  linearUnitToMeters,
13
+ metersToLinearUnit,
14
+ parseLinearMeasurementInput,
15
+ squareMetersToAreaUnit,
16
+ } from '@pascal-app/core'
17
+ import {
14
18
  MEASUREMENT_ACTIVE_COLOR,
15
19
  MEASUREMENT_DANGLING_COLOR,
16
- MEASUREMENT_FLOORPLAN_COLOR,
17
20
  MEASUREMENT_PERSISTENT_COLOR,
18
- measurementFloorplanPresentationColor,
19
21
  measurementPresentationColor,
20
- metersToLinearUnit,
21
- parseLinearMeasurementInput,
22
- squareMetersToAreaUnit,
23
- } from './measurements'
22
+ } from '@pascal-app/viewer'
23
+ import { measurementHint, parseMeasurement } from '../index'
24
+ import { MEASUREMENT_FLOORPLAN_COLOR, measurementFloorplanPresentationColor } from './measurements'
24
25
 
25
26
  describe('measurement presentation', () => {
26
27
  test('uses black at rest, indigo while active, and red for dangling references', () => {
@@ -1,225 +1,8 @@
1
- import { formatNumber, type MeasurementPoint, parseLocalizedNumber } from '@pascal-app/core'
1
+ import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_DANGLING_COLOR } from '@pascal-app/viewer'
2
2
 
3
- export type LinearUnit = 'metric' | 'imperial'
4
- export type MetricNotation = 'meters' | 'millimeters'
5
-
6
- export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
7
- export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
8
3
  export const MEASUREMENT_FLOORPLAN_COLOR = '#4f46e5'
9
- export const MEASUREMENT_PERSISTENT_COLOR = '#111827'
10
-
11
- export function measurementPresentationColor(dangling: boolean, active: boolean): string {
12
- if (dangling) return MEASUREMENT_DANGLING_COLOR
13
- return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_PERSISTENT_COLOR
14
- }
15
4
 
16
5
  export function measurementFloorplanPresentationColor(dangling: boolean, active: boolean): string {
17
6
  if (dangling) return MEASUREMENT_DANGLING_COLOR
18
7
  return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_FLOORPLAN_COLOR
19
8
  }
20
-
21
- type MeasurementAngleArcOptions = {
22
- radius?: number
23
- sampleCount?: number
24
- }
25
-
26
- const ANGLE_ARC_EPSILON = 1e-9
27
-
28
- const subtractPoint = (point: MeasurementPoint, origin: MeasurementPoint): MeasurementPoint => [
29
- point[0] - origin[0],
30
- point[1] - origin[1],
31
- point[2] - origin[2],
32
- ]
33
-
34
- const pointLength = (point: MeasurementPoint): number => Math.hypot(...point)
35
-
36
- const scalePoint = (point: MeasurementPoint, scale: number): MeasurementPoint => [
37
- point[0] * scale,
38
- point[1] * scale,
39
- point[2] * scale,
40
- ]
41
-
42
- const crossPoint = (first: MeasurementPoint, second: MeasurementPoint): MeasurementPoint => [
43
- first[1] * second[2] - first[2] * second[1],
44
- first[2] * second[0] - first[0] * second[2],
45
- first[0] * second[1] - first[1] * second[0],
46
- ]
47
-
48
- const dotPoint = (first: MeasurementPoint, second: MeasurementPoint): number =>
49
- first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
50
-
51
- export function buildMeasurementAngleArcPoints(
52
- start: MeasurementPoint,
53
- vertex: MeasurementPoint,
54
- end: MeasurementPoint,
55
- options: MeasurementAngleArcOptions = {},
56
- ): MeasurementPoint[] {
57
- if (![...start, ...vertex, ...end].every(Number.isFinite)) return []
58
-
59
- const startVector = subtractPoint(start, vertex)
60
- const endVector = subtractPoint(end, vertex)
61
- const startLength = pointLength(startVector)
62
- const endLength = pointLength(endVector)
63
- if (startLength <= ANGLE_ARC_EPSILON || endLength <= ANGLE_ARC_EPSILON) return []
64
-
65
- const startDirection = scalePoint(startVector, 1 / startLength)
66
- const endDirection = scalePoint(endVector, 1 / endLength)
67
- const cosine = Math.max(-1, Math.min(1, dotPoint(startDirection, endDirection)))
68
- const angle = Math.acos(cosine)
69
- if (angle <= 1e-4) return []
70
-
71
- let normal = crossPoint(startDirection, endDirection)
72
- let normalLength = pointLength(normal)
73
- if (normalLength <= ANGLE_ARC_EPSILON) {
74
- const reference: MeasurementPoint = Math.abs(startDirection[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
75
- normal = crossPoint(startDirection, reference)
76
- normalLength = pointLength(normal)
77
- }
78
- if (normalLength <= ANGLE_ARC_EPSILON) return []
79
- normal = scalePoint(normal, 1 / normalLength)
80
- const tangent = crossPoint(normal, startDirection)
81
-
82
- const shortestSide = Math.min(startLength, endLength)
83
- const preferredRadius = options.radius ?? Math.min(Math.max(shortestSide * 0.28, 0.08), 0.75)
84
- const radius = Math.min(Math.max(preferredRadius, 0), shortestSide * 0.45)
85
- if (radius <= ANGLE_ARC_EPSILON) return []
86
-
87
- const sampleCount = Math.min(
88
- 64,
89
- Math.max(4, Math.round(options.sampleCount ?? Math.max(8, (angle / Math.PI) * 32))),
90
- )
91
- return Array.from({ length: sampleCount + 1 }, (_, index) => {
92
- const sampleAngle = angle * (index / sampleCount)
93
- const direction: MeasurementPoint = [
94
- startDirection[0] * Math.cos(sampleAngle) + tangent[0] * Math.sin(sampleAngle),
95
- startDirection[1] * Math.cos(sampleAngle) + tangent[1] * Math.sin(sampleAngle),
96
- startDirection[2] * Math.cos(sampleAngle) + tangent[2] * Math.sin(sampleAngle),
97
- ]
98
- return [
99
- vertex[0] + direction[0] * radius,
100
- vertex[1] + direction[1] * radius,
101
- vertex[2] + direction[2] * radius,
102
- ]
103
- })
104
- }
105
-
106
- const METERS_PER_FOOT = 0.3048
107
- const FEET_PER_METER = 1 / METERS_PER_FOOT
108
-
109
- type LinearControlValueOptions = {
110
- minMeters?: number
111
- maxMeters?: number
112
- }
113
-
114
- export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
115
- return unit === 'imperial' ? meters * FEET_PER_METER : meters
116
- }
117
-
118
- export function linearUnitToMeters(value: number, unit: LinearUnit): number {
119
- return unit === 'imperial' ? value * METERS_PER_FOOT : value
120
- }
121
-
122
- export function parseLinearMeasurementInput(
123
- value: string,
124
- unit: LinearUnit,
125
- locale: string,
126
- ): number | null {
127
- const parsed = parseLocalizedNumber(value, locale)
128
- return parsed === null ? null : linearUnitToMeters(parsed, unit)
129
- }
130
-
131
- export function linearControlValueToMeters(
132
- value: number,
133
- unit: LinearUnit,
134
- options: LinearControlValueOptions = {},
135
- ): number {
136
- const meters = linearUnitToMeters(value, unit)
137
- const minMeters = options.minMeters ?? Number.NEGATIVE_INFINITY
138
- const maxMeters = options.maxMeters ?? Number.POSITIVE_INFINITY
139
-
140
- return Math.min(Math.max(meters, minMeters), maxMeters)
141
- }
142
-
143
- export function getLinearUnitLabel(unit: LinearUnit): string {
144
- return unit === 'imperial' ? 'ft' : 'm'
145
- }
146
-
147
- const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
148
- const CUBIC_FEET_PER_CUBIC_METER = SQUARE_FEET_PER_SQUARE_METER * FEET_PER_METER
149
-
150
- export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
151
- return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
152
- }
153
-
154
- export function getAreaUnitLabel(unit: LinearUnit): string {
155
- return unit === 'imperial' ? 'ft²' : 'm²'
156
- }
157
-
158
- export function formatAreaLabel(
159
- squareMeters: number,
160
- unit: LinearUnit,
161
- fractionDigits = 1,
162
- ): string {
163
- if (!Number.isFinite(squareMeters)) return '--'
164
-
165
- return `${formatNumber(squareMetersToAreaUnit(squareMeters, unit), {
166
- min: fractionDigits,
167
- max: fractionDigits,
168
- })}${getAreaUnitLabel(unit)}`
169
- }
170
-
171
- export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
172
- return unit === 'imperial' ? cubicMeters * CUBIC_FEET_PER_CUBIC_METER : cubicMeters
173
- }
174
-
175
- export function getVolumeUnitLabel(unit: LinearUnit): string {
176
- return unit === 'imperial' ? 'ft³' : 'm³'
177
- }
178
-
179
- export function formatVolumeLabel(
180
- cubicMeters: number,
181
- unit: LinearUnit,
182
- fractionDigits = 1,
183
- ): string {
184
- if (!Number.isFinite(cubicMeters)) return '--'
185
-
186
- return `${formatNumber(cubicMetersToVolumeUnit(cubicMeters, unit), {
187
- min: fractionDigits,
188
- max: fractionDigits,
189
- })}${getVolumeUnitLabel(unit)}`
190
- }
191
-
192
- export function formatLinearMeasurement(
193
- meters: number,
194
- unit: LinearUnit,
195
- metricNotation: MetricNotation = 'meters',
196
- ): string {
197
- if (!Number.isFinite(meters)) return '--'
198
-
199
- const absoluteMeters = Math.abs(meters)
200
-
201
- if (unit === 'imperial') {
202
- const feet = metersToLinearUnit(absoluteMeters, unit)
203
- let wholeFeet = Math.floor(feet)
204
- let inches = Math.round((feet - wholeFeet) * 12)
205
- if (inches === 12) {
206
- wholeFeet += 1
207
- inches = 0
208
- }
209
-
210
- const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
211
-
212
- return `${sign}${wholeFeet}'${inches}"`
213
- }
214
-
215
- if (metricNotation === 'millimeters') {
216
- const roundedMillimeters = Math.round(absoluteMeters * 1000)
217
- const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : ''
218
- return `${sign}${formatNumber(roundedMillimeters, { max: 0 })}mm`
219
- }
220
-
221
- const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
222
- const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
223
-
224
- return `${sign}${formatNumber(roundedMeters, { max: 2 })}m`
225
- }
@@ -0,0 +1,115 @@
1
+ import { beforeAll, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
6
+ isMoveLocked,
7
+ isNodeDuplicable,
8
+ nodeRegistry,
9
+ registerNode,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { z } from 'zod'
13
+ import { duplicateNodeAndPickUp } from '../components/editor/duplicate-node'
14
+ import { classifyParticipant } from '../components/editor/group-transform-shared'
15
+ import useEditor from '../store/use-editor'
16
+ import useInteractionScope from '../store/use-interaction-scope'
17
+ import { canDirectMoveNode } from './direct-manipulation'
18
+ import { createEditorApi } from './editor-api'
19
+ import { registryMoveDisabled } from './node-action-movement'
20
+ import { copySelectedNodesToEditorClipboard } from './scene-clipboard'
21
+
22
+ // Mirrors a lean-to post: a kind-level 2D mover plus per-node move and duplicate locks.
23
+ const KIND = 'move-lock-test'
24
+
25
+ beforeAll(() => {
26
+ if (nodeRegistry.has(KIND)) return
27
+ registerNode({
28
+ kind: KIND,
29
+ schemaVersion: 1,
30
+ schema: z.object({ type: z.literal(KIND) }) as never,
31
+ category: 'structure',
32
+ defaults: () => ({}),
33
+ capabilities: {
34
+ movable: {
35
+ axes: ['x', 'z'],
36
+ gridSnap: true,
37
+ override: ({ node }) =>
38
+ (node as { metadata?: { locked?: boolean } }).metadata?.locked
39
+ ? { axes: [], gridSnap: false }
40
+ : null,
41
+ },
42
+ duplicable: {
43
+ allow: ({ node }) => !(node as { metadata?: { locked?: boolean } }).metadata?.locked,
44
+ },
45
+ },
46
+ floorplanMoveTarget: () => ({
47
+ affectedIds: [],
48
+ apply() {},
49
+ canCommit: () => false,
50
+ commit() {},
51
+ }),
52
+ renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
53
+ } as AnyNodeDefinition)
54
+ })
55
+
56
+ function node(locked: boolean) {
57
+ return {
58
+ id: locked ? 'move_lock_locked' : 'move_lock_free',
59
+ type: KIND,
60
+ parentId: 'level_move_lock',
61
+ position: [1, 0, 2],
62
+ rotation: 0,
63
+ metadata: locked ? { locked: true } : {},
64
+ } as unknown as AnyNode
65
+ }
66
+
67
+ test('a per-node override lock outranks the kind-level 2D mover', () => {
68
+ expect(isMoveLocked(node(true))).toBe(true)
69
+ expect(isMoveLocked(node(false))).toBe(false)
70
+ expect(registryMoveDisabled(node(true))).toBe(true)
71
+ expect(registryMoveDisabled(node(false))).toBe(false)
72
+ expect(canDirectMoveNode(node(true))).toBe(false)
73
+ })
74
+
75
+ test('group pick-up leaves a locked node behind', () => {
76
+ const locked = node(true)
77
+ const free = node(false)
78
+ const nodes = { [locked.id]: locked, [free.id]: free }
79
+ expect(classifyParticipant(locked, 'level_move_lock', nodes)).toBeNull()
80
+ expect(classifyParticipant(free, 'level_move_lock', nodes)).toBe('scalar')
81
+ })
82
+
83
+ test('every moving-node entry refuses a locked node', () => {
84
+ const api = createEditorApi()
85
+ useEditor.getState().setMovingNode(node(true))
86
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
87
+ api.engageMoveDrag(node(true))
88
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
89
+ expect(useEditor.getState().placementDragMode).toBe(false)
90
+ api.engageMove(node(false))
91
+ expect(useInteractionScope.getState().scope.kind).toBe('moving')
92
+ useEditor.getState().setMovingNode(null)
93
+ })
94
+
95
+ test('a locked node cannot be duplicated or copied', () => {
96
+ const level = {
97
+ id: 'level_move_lock',
98
+ type: 'level',
99
+ parentId: null,
100
+ children: ['move_lock_locked', 'move_lock_free'],
101
+ } as unknown as AnyNode
102
+ const locked = node(true)
103
+ const free = node(false)
104
+ useScene.setState({
105
+ nodes: { [level.id]: level, [locked.id]: locked, [free.id]: free } as never,
106
+ rootNodeIds: [level.id as AnyNodeId],
107
+ })
108
+ expect(isNodeDuplicable(locked)).toBe(false)
109
+ expect(isNodeDuplicable(free)).toBe(true)
110
+ duplicateNodeAndPickUp(locked)
111
+ expect(Object.keys(useScene.getState().nodes)).toHaveLength(3)
112
+ expect(useInteractionScope.getState().scope.kind).toBe('idle')
113
+ expect(copySelectedNodesToEditorClipboard([locked.id as AnyNodeId])).toBe(false)
114
+ expect(copySelectedNodesToEditorClipboard([free.id as AnyNodeId])).toBe(true)
115
+ })
@@ -4,12 +4,14 @@ import {
4
4
  cloneNodesInto,
5
5
  collectSubtree,
6
6
  isMovable,
7
+ isMoveLocked,
7
8
  nodeRegistry,
8
9
  useScene,
9
10
  } from '@pascal-app/core'
10
11
  import { copyCreateOps } from './fresh-planar-placement'
11
12
 
12
13
  export function registryMoveDisabled(node: AnyNode): boolean {
14
+ if (isMoveLocked(node)) return true
13
15
  const def = nodeRegistry.get(node.type)
14
16
  return Boolean(
15
17
  def?.capabilities.movable &&
@@ -17,6 +17,10 @@ export function isFreshPlacementMetadata(metadata: unknown): boolean {
17
17
  return getPlacementMetadataRecord(metadata).isNew === true
18
18
  }
19
19
 
20
+ export function stripPlacementMetadataFlags(
21
+ metadata: Record<string, unknown>,
22
+ ): Record<string, unknown>
23
+ export function stripPlacementMetadataFlags(metadata: unknown): unknown
20
24
  export function stripPlacementMetadataFlags(metadata: unknown): unknown {
21
25
  if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
22
26
  return metadata
@@ -1,25 +1,23 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  area,
4
+ formatLinearMeasurement,
5
+ getAreaUnitLabel,
4
6
  getScaledDimensions,
5
7
  getWallCurveLength,
6
8
  getWallEffectiveHeightForNodes,
7
9
  getWallThickness,
8
10
  type ItemNode,
11
+ type LinearUnit,
12
+ type MetricNotation,
9
13
  resolveLevelId,
10
14
  type SlabNode,
11
15
  type StairNode,
16
+ squareMetersToAreaUnit,
12
17
  type WallNode,
13
18
  type WindowNode,
14
19
  type ZoneNode,
15
20
  } from '@pascal-app/core'
16
- import {
17
- formatLinearMeasurement,
18
- getAreaUnitLabel,
19
- type LinearUnit,
20
- type MetricNotation,
21
- squareMetersToAreaUnit,
22
- } from '../measurements'
23
21
  import type { PointKind, PointTarget } from './types'
24
22
 
25
23
  // Point and ask names what the finger is on (the owner, 8 October): the label chip and the
@@ -115,7 +113,7 @@ const roomOfWall = (nodes: Nodes, levelId: string, wallId: string) =>
115
113
 
116
114
  function wallHeight(wall: WallNode, nodes: Nodes): number {
117
115
  try {
118
- const height = getWallEffectiveHeightForNodes(wall, nodes as Record<string, AnyNode>)
116
+ const height = getWallEffectiveHeightForNodes(wall, nodes as Record<string, AnyNode>, 0)
119
117
  if (Number.isFinite(height) && height > 0) return height
120
118
  } catch {
121
119
  // No slab election to consult in a bare scene: the wall's own height stands.
@@ -1,4 +1,4 @@
1
- import { formatLinearMeasurement } from '../measurements'
1
+ import { formatLinearMeasurement } from '@pascal-app/core'
2
2
  import type { Rect, ScreenPoint } from './types'
3
3
 
4
4
  // A region is a view, not a selection (spec 2.3, "A region is a view, not a selection"): a
@@ -148,8 +148,19 @@ function preparedWallHeight(
148
148
  },
149
149
  }
150
150
  }
151
+ if (Math.abs(node.endHeightOffset ?? 0) > 1e-5) {
152
+ return {
153
+ height: null,
154
+ diagnostic: {
155
+ severity: 'error',
156
+ code: 'unsupported_wall_print_slope',
157
+ message: `Wall ${node.id} has a sloped top that does not yet have a canonical printable solid.`,
158
+ nodeIds: [node.id],
159
+ },
160
+ }
161
+ }
151
162
 
152
- const height = getWallEffectiveHeightForNodes(node, nodes)
163
+ const height = getWallEffectiveHeightForNodes(node, nodes, 0)
153
164
  if (!Number.isFinite(height) || height <= 1e-7) {
154
165
  return {
155
166
  height: null,
@@ -155,6 +155,11 @@ describe('buildPrintableWallSolids', () => {
155
155
  { effectiveHeight: 2.5 },
156
156
  { [shaped.id]: shaped },
157
157
  )
158
+ const sloped = buildPrintableWallSolids(
159
+ { ...wall, endHeightOffset: 1.0 },
160
+ { effectiveHeight: 2.5 },
161
+ { [shaped.id]: shaped },
162
+ )
158
163
  const terrain = buildPrintableWallSolids(
159
164
  { ...wall, children: [], fillToTerrain: true },
160
165
  { effectiveHeight: 2.5 },
@@ -172,6 +177,12 @@ describe('buildPrintableWallSolids', () => {
172
177
  diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_curve' })],
173
178
  }),
174
179
  )
180
+ expect(sloped).toEqual(
181
+ expect.objectContaining({
182
+ status: 'blocked',
183
+ diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_slope' })],
184
+ }),
185
+ )
175
186
  expect(terrain).toEqual(
176
187
  expect.objectContaining({
177
188
  status: 'blocked',
@@ -27,6 +27,7 @@ export type PrintWallSolidDiagnostic = {
27
27
  code:
28
28
  | 'invalid_wall_print_dimensions'
29
29
  | 'unsupported_wall_print_curve'
30
+ | 'unsupported_wall_print_slope'
30
31
  | 'unsupported_wall_print_terrain'
31
32
  | 'unsupported_wall_print_opening_shape'
32
33
  | 'invalid_wall_print_opening'
@@ -258,6 +259,14 @@ export function buildPrintableWallSolids(
258
259
  nodeIds: [node.id],
259
260
  })
260
261
  }
262
+ if (Math.abs(node.endHeightOffset ?? 0) > DIMENSION_EPSILON) {
263
+ diagnostics.push({
264
+ severity: 'error',
265
+ code: 'unsupported_wall_print_slope',
266
+ message: `Sloped wall ${node.id} does not yet have a canonical printable solid.`,
267
+ nodeIds: [node.id],
268
+ })
269
+ }
261
270
  if (node.fillToTerrain) {
262
271
  diagnostics.push({
263
272
  severity: 'error',
@@ -77,7 +77,7 @@ export function resolveRoomAssemblyHeights(
77
77
  const wall = nodes[id]
78
78
  if (wall?.type !== 'wall') continue
79
79
  const base = getWallBaseElevationForNodes(wall, sceneNodes)
80
- wallTops.set(id, getWallTop(wall, levelId, sceneNodes, base))
80
+ wallTops.set(id, getWallTop(wall, levelId, sceneNodes, base, 0.5))
81
81
  }
82
82
  const ceilingNode = room.ceilingId ? nodes[room.ceilingId] : undefined
83
83
  const ceiling =
@@ -8,6 +8,7 @@ import {
8
8
  generateId,
9
9
  initSpaceDetectionSync,
10
10
  LevelNode,
11
+ roomFloorElevation,
11
12
  SlabNode,
12
13
  type SlabNode as SlabNodeType,
13
14
  useScene,
@@ -24,11 +25,7 @@ import {
24
25
  setRoomRelativeFloorHeight,
25
26
  stepRoomFloorElevation,
26
27
  } from './room-construction-commands'
27
- import {
28
- commitRoomElevation,
29
- ownFloorElevationBounds,
30
- roomFloorElevation,
31
- } from './room-handle-drag'
28
+ import { commitRoomElevation, ownFloorElevationBounds } from './room-handle-drag'
32
29
  import { applyRoomPlan } from './room-structure-commands'
33
30
  import { showRoomNotice, useRoomTransform } from './room-transform-session'
34
31
 
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  roomFloorChoices as coreRoomFloorChoices,
4
+ levelFootprints,
4
5
  type PascalI18n,
5
6
  roomDrawnFloor,
6
7
  type SlabNode,
7
8
  type ZoneNode,
8
9
  } from '@pascal-app/core'
9
- import { levelFootprints } from './floor-footprints'
10
10
 
11
11
  // "Built on": which floor a room stands on. By default a room is part of its
12
12
  // connected footprint's shared floor (`floor.footprint` absent); a floor key
@@ -61,20 +61,6 @@ export function roomFloorChoices(nodes: Nodes, zoneId: string): PlateChoice[] {
61
61
  .map(({ key, plateId, name }) => ({ key, plateId, name }))
62
62
  }
63
63
 
64
- /**
65
- * The plate a room on a separate floor stands on alone: its height is that
66
- * plate's floor height (nothing else stands on it). Null for a room on the
67
- * shared floor or sharing its separate floor with other rooms.
68
- */
69
- export function roomOwnPlate(nodes: Nodes, zoneId: string): SlabNode | null {
70
- const zone = nodes[zoneId]
71
- if (!isRoom(zone) || !zone.floor?.footprint || zone.floor.support === 'open') return null
72
- const plate = listingPlate(nodes, zone)
73
- if (!plate) return null
74
- const alone = !(plate.zoneIds ?? []).some((id) => id !== zoneId && isGroundRoom(nodes[id]))
75
- return alone ? plate : null
76
- }
77
-
78
64
  /**
79
65
  * The shared floor beside a room on a separate floor, where it goes back to.
80
66
  * For a room on the shared floor, its own plate.
@@ -17,7 +17,9 @@ import {
17
17
  type NodeChange,
18
18
  planWallJustification,
19
19
  resolveCeilingHeight,
20
+ roomFloorElevation,
20
21
  roomFloorElevationFromRelative,
22
+ roomOwnPlate,
21
23
  roomSideFaces,
22
24
  runAsSingleSceneHistoryStep,
23
25
  type StructureConflict,
@@ -32,13 +34,12 @@ import useDeleteConfirmation, {
32
34
  } from '../store/use-delete-confirmation'
33
35
  import useEditor from '../store/use-editor'
34
36
  import { footprintHeightValue } from './floor-footprints'
35
- import { roomOwnPlate, roomSharedFootprint } from './room-built-on'
37
+ import { roomSharedFootprint } from './room-built-on'
36
38
  import { roomClearPolygon, roomConstructionState, roomFloorContents } from './room-construction'
37
39
  import {
38
40
  mezzanineElevationBounds,
39
41
  ownFloorElevationBounds,
40
42
  planRoomElevation,
41
- roomFloorElevation,
42
43
  } from './room-handle-drag'
43
44
  import { applyRoomPlan } from './room-structure-commands'
44
45
  import { showRoomNotice } from './room-transform-session'
@@ -9,6 +9,7 @@ import {
9
9
  generateId,
10
10
  initSpaceDetectionSync,
11
11
  LevelNode,
12
+ roomFloorElevation,
12
13
  structureChangeBatch,
13
14
  useLiveNodeOverrides,
14
15
  useScene,
@@ -33,7 +34,6 @@ import {
33
34
  getActiveRoomHandleDrag,
34
35
  planRoomPush,
35
36
  previewRoomPush,
36
- roomFloorElevation,
37
37
  roomPushHandles,
38
38
  runRoomHandleDrag,
39
39
  selectedWallDimensions,