@pascal-app/editor 1.0.1 → 1.0.3
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 +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/slider-control.tsx +76 -31
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/parametric-inspector.tsx +3 -15
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +42 -29
- package/src/index.tsx +63 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-interaction-scope.ts +155 -6
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeDefinition,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
CeilingNode,
|
|
7
|
+
LevelNode,
|
|
8
|
+
nodeRegistry,
|
|
9
|
+
registerNode,
|
|
10
|
+
SlabNode,
|
|
11
|
+
sceneRegistry,
|
|
12
|
+
useScene,
|
|
13
|
+
WallNode,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
import { BoxGeometry, Group, Mesh } from 'three'
|
|
3
16
|
import { z } from 'zod'
|
|
4
17
|
import {
|
|
5
18
|
classifyParticipant,
|
|
6
19
|
collectParticipants,
|
|
20
|
+
computeGroupPlanBox,
|
|
21
|
+
groupPlanBounds,
|
|
7
22
|
planBoundsCenter,
|
|
8
23
|
rotateGroupPatches,
|
|
9
24
|
rotateGroupSnapshots,
|
|
@@ -472,3 +487,113 @@ describe('group transform participants', () => {
|
|
|
472
487
|
])
|
|
473
488
|
})
|
|
474
489
|
})
|
|
490
|
+
|
|
491
|
+
describe('group plan bounds', () => {
|
|
492
|
+
const withNodes = (nodes: AnyNode[], run: () => void) => {
|
|
493
|
+
const saved = useScene.getState().nodes
|
|
494
|
+
useScene.setState({ nodes: Object.fromEntries(nodes.map((n) => [n.id, n])) as typeof saved })
|
|
495
|
+
try {
|
|
496
|
+
run()
|
|
497
|
+
} finally {
|
|
498
|
+
useScene.setState({ nodes: saved })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
test('a placed object measures its built mesh in the level frame, or falls back to its anchor', () => {
|
|
503
|
+
const level = new Group()
|
|
504
|
+
level.position.set(10, 0, -4)
|
|
505
|
+
level.rotation.y = Math.PI / 5
|
|
506
|
+
const item = new Mesh(new BoxGeometry(1, 1, 1))
|
|
507
|
+
item.position.set(2, 0.5, 0)
|
|
508
|
+
level.add(item)
|
|
509
|
+
level.updateWorldMatrix(true, true)
|
|
510
|
+
const registry = sceneRegistry.nodes as unknown as Map<string, unknown>
|
|
511
|
+
registry.set('item_plan_bounds_test', item)
|
|
512
|
+
const start = {
|
|
513
|
+
id: 'item_plan_bounds_test' as AnyNodeId,
|
|
514
|
+
kind: 'vec3' as const,
|
|
515
|
+
position: [2, 0, 0] as [number, number, number],
|
|
516
|
+
rotation: [0, 0, 0] as [number, number, number],
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
const frameInv = level.matrixWorld.clone().invert()
|
|
520
|
+
const built = groupPlanBounds([start], frameInv)
|
|
521
|
+
expect(built?.minX).toBeCloseTo(1.5)
|
|
522
|
+
expect(built?.maxX).toBeCloseTo(2.5)
|
|
523
|
+
expect(built?.minZ).toBeCloseTo(-0.5)
|
|
524
|
+
expect(built?.maxZ).toBeCloseTo(0.5)
|
|
525
|
+
// Unbuilt while the 3D scene is paused: the placeholder must not count.
|
|
526
|
+
item.geometry.userData.placeholder = true
|
|
527
|
+
expect(groupPlanBounds([start], frameInv)).toEqual({ minX: 2, minZ: 0, maxX: 2, maxZ: 0 })
|
|
528
|
+
} finally {
|
|
529
|
+
registry.delete('item_plan_bounds_test')
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test('hugs the selected wall outlines; a connected wall stays outside', () => {
|
|
534
|
+
const level = LevelNode.parse({ children: [] })
|
|
535
|
+
const bottom = WallNode.parse({
|
|
536
|
+
parentId: level.id,
|
|
537
|
+
start: [0, 0],
|
|
538
|
+
end: [2.4, 0],
|
|
539
|
+
thickness: 0.2,
|
|
540
|
+
})
|
|
541
|
+
const side = WallNode.parse({
|
|
542
|
+
parentId: level.id,
|
|
543
|
+
start: [2.4, 0],
|
|
544
|
+
end: [2.4, 5.3],
|
|
545
|
+
thickness: 0.2,
|
|
546
|
+
})
|
|
547
|
+
const neighbour = WallNode.parse({
|
|
548
|
+
parentId: level.id,
|
|
549
|
+
start: [2.4, 5.3],
|
|
550
|
+
end: [8, 5.3],
|
|
551
|
+
thickness: 0.2,
|
|
552
|
+
})
|
|
553
|
+
withNodes([level, bottom, side, neighbour], () => {
|
|
554
|
+
const plan = computeGroupPlanBox([bottom.id, side.id], level.id)
|
|
555
|
+
expect(plan?.minX).toBeCloseTo(0)
|
|
556
|
+
expect(plan?.maxX).toBeCloseTo(2.5)
|
|
557
|
+
expect(plan?.minZ).toBeCloseTo(-0.1)
|
|
558
|
+
expect(plan?.maxZ).toBeLessThan(5.5)
|
|
559
|
+
expect(plan?.maxX).toBeLessThan(8)
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('two walls with the room floor and ceiling: the box is the drawn outlines, never the meshes', () => {
|
|
564
|
+
// A user's selection whose box came out 13 m wide in 2D-only view.
|
|
565
|
+
const level = LevelNode.parse({ id: 'level_vvkg048zziz9ktiy', children: [] })
|
|
566
|
+
const room: [number, number][] = [
|
|
567
|
+
[-3.6221682640435353, -9.037533979512114],
|
|
568
|
+
[-6.21504662384804, -8.29671159099654],
|
|
569
|
+
[-5.712152486161543, -6.5365821090938],
|
|
570
|
+
[-5.712152486161543, -5],
|
|
571
|
+
[-8.223564365687542, -5],
|
|
572
|
+
[-8.223564365687542, -6.5365821090938],
|
|
573
|
+
[-10.712152486161543, -6.5365821090938],
|
|
574
|
+
[-11.712152486161543, -8.5365821090938],
|
|
575
|
+
[-4.119274126357038, -10.777404497609373],
|
|
576
|
+
]
|
|
577
|
+
const wall15 = WallNode.parse({
|
|
578
|
+
parentId: level.id,
|
|
579
|
+
start: [-10.712152486161543, -6.5365821090938],
|
|
580
|
+
end: [-11.712152486161543, -8.5365821090938],
|
|
581
|
+
})
|
|
582
|
+
const wall12 = WallNode.parse({
|
|
583
|
+
parentId: level.id,
|
|
584
|
+
start: [-11.712152486161543, -8.5365821090938],
|
|
585
|
+
end: [-6.712152486161543, -10.0365821090938],
|
|
586
|
+
})
|
|
587
|
+
const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 })
|
|
588
|
+
const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 })
|
|
589
|
+
withNodes([level, wall15, wall12, slab, ceiling], () => {
|
|
590
|
+
const plan = computeGroupPlanBox([wall15.id, ceiling.id, slab.id, wall12.id], level.id)!
|
|
591
|
+
expect(plan.minX).toBeLessThan(-11.71)
|
|
592
|
+
expect(plan.minX).toBeGreaterThan(-11.95)
|
|
593
|
+
expect(plan.maxX).toBeCloseTo(-3.62, 1)
|
|
594
|
+
expect(plan.minZ).toBeCloseTo(-10.78, 1)
|
|
595
|
+
expect(plan.maxZ).toBeCloseTo(-5, 1)
|
|
596
|
+
expect(plan.maxX - plan.minX).toBeLessThan(8.4)
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
})
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
|
+
calculateLevelMiters,
|
|
5
|
+
getWallPlanFootprint,
|
|
4
6
|
nodeRegistry,
|
|
5
7
|
resolveBuildingForLevel,
|
|
6
8
|
sceneRegistry,
|
|
9
|
+
useScene,
|
|
10
|
+
type WallNode,
|
|
7
11
|
} from '@pascal-app/core'
|
|
8
|
-
import { Box3, Matrix4 } from 'three'
|
|
12
|
+
import { Box3, type BufferGeometry, Matrix4, type Object3D, Vector3 } from 'three'
|
|
9
13
|
|
|
10
14
|
// Shared plumbing for the group transform gizmos (rotate + move). Both operate
|
|
11
15
|
// on the same multi-selection: classify each participant by how its placement
|
|
@@ -242,48 +246,6 @@ export function collectParticipants(
|
|
|
242
246
|
return { starts, links }
|
|
243
247
|
}
|
|
244
248
|
|
|
245
|
-
// Grow a selection to the full connected component of walls/fences: any
|
|
246
|
-
// endpoint node transitively reachable through shared junctions from a selected
|
|
247
|
-
// endpoint node joins in, so the whole rigid structure transforms as one piece
|
|
248
|
-
// (rather than tearing/stretching at the boundary). Non-endpoint selections
|
|
249
|
-
// (items, columns) pass through unchanged.
|
|
250
|
-
export function expandToComponent(
|
|
251
|
-
selectedIds: string[],
|
|
252
|
-
sceneNodes: Record<string, AnyNode | undefined>,
|
|
253
|
-
levelId: string | null,
|
|
254
|
-
): string[] {
|
|
255
|
-
const endpoints: { id: string; start: Vec2; end: Vec2 }[] = []
|
|
256
|
-
for (const [id, node] of Object.entries(sceneNodes)) {
|
|
257
|
-
if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') {
|
|
258
|
-
const n = node as AnyNode & { start: Vec2; end: Vec2 }
|
|
259
|
-
endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] })
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
const included = new Set(selectedIds)
|
|
263
|
-
if (!endpoints.some((e) => included.has(e.id))) return selectedIds
|
|
264
|
-
|
|
265
|
-
let changed = true
|
|
266
|
-
while (changed) {
|
|
267
|
-
changed = false
|
|
268
|
-
for (const e of endpoints) {
|
|
269
|
-
if (included.has(e.id)) continue
|
|
270
|
-
const touches = endpoints.some(
|
|
271
|
-
(o) =>
|
|
272
|
-
included.has(o.id) &&
|
|
273
|
-
(nearPoint(e.start, o.start) ||
|
|
274
|
-
nearPoint(e.start, o.end) ||
|
|
275
|
-
nearPoint(e.end, o.start) ||
|
|
276
|
-
nearPoint(e.end, o.end)),
|
|
277
|
-
)
|
|
278
|
-
if (touches) {
|
|
279
|
-
included.add(e.id)
|
|
280
|
-
changed = true
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
return Array.from(included)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
249
|
// Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live
|
|
288
250
|
// preview and for the single batched `updateNodes` on commit.
|
|
289
251
|
export type GroupPatch = readonly [AnyNodeId, Record<string, unknown>]
|
|
@@ -389,9 +351,24 @@ export function rotateGroupSnapshots(
|
|
|
389
351
|
|
|
390
352
|
export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number }
|
|
391
353
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
354
|
+
const planCorner = new Vector3()
|
|
355
|
+
const planMatrix = new Matrix4()
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The selection's footprint in the level frame — the plan's own coordinates,
|
|
359
|
+
* measured from node data wherever the plan draws from data: walls by their
|
|
360
|
+
* mitered outline, polygon hosts (slab, ceiling, zone) by their polygon, fences
|
|
361
|
+
* by their run. Only placed objects (items, columns, stairs…) measure their
|
|
362
|
+
* meshes: bounds go through `frameInv × matrixWorld` (a world box can't be
|
|
363
|
+
* carried into a rotated building's frame), placeholders — unbuilt while the
|
|
364
|
+
* 3D scene is paused in 2D-only view — are skipped, and a node with no built
|
|
365
|
+
* mesh falls back to its anchor.
|
|
366
|
+
*/
|
|
367
|
+
export function groupPlanBounds(
|
|
368
|
+
starts: ParticipantStart[],
|
|
369
|
+
frameInv: Matrix4,
|
|
370
|
+
): GroupPlanBounds | null {
|
|
371
|
+
const nodes = useScene.getState().nodes
|
|
395
372
|
let minX = Number.POSITIVE_INFINITY
|
|
396
373
|
let minZ = Number.POSITIVE_INFINITY
|
|
397
374
|
let maxX = Number.NEGATIVE_INFINITY
|
|
@@ -402,43 +379,72 @@ function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null
|
|
|
402
379
|
maxX = Math.max(maxX, x)
|
|
403
380
|
maxZ = Math.max(maxZ, z)
|
|
404
381
|
}
|
|
382
|
+
const miters = new Map<string | null, ReturnType<typeof calculateLevelMiters>>()
|
|
383
|
+
const wallOutline = (wall: WallNode) => {
|
|
384
|
+
const levelId = wall.parentId ?? null
|
|
385
|
+
let levelMiters = miters.get(levelId)
|
|
386
|
+
if (!levelMiters) {
|
|
387
|
+
levelMiters = calculateLevelMiters(
|
|
388
|
+
Object.values(nodes).filter(
|
|
389
|
+
(node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
miters.set(levelId, levelMiters)
|
|
393
|
+
}
|
|
394
|
+
return getWallPlanFootprint(wall, levelMiters)
|
|
395
|
+
}
|
|
396
|
+
const measureMeshes = (id: string) => {
|
|
397
|
+
const obj = sceneRegistry.nodes.get(id as AnyNodeId)
|
|
398
|
+
if (!obj) return false
|
|
399
|
+
obj.updateWorldMatrix(true, true)
|
|
400
|
+
let found = false
|
|
401
|
+
obj.traverse((child: Object3D) => {
|
|
402
|
+
const geometry = (child as Object3D & { geometry?: BufferGeometry }).geometry
|
|
403
|
+
if (!child.visible || !geometry || geometry.userData.placeholder) return
|
|
404
|
+
// Always fresh: geometries rebuilt in place keep a stale cached box.
|
|
405
|
+
geometry.computeBoundingBox()
|
|
406
|
+
const bounds = geometry.boundingBox
|
|
407
|
+
if (!bounds || bounds.isEmpty()) return
|
|
408
|
+
planMatrix.multiplyMatrices(frameInv, child.matrixWorld)
|
|
409
|
+
for (let corner = 0; corner < 8; corner++) {
|
|
410
|
+
planCorner
|
|
411
|
+
.set(
|
|
412
|
+
corner & 1 ? bounds.max.x : bounds.min.x,
|
|
413
|
+
corner & 2 ? bounds.max.y : bounds.min.y,
|
|
414
|
+
corner & 4 ? bounds.max.z : bounds.min.z,
|
|
415
|
+
)
|
|
416
|
+
.applyMatrix4(planMatrix)
|
|
417
|
+
reach(planCorner.x, planCorner.z)
|
|
418
|
+
}
|
|
419
|
+
found = true
|
|
420
|
+
})
|
|
421
|
+
return found
|
|
422
|
+
}
|
|
405
423
|
for (const s of starts) {
|
|
424
|
+
const node = nodes[s.id]
|
|
406
425
|
if (s.kind === 'endpoint') {
|
|
426
|
+
const outline = node?.type === 'wall' ? wallOutline(node) : []
|
|
427
|
+
if (outline.length > 0) {
|
|
428
|
+
for (const point of outline) reach(point.x, point.y)
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
407
431
|
reach(s.start[0], s.start[1])
|
|
408
432
|
reach(s.end[0], s.end[1])
|
|
433
|
+
const path = (node as { path?: unknown } | undefined)?.path
|
|
434
|
+
if (isVec2Array(path)) for (const [x, z] of path) reach(x, z)
|
|
409
435
|
} else if (s.kind === 'polygon') {
|
|
410
|
-
for (const [x, z] of s.polygon)
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
} else {
|
|
436
|
+
for (const [x, z] of s.polygon) reach(x, z)
|
|
437
|
+
} else if (!measureMeshes(s.id)) {
|
|
414
438
|
reach(s.position[0], s.position[2])
|
|
415
439
|
}
|
|
416
440
|
}
|
|
417
|
-
|
|
418
|
-
return { minX, minZ, maxX, maxZ }
|
|
441
|
+
return Number.isFinite(minX) ? { minX, minZ, maxX, maxZ } : null
|
|
419
442
|
}
|
|
420
443
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
// points alone sit metres inside a wide selection's real footprint, so a
|
|
426
|
-
// gesture that pivots on the data extents orbits a different point than the
|
|
427
|
-
// idle keyboard rotate and the rotate gizmos, which both use the mesh box.
|
|
428
|
-
export function groupPlanBounds(
|
|
429
|
-
box: Box3 | null,
|
|
430
|
-
starts: ParticipantStart[],
|
|
431
|
-
frameInv: Matrix4,
|
|
432
|
-
): GroupPlanBounds | null {
|
|
433
|
-
if (!box) return participantExtents(starts)
|
|
434
|
-
const min = box.min.clone().applyMatrix4(frameInv)
|
|
435
|
-
const max = box.max.clone().applyMatrix4(frameInv)
|
|
436
|
-
return {
|
|
437
|
-
minX: Math.min(min.x, max.x),
|
|
438
|
-
minZ: Math.min(min.z, max.z),
|
|
439
|
-
maxX: Math.max(min.x, max.x),
|
|
440
|
-
maxZ: Math.max(min.z, max.z),
|
|
441
|
-
}
|
|
444
|
+
/** `groupPlanBounds` for a selection: the dashed boxes and gizmos measure this. */
|
|
445
|
+
export function computeGroupPlanBox(ids: string[], levelId: string | null): GroupPlanBounds | null {
|
|
446
|
+
const { starts } = collectParticipants(ids, useScene.getState().nodes, levelId)
|
|
447
|
+
return groupPlanBounds(starts, levelFrame(levelId).inverse)
|
|
442
448
|
}
|
|
443
449
|
|
|
444
450
|
export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { type Cursor, emitter } from '@pascal-app/core'
|
|
3
|
+
import { type Cursor, emitter, sceneRegistry } from '@pascal-app/core'
|
|
4
4
|
import type { ThreeEvent } from '@react-three/fiber'
|
|
5
5
|
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
|
|
6
6
|
import {
|
|
@@ -17,11 +17,15 @@ import {
|
|
|
17
17
|
type Raycaster,
|
|
18
18
|
Shape,
|
|
19
19
|
TorusGeometry,
|
|
20
|
+
Vector3,
|
|
20
21
|
} from 'three'
|
|
21
22
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
22
23
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
23
24
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
24
25
|
import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation'
|
|
26
|
+
import { createSpatialDragPlane, intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane'
|
|
27
|
+
import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input'
|
|
28
|
+
import { getActiveBuildingId } from '../../../lib/world-grid-snap'
|
|
25
29
|
import useEditor from '../../../store/use-editor'
|
|
26
30
|
|
|
27
31
|
// While a press-drag move is in flight (`placementDragMode`), the move tool
|
|
@@ -42,6 +46,7 @@ export const NO_RAYCAST = () => null
|
|
|
42
46
|
export const HIT_AREA_MARGIN = 0.035
|
|
43
47
|
|
|
44
48
|
const HIT_AREA_RENDER_ORDER = 1011
|
|
49
|
+
const HIT_AREA_POINTER_EVENTS_ORDER = 10
|
|
45
50
|
const HIT_AREA_THICKNESS = 0.08
|
|
46
51
|
const CHEVRON_MIN_X = -0.2
|
|
47
52
|
const CHEVRON_MAX_X = 0.22
|
|
@@ -492,15 +497,80 @@ export function InvisibleHandleHitArea({
|
|
|
492
497
|
onPointerLeave: PointerHandler
|
|
493
498
|
scale: number
|
|
494
499
|
}) {
|
|
500
|
+
const handlePointerDown: PointerHandler = (event) => {
|
|
501
|
+
const spatialPointerId = getSpatialPointerId(event.nativeEvent)
|
|
502
|
+
if (spatialPointerId) {
|
|
503
|
+
const pointerTarget = event.object as typeof event.object & {
|
|
504
|
+
releasePointerCapture?: (pointerId: number) => void
|
|
505
|
+
setPointerCapture?: (pointerId: number) => void
|
|
506
|
+
}
|
|
507
|
+
pointerTarget.setPointerCapture?.(event.pointerId)
|
|
508
|
+
const buildingId = getActiveBuildingId()
|
|
509
|
+
const building = buildingId ? sceneRegistry.nodes.get(buildingId) : undefined
|
|
510
|
+
building?.updateWorldMatrix(true, false)
|
|
511
|
+
const inverse = building?.matrixWorld.clone().invert()
|
|
512
|
+
const initialPoint = event.point.clone()
|
|
513
|
+
const plane = createSpatialDragPlane(initialPoint, event.ray, building?.matrixWorld)
|
|
514
|
+
const point = new Vector3()
|
|
515
|
+
let seeded = false
|
|
516
|
+
const emitPoint = (position: Vector3, ray = event.ray) => {
|
|
517
|
+
const local = inverse ? position.clone().applyMatrix4(inverse) : position
|
|
518
|
+
emitter.emit('grid:move', {
|
|
519
|
+
position: [position.x, position.y, position.z],
|
|
520
|
+
localPosition: [local.x, local.y, local.z],
|
|
521
|
+
nativeEvent: {
|
|
522
|
+
...event.nativeEvent,
|
|
523
|
+
inputSource: spatialPointerId,
|
|
524
|
+
pointerType: 'xr',
|
|
525
|
+
pointerId: event.pointerId,
|
|
526
|
+
target: event.nativeEvent.target,
|
|
527
|
+
ray: ray.clone(),
|
|
528
|
+
} as never,
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
// Direct resize sessions replace this capture in onPointerDown below.
|
|
532
|
+
// Move/reshape tools consume grid events in a plane frozen at the grip.
|
|
533
|
+
spatialPointerInput.capture(spatialPointerId, {
|
|
534
|
+
onMove: (ray) => {
|
|
535
|
+
if (!seeded) {
|
|
536
|
+
emitPoint(initialPoint)
|
|
537
|
+
seeded = true
|
|
538
|
+
}
|
|
539
|
+
if (intersectSpatialDragPlane(ray, plane, point)) emitPoint(point, ray)
|
|
540
|
+
},
|
|
541
|
+
onRelease: () => {
|
|
542
|
+
pointerTarget.releasePointerCapture?.(event.pointerId)
|
|
543
|
+
window.dispatchEvent(
|
|
544
|
+
new PointerEvent('pointerup', {
|
|
545
|
+
bubbles: true,
|
|
546
|
+
button: 0,
|
|
547
|
+
pointerId: event.pointerId,
|
|
548
|
+
pointerType: 'xr',
|
|
549
|
+
}),
|
|
550
|
+
)
|
|
551
|
+
},
|
|
552
|
+
onCancel: () => {
|
|
553
|
+
pointerTarget.releasePointerCapture?.(event.pointerId)
|
|
554
|
+
emitter.emit('tool:cancel')
|
|
555
|
+
},
|
|
556
|
+
onReplace: () => {
|
|
557
|
+
pointerTarget.releasePointerCapture?.(event.pointerId)
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
onPointerDown(event)
|
|
562
|
+
}
|
|
563
|
+
|
|
495
564
|
return (
|
|
496
565
|
<mesh
|
|
497
566
|
frustumCulled={false}
|
|
498
567
|
geometry={geometry}
|
|
499
568
|
layers={EDITOR_LAYER}
|
|
500
569
|
material={material}
|
|
501
|
-
onPointerDown={
|
|
570
|
+
onPointerDown={handlePointerDown}
|
|
502
571
|
onPointerEnter={onPointerEnter}
|
|
503
572
|
onPointerLeave={onPointerLeave}
|
|
573
|
+
{...({ pointerEventsOrder: HIT_AREA_POINTER_EVENTS_ORDER } as Record<string, unknown>)}
|
|
504
574
|
raycast={hitAreaRaycast}
|
|
505
575
|
renderOrder={HIT_AREA_RENDER_ORDER}
|
|
506
576
|
scale={scale}
|
|
@@ -17,6 +17,8 @@ import { useEffect, useRef } from 'react'
|
|
|
17
17
|
import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
|
|
18
18
|
import { isHistoryShortcut } from '../../../lib/history'
|
|
19
19
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
20
|
+
import { intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane'
|
|
21
|
+
import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input'
|
|
20
22
|
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
|
21
23
|
import { commitHandleDragPatch } from './handle-drag-history'
|
|
22
24
|
|
|
@@ -58,6 +60,7 @@ type HandleDragSession = {
|
|
|
58
60
|
commit?: (patch: Partial<AnyNode>) => void
|
|
59
61
|
markDirty?: boolean
|
|
60
62
|
onBegin?: () => void
|
|
63
|
+
onCancel?: () => void
|
|
61
64
|
onEnd?: () => void
|
|
62
65
|
overrideId?: AnyNodeId
|
|
63
66
|
}
|
|
@@ -107,6 +110,7 @@ function suppressInputDraggingUntilPointerRelease(pointerId: number) {
|
|
|
107
110
|
window.addEventListener('pointerup', restore)
|
|
108
111
|
window.addEventListener('pointercancel', restore)
|
|
109
112
|
window.addEventListener('blur', onBlur)
|
|
113
|
+
return () => restore()
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
export function useHandleDrag(args: UseHandleDragArgs) {
|
|
@@ -121,9 +125,17 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
121
125
|
if (event.button !== 0) return
|
|
122
126
|
event.stopPropagation()
|
|
123
127
|
suppressBoxSelectForPointer(event)
|
|
128
|
+
const spatialPointerId = getSpatialPointerId(event.nativeEvent)
|
|
124
129
|
|
|
125
130
|
if (args.kind === 'tap') {
|
|
126
|
-
suppressInputDraggingUntilPointerRelease(event.
|
|
131
|
+
const restoreInputDragging = suppressInputDraggingUntilPointerRelease(event.pointerId)
|
|
132
|
+
if (spatialPointerId) {
|
|
133
|
+
spatialPointerInput.capture(spatialPointerId, {
|
|
134
|
+
onMove: () => undefined,
|
|
135
|
+
onRelease: restoreInputDragging,
|
|
136
|
+
onCancel: restoreInputDragging,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
127
139
|
swallowNextClick()
|
|
128
140
|
sfxEmitter.emit('sfx:item-pick')
|
|
129
141
|
document.body.style.cursor = ''
|
|
@@ -133,6 +145,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
133
145
|
|
|
134
146
|
const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args
|
|
135
147
|
rideObject.updateMatrixWorld()
|
|
148
|
+
const spatialRay = spatialPointerId ? event.ray.clone() : null
|
|
136
149
|
|
|
137
150
|
const ndc = new Vector2()
|
|
138
151
|
const setPointerRay = (clientX: number, clientY: number) => {
|
|
@@ -144,10 +157,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
144
157
|
raycaster.setFromCamera(ndc, camera)
|
|
145
158
|
}
|
|
146
159
|
const getPointerRay: GetPointerRay = (clientX, clientY, target) => {
|
|
160
|
+
if (spatialRay) return target.copy(spatialRay)
|
|
147
161
|
setPointerRay(clientX, clientY)
|
|
148
162
|
return target.copy(raycaster.ray)
|
|
149
163
|
}
|
|
150
164
|
const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
|
|
165
|
+
if (spatialRay) return intersectSpatialDragPlane(spatialRay, plane, target)
|
|
151
166
|
setPointerRay(clientX, clientY)
|
|
152
167
|
return raycaster.ray.intersectPlane(plane, target)
|
|
153
168
|
}
|
|
@@ -180,7 +195,11 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
180
195
|
|
|
181
196
|
let lastPatch: Partial<AnyNode> | null = null
|
|
182
197
|
let historyPaused = true
|
|
198
|
+
let releaseSpatialCapture: (() => void) | null = null
|
|
183
199
|
let altKey = event.nativeEvent.altKey
|
|
200
|
+
let shiftKey = event.nativeEvent.shiftKey
|
|
201
|
+
let pendingMoveEvent: PointerEvent | null = null
|
|
202
|
+
let moveFrame = 0
|
|
184
203
|
|
|
185
204
|
const resumeHistory = () => {
|
|
186
205
|
if (!historyPaused) return
|
|
@@ -188,10 +207,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
188
207
|
useScene.temporal.getState().resume()
|
|
189
208
|
}
|
|
190
209
|
|
|
191
|
-
const
|
|
210
|
+
const processMove = (moveEvent: PointerEvent) => {
|
|
192
211
|
const patch = session.move({
|
|
193
212
|
event: moveEvent,
|
|
194
|
-
modifiers: { altKey },
|
|
213
|
+
modifiers: { altKey, shiftKey },
|
|
195
214
|
getPointerRay,
|
|
196
215
|
intersectPlane,
|
|
197
216
|
})
|
|
@@ -204,12 +223,35 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
204
223
|
}
|
|
205
224
|
}
|
|
206
225
|
|
|
226
|
+
// Coalesce high-frequency pointer events so live React/store updates happen
|
|
227
|
+
// at most once per animation frame while retaining the newest position.
|
|
228
|
+
const flushMove = () => {
|
|
229
|
+
moveFrame = 0
|
|
230
|
+
const moveEvent = pendingMoveEvent
|
|
231
|
+
pendingMoveEvent = null
|
|
232
|
+
if (moveEvent) processMove(moveEvent)
|
|
233
|
+
}
|
|
234
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
235
|
+
pendingMoveEvent = moveEvent
|
|
236
|
+
if (moveFrame !== 0) return
|
|
237
|
+
if (typeof window.requestAnimationFrame === 'function') {
|
|
238
|
+
moveFrame = window.requestAnimationFrame(flushMove)
|
|
239
|
+
} else {
|
|
240
|
+
flushMove()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
207
244
|
const cleanup = () => {
|
|
245
|
+
if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame)
|
|
246
|
+
moveFrame = 0
|
|
247
|
+
pendingMoveEvent = null
|
|
208
248
|
window.removeEventListener('pointermove', onMove)
|
|
209
249
|
window.removeEventListener('pointerup', onUp)
|
|
210
250
|
window.removeEventListener('pointercancel', onCancel)
|
|
211
251
|
window.removeEventListener('keydown', onKeyDown, true)
|
|
212
252
|
window.removeEventListener('keyup', onKeyUp, true)
|
|
253
|
+
releaseSpatialCapture?.()
|
|
254
|
+
releaseSpatialCapture = null
|
|
213
255
|
if (document.body.style.cursor === cursor) {
|
|
214
256
|
document.body.style.cursor = ''
|
|
215
257
|
}
|
|
@@ -230,6 +272,8 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
230
272
|
}
|
|
231
273
|
|
|
232
274
|
const onUp = () => {
|
|
275
|
+
if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame)
|
|
276
|
+
flushMove()
|
|
233
277
|
swallowNextClick()
|
|
234
278
|
sfxEmitter.emit('sfx:item-place')
|
|
235
279
|
if (lastPatch) {
|
|
@@ -245,6 +289,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
245
289
|
}
|
|
246
290
|
|
|
247
291
|
const onCancel = () => {
|
|
292
|
+
session.onCancel?.()
|
|
248
293
|
clearOverride()
|
|
249
294
|
cleanup()
|
|
250
295
|
}
|
|
@@ -256,6 +301,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
256
301
|
altKey = true
|
|
257
302
|
return
|
|
258
303
|
}
|
|
304
|
+
if (e.key === 'Shift') {
|
|
305
|
+
shiftKey = true
|
|
306
|
+
return
|
|
307
|
+
}
|
|
259
308
|
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
260
309
|
e.preventDefault()
|
|
261
310
|
e.stopPropagation()
|
|
@@ -264,12 +313,32 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
264
313
|
}
|
|
265
314
|
const onKeyUp = (e: KeyboardEvent) => {
|
|
266
315
|
if (e.key === 'Alt') altKey = false
|
|
316
|
+
if (e.key === 'Shift') shiftKey = false
|
|
267
317
|
}
|
|
268
318
|
|
|
269
319
|
dragCleanupRef.current = onCancel
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
320
|
+
if (spatialPointerId && spatialRay) {
|
|
321
|
+
releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
|
|
322
|
+
onMove: (ray) => {
|
|
323
|
+
spatialRay.copy(ray)
|
|
324
|
+
processMove(
|
|
325
|
+
new PointerEvent('pointermove', {
|
|
326
|
+
button: 0,
|
|
327
|
+
buttons: 1,
|
|
328
|
+
altKey,
|
|
329
|
+
pointerId: event.pointerId,
|
|
330
|
+
pointerType: 'xr',
|
|
331
|
+
}),
|
|
332
|
+
)
|
|
333
|
+
},
|
|
334
|
+
onRelease: onUp,
|
|
335
|
+
onCancel,
|
|
336
|
+
})
|
|
337
|
+
} else {
|
|
338
|
+
window.addEventListener('pointermove', onMove)
|
|
339
|
+
window.addEventListener('pointerup', onUp)
|
|
340
|
+
window.addEventListener('pointercancel', onCancel)
|
|
341
|
+
}
|
|
273
342
|
window.addEventListener('keydown', onKeyDown, true)
|
|
274
343
|
window.addEventListener('keyup', onKeyUp, true)
|
|
275
344
|
}
|