@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.
- package/package.json +7 -7
- package/src/components/editor/custom-camera-controls.tsx +90 -34
- package/src/components/editor/duplicate-node.ts +2 -1
- package/src/components/editor/first-person-controls.tsx +5 -7
- package/src/components/editor/fit-scene-framing.test.ts +318 -0
- package/src/components/editor/fit-scene-framing.ts +142 -0
- package/src/components/editor/floating-action-menu.tsx +4 -2
- package/src/components/editor/floor-openings-overlay.tsx +1 -1
- package/src/components/editor/floor-region-controls.tsx +2 -2
- package/src/components/editor/floorplan-panel.tsx +173 -8
- package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
- package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
- package/src/components/editor/footprint-height-handle.tsx +1 -1
- package/src/components/editor/group-transform-shared.ts +3 -1
- package/src/components/editor/index.tsx +20 -0
- package/src/components/editor/measurement-pill.tsx +1 -1
- package/src/components/editor/node-arrow-handles.tsx +1 -1
- package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
- package/src/components/editor/open-wall-ends-hint.tsx +1 -1
- package/src/components/editor/quick-measurement-card.tsx +1 -1
- package/src/components/editor/room-controls.tsx +3 -2
- package/src/components/editor/room-handles-2d.tsx +2 -1
- package/src/components/editor/room-handles.tsx +3 -3
- package/src/components/editor/room-labels.tsx +4 -2
- package/src/components/editor/site-edge-labels.tsx +6 -2
- package/src/components/editor/wall-measurement-label.tsx +30 -14
- package/src/components/editor/wall-move-side-handles.tsx +10 -6
- package/src/components/editor/wall-region-handles.tsx +2 -2
- package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
- package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
- package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
- package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
- package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
- package/src/components/systems/roof/roof-edit-system.tsx +30 -19
- package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
- package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
- package/src/components/tools/item/move-tool.tsx +1 -6
- package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
- package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
- package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
- package/src/components/tools/shared/placement-box.tsx +1 -5
- package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
- package/src/components/tools/shared/segment-angle.ts +0 -4
- package/src/components/tools/tool-manager.tsx +1 -24
- package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
- package/src/components/tools/wall/wall-typed-entry.ts +70 -0
- package/src/components/ui/controls/slider-control.tsx +18 -6
- package/src/components/ui/controls/tool-options-panel.tsx +74 -44
- package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
- package/src/components/ui/helpers/helper-manager.tsx +54 -0
- package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
- package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
- package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
- package/src/components/ui/panels/multi-height-mode.tsx +3 -3
- package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
- package/src/components/ui/panels/panel-manager.tsx +4 -2
- package/src/components/ui/panels/panel-wrapper.tsx +41 -18
- package/src/components/ui/panels/room-construction-rows.tsx +1 -1
- package/src/components/ui/panels/room-openings-field.tsx +1 -1
- package/src/components/ui/panels/room-panel.tsx +1 -1
- package/src/components/ui/right-stack.test.ts +13 -6
- package/src/components/ui/right-stack.tsx +21 -10
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
- package/src/components/viewer/viewer-rooms.tsx +1 -1
- package/src/i18n/locales/en/messages.po +856 -124
- package/src/i18n/locales/en/messages.ts +1 -1
- package/src/i18n/locales/en-XA/messages.ts +1 -1
- package/src/i18n/locales/es/messages.po +2087 -257
- package/src/i18n/locales/fr/messages.po +2087 -257
- package/src/i18n/locales/ja/messages.po +2087 -257
- package/src/i18n/locales/zh-CN/messages.po +2087 -257
- package/src/index.tsx +74 -47
- package/src/lib/contextual-help.ts +6 -0
- package/src/lib/elevation-guides.ts +21 -6
- package/src/lib/elevator-support.ts +1 -72
- package/src/lib/floor-footprints.test.ts +1 -1
- package/src/lib/floor-footprints.ts +1 -9
- package/src/lib/floor-foundation-support.test.ts +8 -3
- package/src/lib/floor-opening-draft.ts +1 -1
- package/src/lib/floor-region-session.ts +1 -1
- package/src/lib/floorplan/open-wall-ends.ts +2 -1
- package/src/lib/gesture-lifecycle.ts +3 -1
- package/src/lib/interaction/scope.ts +1 -0
- package/src/lib/layer-thickness.ts +1 -1
- package/src/lib/level-selection.test.ts +18 -2
- package/src/lib/level-selection.ts +31 -8
- package/src/lib/linear-display.ts +1 -1
- package/src/lib/live-gesture-round-trip.test.ts +1 -1
- package/src/lib/measurement-label.test.ts +1 -1
- package/src/lib/measurements.test.ts +8 -7
- package/src/lib/measurements.ts +1 -218
- package/src/lib/node-action-movement.test.tsx +115 -0
- package/src/lib/node-action-movement.ts +2 -0
- package/src/lib/placement-metadata.ts +4 -0
- package/src/lib/point-ask/describe-target.ts +6 -8
- package/src/lib/point-ask/region.ts +1 -1
- package/src/lib/print-shell-compiler.ts +12 -1
- package/src/lib/print-wall-solids.test.ts +11 -0
- package/src/lib/print-wall-solids.ts +9 -0
- package/src/lib/room-assembly-overlay.ts +1 -1
- package/src/lib/room-built-on.test.ts +2 -5
- package/src/lib/room-built-on.ts +1 -15
- package/src/lib/room-construction-commands.ts +3 -2
- package/src/lib/room-controls-phase6.test.ts +1 -1
- package/src/lib/room-handle-drag.ts +6 -25
- package/src/lib/room-push-dimensions.test.ts +1 -1
- package/src/lib/scene-bounds.test.ts +144 -0
- package/src/lib/scene-bounds.ts +79 -15
- package/src/lib/scene-clipboard.ts +2 -1
- package/src/lib/snapping-mode.test.ts +23 -1
- package/src/lib/viewer-selection.ts +1 -1
- package/src/lib/wall-region-list.ts +3 -1
- package/src/lib/window-interaction.test.ts +4 -2
- package/src/store/use-editor.tsx +5 -0
- package/src/store/use-placement-typing.test.ts +165 -0
- package/src/store/use-placement-typing.ts +186 -0
- package/src/components/tools/elevator/elevator-defaults.ts +0 -8
- package/src/components/tools/elevator/elevator-tool.tsx +0 -372
- package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
- package/src/lib/door-interaction.ts +0 -102
- package/src/lib/measurement-label.ts +0 -68
- 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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', () => {
|
package/src/lib/measurements.ts
CHANGED
|
@@ -1,225 +1,8 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
@@ -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
|
|
package/src/lib/room-built-on.ts
CHANGED
|
@@ -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 {
|
|
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,
|