@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
|
@@ -16,12 +16,15 @@ import {
|
|
|
16
16
|
type Point,
|
|
17
17
|
planWallDivision,
|
|
18
18
|
resolveMovedWallSupportSlabPatch,
|
|
19
|
+
roomFloorElevation,
|
|
20
|
+
roomOwnPlate,
|
|
19
21
|
runAsSingleSceneHistoryStep,
|
|
20
22
|
SEPARATOR_JOIN_REACH,
|
|
21
23
|
type SeparatorNode,
|
|
22
24
|
type SeparatorSpan,
|
|
23
25
|
type StructureNodes,
|
|
24
26
|
type StructurePlan,
|
|
27
|
+
separatorFloorElevation,
|
|
25
28
|
setRoomFloorConstruction,
|
|
26
29
|
setWallGeometry,
|
|
27
30
|
setZoneEdges,
|
|
@@ -46,7 +49,6 @@ import { clientToPlan } from './floorplan/plan-coords'
|
|
|
46
49
|
import { beginGesture } from './gesture-lifecycle'
|
|
47
50
|
import { isHistoryShortcut } from './history'
|
|
48
51
|
import { MEZZANINE_FAILED_MESSAGE, mezzanineConflictMessage } from './mezzanine-messages'
|
|
49
|
-
import { roomOwnPlate } from './room-built-on'
|
|
50
52
|
import {
|
|
51
53
|
mezzanineEdgeDimensions,
|
|
52
54
|
type RoomDimension,
|
|
@@ -146,19 +148,6 @@ export const useRoomHandleDrag = create<{ drag: RoomHandleDrag | null }>(() => (
|
|
|
146
148
|
|
|
147
149
|
type Nodes = Record<string, AnyNode>
|
|
148
150
|
|
|
149
|
-
/** The walking surface of a room: its own elevation, else its plate's. */
|
|
150
|
-
export function roomFloorElevation(nodes: Nodes, zoneId: string): number {
|
|
151
|
-
const zone = nodes[zoneId]
|
|
152
|
-
if (zone?.type !== 'zone') return 0
|
|
153
|
-
const own = roomOwnPlate(nodes, zoneId)
|
|
154
|
-
if (own) return own.elevation
|
|
155
|
-
if (typeof zone.floor?.elevation === 'number') return zone.floor.elevation
|
|
156
|
-
const plate = Object.values(nodes).find(
|
|
157
|
-
(node) => node.type === 'slab' && node.zoneIds?.includes(zoneId),
|
|
158
|
-
)
|
|
159
|
-
return plate?.type === 'slab' ? (plate.elevation ?? 0.05) : 0.05
|
|
160
|
-
}
|
|
161
|
-
|
|
162
151
|
/**
|
|
163
152
|
* The stretch of a wall a push moves: `[0, 1]` is the whole wall (its
|
|
164
153
|
* neighbours follow through the junction planner), anything shorter is split
|
|
@@ -206,7 +195,7 @@ function wallPushHandle(
|
|
|
206
195
|
const offsets = getWallFaceOffsets(wall)
|
|
207
196
|
const face = side === 'a' ? offsets.a : -offsets.b
|
|
208
197
|
const offset = Math.max(face + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
|
|
209
|
-
const wallHeight = getWallEffectiveHeightForNodes(wall, nodes)
|
|
198
|
+
const wallHeight = getWallEffectiveHeightForNodes(wall, nodes, 0.5)
|
|
210
199
|
return {
|
|
211
200
|
key,
|
|
212
201
|
wallId: wall.id,
|
|
@@ -396,14 +385,6 @@ export function separatorPushHandles(separator: SeparatorNode, floor = 0): WallP
|
|
|
396
385
|
})
|
|
397
386
|
}
|
|
398
387
|
|
|
399
|
-
/** The floor an invisible wall lies on: the walking surface of a room it closes, else 0. */
|
|
400
|
-
export function separatorFloorElevation(nodes: Nodes, separatorId: string): number {
|
|
401
|
-
const zone = Object.values(nodes).find(
|
|
402
|
-
(node) => node.type === 'zone' && node.boundarySeparatorIds.includes(separatorId),
|
|
403
|
-
)
|
|
404
|
-
return zone ? roomFloorElevation(nodes, zone.id) : 0
|
|
405
|
-
}
|
|
406
|
-
|
|
407
388
|
/**
|
|
408
389
|
* Snaps a push the way the wall move always has: the wall's reference line
|
|
409
390
|
* (not the travel) lands on the grid lattice along its normal, so an off-grid
|
|
@@ -642,7 +623,7 @@ function previewPushPlan(
|
|
|
642
623
|
start: change.node.start,
|
|
643
624
|
end: change.node.end,
|
|
644
625
|
thickness: change.node.thickness ?? 0.1,
|
|
645
|
-
height: getWallEffectiveHeightForNodes(change.node, nodes),
|
|
626
|
+
height: getWallEffectiveHeightForNodes(change.node, nodes, 0),
|
|
646
627
|
},
|
|
647
628
|
]
|
|
648
629
|
: []
|
|
@@ -1403,7 +1384,7 @@ export function beginWallGroupPush(wallIds: readonly string[], levelId: string):
|
|
|
1403
1384
|
{
|
|
1404
1385
|
wall,
|
|
1405
1386
|
base,
|
|
1406
|
-
top: base + getWallEffectiveHeightForNodes(wall, nodes),
|
|
1387
|
+
top: base + getWallEffectiveHeightForNodes(wall, nodes, 0),
|
|
1407
1388
|
normal: wallPushNormal(wall),
|
|
1408
1389
|
rooms: pushDimensionRooms(nodes, levelId, { wallId: wall.id, t0: 0, t1: 1 }, base),
|
|
1409
1390
|
},
|
|
@@ -2,11 +2,11 @@ import { describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
createRoomTopologyIndex,
|
|
5
|
+
formatLinearMeasurement,
|
|
5
6
|
LevelNode,
|
|
6
7
|
type Point,
|
|
7
8
|
WallNode,
|
|
8
9
|
} from '@pascal-app/core'
|
|
9
|
-
import { formatLinearMeasurement } from './measurements'
|
|
10
10
|
import {
|
|
11
11
|
DIMENSION_FLOOR_LIFT,
|
|
12
12
|
mezzanineEdgeDimensions,
|
|
@@ -172,6 +172,150 @@ describe('computeSceneBoundsXZ', () => {
|
|
|
172
172
|
expect(bounds!.center).toEqual([2, 2])
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
+
describe('ancestor transforms', () => {
|
|
176
|
+
function makeBuilding(
|
|
177
|
+
id: string,
|
|
178
|
+
position: [number, number, number],
|
|
179
|
+
rotationY = 0,
|
|
180
|
+
parentId: string | null = null,
|
|
181
|
+
): AnyNode {
|
|
182
|
+
return {
|
|
183
|
+
object: 'node',
|
|
184
|
+
id,
|
|
185
|
+
type: 'building',
|
|
186
|
+
parentId,
|
|
187
|
+
visible: true,
|
|
188
|
+
metadata: {},
|
|
189
|
+
children: [],
|
|
190
|
+
position,
|
|
191
|
+
rotation: [0, rotationY, 0],
|
|
192
|
+
} as unknown as AnyNode
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function makeLevel(id: string, parentId: string): AnyNode {
|
|
196
|
+
return {
|
|
197
|
+
object: 'node',
|
|
198
|
+
id,
|
|
199
|
+
type: 'level',
|
|
200
|
+
parentId,
|
|
201
|
+
visible: true,
|
|
202
|
+
metadata: {},
|
|
203
|
+
children: [],
|
|
204
|
+
level: 0,
|
|
205
|
+
} as unknown as AnyNode
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function child(node: AnyNode, parentId: string): AnyNode {
|
|
209
|
+
return { ...node, parentId } as AnyNode
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function expectClose(actual: [number, number], expected: [number, number]) {
|
|
213
|
+
expect(actual[0]).toBeCloseTo(expected[0], 9)
|
|
214
|
+
expect(actual[1]).toBeCloseTo(expected[1], 9)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
test('carries building-local walls and zones through a translated building', () => {
|
|
218
|
+
const nodes = [
|
|
219
|
+
makeBuilding('building_a', [100, 0, 100]),
|
|
220
|
+
makeLevel('level_a', 'building_a'),
|
|
221
|
+
child(makeWall([-5, -4], [5, -4]), 'level_a'),
|
|
222
|
+
child(
|
|
223
|
+
makeZone([
|
|
224
|
+
[-5, -4],
|
|
225
|
+
[5, -4],
|
|
226
|
+
[5, 4],
|
|
227
|
+
[-5, 4],
|
|
228
|
+
]),
|
|
229
|
+
'level_a',
|
|
230
|
+
),
|
|
231
|
+
]
|
|
232
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
233
|
+
expectClose(bounds.min, [95, 96])
|
|
234
|
+
expectClose(bounds.max, [105, 104])
|
|
235
|
+
expectClose(bounds.center, [100, 100])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('turns building-local geometry by the building yaw', () => {
|
|
239
|
+
// three.js Y rotation by +90°: local (x, z) → (z, −x).
|
|
240
|
+
const nodes = [
|
|
241
|
+
makeBuilding('building_r', [10, 0, 0], Math.PI / 2),
|
|
242
|
+
makeLevel('level_r', 'building_r'),
|
|
243
|
+
child(makeWall([0, 0], [8, 0]), 'level_r'),
|
|
244
|
+
child(makeWall([8, 0], [8, 2]), 'level_r'),
|
|
245
|
+
]
|
|
246
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
247
|
+
expectClose(bounds.min, [10, -8])
|
|
248
|
+
expectClose(bounds.max, [12, 0])
|
|
249
|
+
expectClose(bounds.size, [2, 8])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('composes nested positioned and rotated ancestors', () => {
|
|
253
|
+
const item = {
|
|
254
|
+
object: 'node',
|
|
255
|
+
id: 'item_host',
|
|
256
|
+
type: 'item',
|
|
257
|
+
parentId: 'level_n',
|
|
258
|
+
visible: true,
|
|
259
|
+
metadata: {},
|
|
260
|
+
children: [],
|
|
261
|
+
position: [2, 0, 0],
|
|
262
|
+
rotation: [0, Math.PI / 2, 0],
|
|
263
|
+
} as unknown as AnyNode
|
|
264
|
+
const hosted = {
|
|
265
|
+
object: 'node',
|
|
266
|
+
id: 'item_hosted',
|
|
267
|
+
type: 'item',
|
|
268
|
+
parentId: 'item_host',
|
|
269
|
+
visible: true,
|
|
270
|
+
metadata: {},
|
|
271
|
+
children: [],
|
|
272
|
+
position: [1, 0, 0],
|
|
273
|
+
rotation: [0, 0, 0],
|
|
274
|
+
} as unknown as AnyNode
|
|
275
|
+
// Building at (50, 50) turned 90°: the host lands at (50, 48), and the
|
|
276
|
+
// hosted item (1, 0) under a total 180° yaw lands one metre further −x.
|
|
277
|
+
const nodes = [
|
|
278
|
+
makeBuilding('building_n', [50, 0, 50], Math.PI / 2),
|
|
279
|
+
makeLevel('level_n', 'building_n'),
|
|
280
|
+
item,
|
|
281
|
+
hosted,
|
|
282
|
+
]
|
|
283
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
284
|
+
expectClose(bounds.min, [49, 48])
|
|
285
|
+
expectClose(bounds.max, [50, 50])
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('leaves geometry under an untransformed building unchanged', () => {
|
|
289
|
+
const nodes = [
|
|
290
|
+
makeBuilding('building_0', [0, 0, 0]),
|
|
291
|
+
makeLevel('level_0', 'building_0'),
|
|
292
|
+
child(makeWall([0, 0], [4, 0]), 'level_0'),
|
|
293
|
+
child(makeWall([4, 0], [4, 3]), 'level_0'),
|
|
294
|
+
]
|
|
295
|
+
const bounds = computeSceneBoundsXZ(nodes)!
|
|
296
|
+
expect(bounds.min).toEqual([0, 0])
|
|
297
|
+
expect(bounds.max).toEqual([4, 3])
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('a door on a wall is bounded by the wall, not its wall-local position', () => {
|
|
302
|
+
const wall = makeWall([50, 50], [60, 50])
|
|
303
|
+
const door = {
|
|
304
|
+
object: 'node',
|
|
305
|
+
id: 'door_1',
|
|
306
|
+
type: 'door',
|
|
307
|
+
parentId: wall.id,
|
|
308
|
+
visible: true,
|
|
309
|
+
metadata: {},
|
|
310
|
+
position: [2, 1, 0],
|
|
311
|
+
} as unknown as AnyNode
|
|
312
|
+
const bounds = computeSceneBoundsXZ([wall, door])
|
|
313
|
+
expect(bounds).not.toBeNull()
|
|
314
|
+
expect(bounds!.min[0]).toBe(50)
|
|
315
|
+
expect(bounds!.max[0]).toBe(60)
|
|
316
|
+
expect(bounds!.center).toEqual([55, 50])
|
|
317
|
+
})
|
|
318
|
+
|
|
175
319
|
test('skips non-finite coordinates', () => {
|
|
176
320
|
const nodes: AnyNode[] = [makeWall([Number.NaN, 0], [4, 2]), makeWall([0, 0], [1, 1])]
|
|
177
321
|
const bounds = computeSceneBoundsXZ(nodes)
|
package/src/lib/scene-bounds.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* tree and derives an axis-aligned bounding box on the XZ (plan) plane.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { turnPlanPoint, yawOf } from '@pascal-app/core'
|
|
14
15
|
import type { AnyNode } from '@pascal-app/core/schema'
|
|
15
16
|
|
|
16
17
|
export type SceneBoundsXZ = {
|
|
@@ -27,13 +28,19 @@ export type SceneBoundsXZ = {
|
|
|
27
28
|
// A very small guard against degenerate bounds (e.g. a single wall of zero length).
|
|
28
29
|
const MIN_BOUNDS_EXTENT = 0.0001
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
/** A node's plan frame in world XZ: local (x, z) → origin + turnPlanPoint(x, z, yaw). */
|
|
32
|
+
type PlanFrame = { x: number; z: number; yaw: number }
|
|
33
|
+
|
|
34
|
+
const WORLD_FRAME: PlanFrame = { x: 0, z: 0, yaw: 0 }
|
|
35
|
+
|
|
36
|
+
type BoundsAcc = { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean }
|
|
37
|
+
|
|
38
|
+
function extendPoint(acc: BoundsAcc, frame: PlanFrame, localX: unknown, localZ: unknown): void {
|
|
39
|
+
if (typeof localX !== 'number' || typeof localZ !== 'number') return
|
|
40
|
+
if (!(Number.isFinite(localX) && Number.isFinite(localZ))) return
|
|
41
|
+
const [tx, tz] = frame.yaw === 0 ? [localX, localZ] : turnPlanPoint(localX, localZ, frame.yaw)
|
|
42
|
+
const x = frame.x + tx
|
|
43
|
+
const z = frame.z + tz
|
|
37
44
|
if (x < acc.minX) acc.minX = x
|
|
38
45
|
if (x > acc.maxX) acc.maxX = x
|
|
39
46
|
if (z < acc.minZ) acc.minZ = z
|
|
@@ -50,6 +57,9 @@ function extendPoint(
|
|
|
50
57
|
* - `polygon` → zone, slab, site-boundary polygons.
|
|
51
58
|
* - `position` → building/item/door/window position; uses [x, z] only.
|
|
52
59
|
*
|
|
60
|
+
* Each point is in its parent's frame, so it is carried to world XZ through
|
|
61
|
+
* the ancestors' `position` and `rotation` yaw (a moved or turned building).
|
|
62
|
+
*
|
|
53
63
|
* Site-node polygons are intentionally excluded when they are the default
|
|
54
64
|
* 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame
|
|
55
65
|
* an empty square around the origin. We still include site polygons that
|
|
@@ -63,7 +73,7 @@ export function computeSceneBoundsXZ(
|
|
|
63
73
|
const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes)
|
|
64
74
|
if (list.length === 0) return null
|
|
65
75
|
|
|
66
|
-
const acc = {
|
|
76
|
+
const acc: BoundsAcc = {
|
|
67
77
|
minX: Number.POSITIVE_INFINITY,
|
|
68
78
|
minZ: Number.POSITIVE_INFINITY,
|
|
69
79
|
maxX: Number.NEGATIVE_INFINITY,
|
|
@@ -71,15 +81,24 @@ export function computeSceneBoundsXZ(
|
|
|
71
81
|
hasPoint: false,
|
|
72
82
|
}
|
|
73
83
|
|
|
84
|
+
const byId = new Map<string, AnyNode>()
|
|
85
|
+
for (const node of list) {
|
|
86
|
+
if (node && typeof node === 'object' && typeof node.id === 'string') byId.set(node.id, node)
|
|
87
|
+
}
|
|
88
|
+
const frameOf = createFrameResolver(byId)
|
|
89
|
+
|
|
74
90
|
for (const node of list) {
|
|
75
91
|
if (!node || typeof node !== 'object') continue
|
|
76
92
|
const anyNode = node as unknown as Record<string, unknown>
|
|
93
|
+
// A node's own fields live in its parent's frame (a building's walls are
|
|
94
|
+
// building-local), so every point goes through the ancestors' transforms.
|
|
95
|
+
const frame = frameOf(node.parentId ?? null)
|
|
77
96
|
|
|
78
97
|
// Wall / fence endpoints in level coordinates.
|
|
79
98
|
const start = anyNode.start as unknown
|
|
80
99
|
const end = anyNode.end as unknown
|
|
81
|
-
if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1])
|
|
82
|
-
if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1])
|
|
100
|
+
if (Array.isArray(start) && start.length >= 2) extendPoint(acc, frame, start[0], start[1])
|
|
101
|
+
if (Array.isArray(end) && end.length >= 2) extendPoint(acc, frame, end[0], end[1])
|
|
83
102
|
|
|
84
103
|
// Zone / slab polygons (and explicit polygon-shaped site boundaries).
|
|
85
104
|
const polygon = anyNode.polygon as unknown
|
|
@@ -88,7 +107,7 @@ export function computeSceneBoundsXZ(
|
|
|
88
107
|
// points under `polygon.points` (a discriminated PropertyLineData shape).
|
|
89
108
|
for (const point of polygon) {
|
|
90
109
|
if (Array.isArray(point) && point.length >= 2) {
|
|
91
|
-
extendPoint(acc, point[0], point[1])
|
|
110
|
+
extendPoint(acc, frame, point[0], point[1])
|
|
92
111
|
}
|
|
93
112
|
}
|
|
94
113
|
} else if (
|
|
@@ -105,16 +124,18 @@ export function computeSceneBoundsXZ(
|
|
|
105
124
|
} else {
|
|
106
125
|
for (const point of points) {
|
|
107
126
|
if (Array.isArray(point) && point.length >= 2) {
|
|
108
|
-
extendPoint(acc, point[0], point[1])
|
|
127
|
+
extendPoint(acc, frame, point[0], point[1])
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
130
|
}
|
|
112
131
|
}
|
|
113
132
|
|
|
114
|
-
// Position on the XZ plane (3D position = [x, y, z]).
|
|
133
|
+
// Position on the XZ plane (3D position = [x, y, z]). A node hosted on a
|
|
134
|
+
// wall (door, window, wall item) has a wall-local position, and the
|
|
135
|
+
// wall's endpoints already bound it.
|
|
115
136
|
const position = anyNode.position as unknown
|
|
116
|
-
if (Array.isArray(position) && position.length >= 3) {
|
|
117
|
-
extendPoint(acc, position[0], position[2])
|
|
137
|
+
if (Array.isArray(position) && position.length >= 3 && !isWallHosted(node, byId)) {
|
|
138
|
+
extendPoint(acc, frame, position[0], position[2])
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
|
|
@@ -146,6 +167,49 @@ export function computeSceneBoundsXZ(
|
|
|
146
167
|
}
|
|
147
168
|
}
|
|
148
169
|
|
|
170
|
+
/**
|
|
171
|
+
* World plan frame of each node, composed down the parent chain the way the
|
|
172
|
+
* renderers nest groups: a node with a `position` (building, item) translates
|
|
173
|
+
* its children and turns them by its `rotation` yaw; nodes without one (site,
|
|
174
|
+
* level) pass their parent's frame through.
|
|
175
|
+
*/
|
|
176
|
+
function createFrameResolver(byId: Map<string, AnyNode>): (id: string | null) => PlanFrame {
|
|
177
|
+
const cache = new Map<string, PlanFrame>()
|
|
178
|
+
const resolving = new Set<string>()
|
|
179
|
+
|
|
180
|
+
const resolve = (id: string | null): PlanFrame => {
|
|
181
|
+
if (!id) return WORLD_FRAME
|
|
182
|
+
const cached = cache.get(id)
|
|
183
|
+
if (cached) return cached
|
|
184
|
+
const node = byId.get(id)
|
|
185
|
+
if (!node || resolving.has(id)) return WORLD_FRAME
|
|
186
|
+
resolving.add(id)
|
|
187
|
+
const parent = resolve(node.parentId ?? null)
|
|
188
|
+
resolving.delete(id)
|
|
189
|
+
|
|
190
|
+
const anyNode = node as unknown as Record<string, unknown>
|
|
191
|
+
const position = anyNode.position
|
|
192
|
+
let frame = parent
|
|
193
|
+
if (Array.isArray(position) && position.length >= 3) {
|
|
194
|
+
const px = Number.isFinite(position[0]) ? (position[0] as number) : 0
|
|
195
|
+
const pz = Number.isFinite(position[2]) ? (position[2] as number) : 0
|
|
196
|
+
const [tx, tz] = turnPlanPoint(px, pz, parent.yaw)
|
|
197
|
+
frame = { x: parent.x + tx, z: parent.z + tz, yaw: parent.yaw + yawOf(anyNode.rotation) }
|
|
198
|
+
}
|
|
199
|
+
cache.set(id, frame)
|
|
200
|
+
return frame
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return resolve
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isWallHosted(node: AnyNode, byId: Map<string, AnyNode>): boolean {
|
|
207
|
+
const parent = node.parentId ? byId.get(node.parentId) : undefined
|
|
208
|
+
if (!parent) return false
|
|
209
|
+
const anyParent = parent as unknown as Record<string, unknown>
|
|
210
|
+
return Array.isArray(anyParent.start) && Array.isArray(anyParent.end)
|
|
211
|
+
}
|
|
212
|
+
|
|
149
213
|
/**
|
|
150
214
|
* Matches the `SiteNode` bootstrap polygon defined in
|
|
151
215
|
* `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin).
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
generateSceneMaterialId,
|
|
7
7
|
getArtifactStore,
|
|
8
8
|
isDerivedNode,
|
|
9
|
+
isNodeDuplicable,
|
|
9
10
|
isStoreyContent,
|
|
10
11
|
type LevelNode,
|
|
11
12
|
nodeRegistry,
|
|
@@ -156,7 +157,7 @@ function isClipboardRoot(
|
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
function isCopyableRootType(node: AnyNode) {
|
|
159
|
-
if (isDerivedNode(node)) return false
|
|
160
|
+
if (isDerivedNode(node) || !isNodeDuplicable(node)) return false
|
|
160
161
|
if (COPYABLE_ROOT_TYPES.has(node.type)) return true
|
|
161
162
|
const definition = nodeRegistry.get(node.type)
|
|
162
163
|
return !!definition && definition.capabilities?.duplicable !== false
|
|
@@ -106,6 +106,8 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
106
106
|
roof: 'structural',
|
|
107
107
|
zone: 'structural',
|
|
108
108
|
block: 'structural',
|
|
109
|
+
door: 'item',
|
|
110
|
+
window: 'item',
|
|
109
111
|
}
|
|
110
112
|
const profileOf = (t: string) => declared[t]
|
|
111
113
|
const profileOfNode = (id: string) =>
|
|
@@ -173,7 +175,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
173
175
|
})
|
|
174
176
|
|
|
175
177
|
it('an undeclared kind (no snapProfile) gets no snap context', () => {
|
|
176
|
-
expect(ctx({ kind: 'moving', nodeType: '
|
|
178
|
+
expect(ctx({ kind: 'moving', nodeType: 'unknown_plugin_kind' })).toBeNull()
|
|
177
179
|
expect(ctx({ kind: 'idle' }, 'build', 'shelf')).toBeNull()
|
|
178
180
|
})
|
|
179
181
|
|
|
@@ -205,4 +207,24 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
205
207
|
}),
|
|
206
208
|
).toBe('polygon')
|
|
207
209
|
})
|
|
210
|
+
it('moving scope resolves active snap context when moving a window or door', () => {
|
|
211
|
+
expect(
|
|
212
|
+
snapContextOf({
|
|
213
|
+
scope: { kind: 'moving', nodeType: 'window' },
|
|
214
|
+
mode: 'select',
|
|
215
|
+
tool: null,
|
|
216
|
+
profileOf,
|
|
217
|
+
profileOfNode,
|
|
218
|
+
}),
|
|
219
|
+
).toBe('item')
|
|
220
|
+
expect(
|
|
221
|
+
snapContextOf({
|
|
222
|
+
scope: { kind: 'moving', nodeType: 'door' },
|
|
223
|
+
mode: 'select',
|
|
224
|
+
tool: null,
|
|
225
|
+
profileOf,
|
|
226
|
+
profileOfNode,
|
|
227
|
+
}),
|
|
228
|
+
).toBe('item')
|
|
229
|
+
})
|
|
208
230
|
})
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type LevelNode,
|
|
12
12
|
type NodeEvent,
|
|
13
13
|
resolveSelectionProxyId,
|
|
14
|
+
roomFloorElevation,
|
|
14
15
|
sceneRegistry,
|
|
15
16
|
type UnitNode,
|
|
16
17
|
type ZoneNode,
|
|
@@ -29,7 +30,6 @@ import {
|
|
|
29
30
|
} from 'three'
|
|
30
31
|
import { useBelowGrade } from './below-grade'
|
|
31
32
|
import { nodeDisplayLabel } from './node-display-label'
|
|
32
|
-
import { roomFloorElevation } from './room-handle-drag'
|
|
33
33
|
import { type DescriptorTranslator, displayRoomName } from './room-name-messages'
|
|
34
34
|
import { RoomSelectionIndex, type RoomSelectionRecord, resolveRoomHit } from './room-selection'
|
|
35
35
|
import type { SelectionModifierKeys } from './selection-routing'
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
3
|
getCatalogMaterialById,
|
|
4
|
+
getLinearUnitLabel,
|
|
4
5
|
getMaterialSwatch,
|
|
6
|
+
type LinearUnit,
|
|
7
|
+
metersToLinearUnit,
|
|
5
8
|
parseMaterialRef,
|
|
6
9
|
roomSideFaces,
|
|
7
10
|
type SceneMaterial,
|
|
@@ -11,7 +14,6 @@ import {
|
|
|
11
14
|
type WallFaceRegion,
|
|
12
15
|
type WallNode,
|
|
13
16
|
} from '@pascal-app/core'
|
|
14
|
-
import { getLinearUnitLabel, type LinearUnit, metersToLinearUnit } from './measurements'
|
|
15
17
|
|
|
16
18
|
// Rows for the wall panel's "Paint regions" list: finish swatch + name, the
|
|
17
19
|
// face in room terms when the wall has a room on exactly one side, and the
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
-
import { useInteractive, useScene, WindowNode } from '@pascal-app/core'
|
|
3
2
|
import {
|
|
4
3
|
getDisplayedWindowShutterValue,
|
|
5
4
|
getDisplayedWindowValue,
|
|
6
5
|
openWindowOpenState,
|
|
7
6
|
setWindowShuttersOpenState,
|
|
8
|
-
|
|
7
|
+
useInteractive,
|
|
8
|
+
useScene,
|
|
9
|
+
WindowNode,
|
|
10
|
+
} from '@pascal-app/core'
|
|
9
11
|
|
|
10
12
|
const originalScene = useScene.getState()
|
|
11
13
|
const originalInteractive = useInteractive.getState()
|
package/src/store/use-editor.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ChimneyMaterialRole,
|
|
11
11
|
DEFAULT_BRUSH_SETTINGS,
|
|
12
12
|
type DormerSurfaceMaterialRole,
|
|
13
|
+
isMoveLocked,
|
|
13
14
|
type LevelNode,
|
|
14
15
|
nodeRegistry,
|
|
15
16
|
type RoofSurfaceMaterialRole,
|
|
@@ -1256,6 +1257,10 @@ const useEditor = create<EditorState>()(
|
|
|
1256
1257
|
set({ placementDragMode: false })
|
|
1257
1258
|
return
|
|
1258
1259
|
}
|
|
1260
|
+
if (isMoveLocked(node)) {
|
|
1261
|
+
set({ placementDragMode: false })
|
|
1262
|
+
return
|
|
1263
|
+
}
|
|
1259
1264
|
const targetNode = node
|
|
1260
1265
|
const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew)
|
|
1261
1266
|
if (isNew) {
|