@pascal-app/editor 1.0.0 → 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 +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- 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/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- 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-action-menu.tsx +12 -1
- 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 +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- 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 +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- 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 +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- 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/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- 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/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- 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 +669 -336
- 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.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- 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/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- 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 +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- 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 +43 -28
- package/src/index.tsx +68 -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/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- 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/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -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/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- 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/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- 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-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -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}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, createSceneApi, type LinearResizeHandle, useScene } from '@pascal-app/core'
|
|
3
|
+
import { Euler, Vector3 } from 'three'
|
|
4
|
+
import { createArrowHandleGeometry } from './handle-arrow'
|
|
5
|
+
import {
|
|
6
|
+
computeFreezeOffset,
|
|
7
|
+
resolveLinearHandlePosition,
|
|
8
|
+
resolveLinearHandleRotation,
|
|
9
|
+
} from './handle-placement'
|
|
10
|
+
|
|
11
|
+
const scene = createSceneApi(useScene)
|
|
12
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
13
|
+
test(`${axis} clearance preserves sufficient offsets and keeps even hovered arrow tails clear at different scales`, () => {
|
|
14
|
+
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
|
15
|
+
const geometry = createArrowHandleGeometry(true)
|
|
16
|
+
geometry.computeBoundingBox()
|
|
17
|
+
const tail = geometry.boundingBox!.min.x * 1.12
|
|
18
|
+
for (const edge of [0.06, 1.2, -0.4]) {
|
|
19
|
+
const position: [number, number, number] = [0, 0, 0]
|
|
20
|
+
position[index] = edge + 0.15
|
|
21
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
22
|
+
kind: 'linear-resize',
|
|
23
|
+
axis,
|
|
24
|
+
anchor: 'center',
|
|
25
|
+
currentValue: () => 1,
|
|
26
|
+
apply: () => null,
|
|
27
|
+
placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
|
|
28
|
+
}
|
|
29
|
+
for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
|
|
30
|
+
const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
|
|
31
|
+
expect(resolved[index] + tail * scale - edge).toBeGreaterThan(0.1 * scale)
|
|
32
|
+
if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
|
|
33
|
+
resolved.forEach((value, i) => {
|
|
34
|
+
if (i !== index) expect(value).toBe(position[i]!)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
delete descriptor.placement.clearance
|
|
38
|
+
expect(resolveLinearHandlePosition(descriptor, null, scene, 3)).toBe(position)
|
|
39
|
+
}
|
|
40
|
+
geometry.dispose()
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('array rotations keep sibling handles finite during rotation and resize', () => {
|
|
45
|
+
const initial = { position: [1, 2, 3], rotation: [0, Math.PI / 2, 0] } as unknown as AnyNode
|
|
46
|
+
const rotated = { ...initial, rotation: [0, Math.PI, 0] } as unknown as AnyNode
|
|
47
|
+
expect(computeFreezeOffset(rotated, initial)).toEqual([0, 0, 0])
|
|
48
|
+
const moved = { ...initial, position: [2, 4, 3] } as unknown as AnyNode
|
|
49
|
+
const offset = computeFreezeOffset(moved, initial)
|
|
50
|
+
expect(offset[0]).toBeCloseTo(0)
|
|
51
|
+
expect(offset[1]).toBe(2)
|
|
52
|
+
expect(offset[2]).toBeCloseTo(1)
|
|
53
|
+
expect(
|
|
54
|
+
computeFreezeOffset(moved, { ...initial, rotation: Math.PI / 2 } as unknown as AnyNode),
|
|
55
|
+
).toEqual(offset)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
59
|
+
test(`${axis} negative direction keeps hovered arrow above its lower-edge clearance`, () => {
|
|
60
|
+
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
|
61
|
+
const geometry = createArrowHandleGeometry(true)
|
|
62
|
+
geometry.computeBoundingBox()
|
|
63
|
+
const tail = -geometry.boundingBox!.min.x * 1.12
|
|
64
|
+
for (const edge of [-2, 0, 2]) {
|
|
65
|
+
const position: [number, number, number] = [0, 0, 0]
|
|
66
|
+
position[index] = edge - 0.15
|
|
67
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
68
|
+
kind: 'linear-resize',
|
|
69
|
+
axis,
|
|
70
|
+
anchor: 'max',
|
|
71
|
+
direction: -1,
|
|
72
|
+
currentValue: () => 1,
|
|
73
|
+
apply: () => null,
|
|
74
|
+
placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
|
|
75
|
+
}
|
|
76
|
+
for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
|
|
77
|
+
const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
|
|
78
|
+
expect(edge - (resolved[index] + tail * scale)).toBeGreaterThan(0.1 * scale)
|
|
79
|
+
if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
|
|
80
|
+
const pointing = new Vector3(1, 0, 0)
|
|
81
|
+
.applyEuler(new Euler(...resolveLinearHandleRotation(descriptor, resolved)))
|
|
82
|
+
.applyAxisAngle(new Vector3(0, 1, 0), axis === 'z' ? -Math.PI / 2 : 0)
|
|
83
|
+
expect(pointing.getComponent(index)).toBeCloseTo(-1)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
geometry.dispose()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('unspecified direction preserves vertical position heuristic and wall-facing blade', () => {
|
|
91
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
92
|
+
kind: 'linear-resize',
|
|
93
|
+
axis: 'y',
|
|
94
|
+
anchor: 'min',
|
|
95
|
+
currentValue: () => 1,
|
|
96
|
+
apply: () => null,
|
|
97
|
+
placement: { position: () => [0, 0, 0] },
|
|
98
|
+
}
|
|
99
|
+
expect(resolveLinearHandleRotation(descriptor, [0, -1, 0])).toEqual([
|
|
100
|
+
0,
|
|
101
|
+
Math.PI / 2,
|
|
102
|
+
-Math.PI / 2,
|
|
103
|
+
])
|
|
104
|
+
expect(resolveLinearHandleRotation(descriptor, [0, 1, 0])).toEqual([0, Math.PI / 2, Math.PI / 2])
|
|
105
|
+
expect(
|
|
106
|
+
resolveLinearHandleRotation({ ...descriptor, axis: 'x', faceNormal: true }, [0, 0, 0]),
|
|
107
|
+
).toEqual([Math.PI / 2, 0, 0])
|
|
108
|
+
expect(
|
|
109
|
+
resolveLinearHandleRotation({ ...descriptor, axis: 'z', faceNormal: true }, [0, 0, 0]),
|
|
110
|
+
).toEqual([0, 0, 0])
|
|
111
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AnyNode, LinearResizeHandle, RadialResizeHandle, SceneApi } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function resolveLinearHandlePosition<N>(
|
|
4
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
5
|
+
node: N,
|
|
6
|
+
scene: SceneApi,
|
|
7
|
+
baseScale: number,
|
|
8
|
+
): readonly [number, number, number] {
|
|
9
|
+
const position = descriptor.placement.position(node, scene)
|
|
10
|
+
const clearance = descriptor.kind === 'linear-resize' ? descriptor.placement.clearance : undefined
|
|
11
|
+
if (!clearance) return position
|
|
12
|
+
const index = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2
|
|
13
|
+
const result: [number, number, number] = [...position]
|
|
14
|
+
const direction = descriptor.kind === 'linear-resize' ? (descriptor.direction ?? 1) : 1
|
|
15
|
+
const edge = clearance.edge(node, scene) + direction * clearance.distance * baseScale
|
|
16
|
+
result[index] = direction === -1 ? Math.min(result[index], edge) : Math.max(result[index], edge)
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveLinearHandleRotation<N>(
|
|
21
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
22
|
+
position: readonly [number, number, number],
|
|
23
|
+
): [number, number, number] {
|
|
24
|
+
const direction = descriptor.kind === 'linear-resize' ? descriptor.direction : undefined
|
|
25
|
+
if (descriptor.axis === 'y') {
|
|
26
|
+
const sign = direction ?? (position[1] < 0 ? -1 : 1)
|
|
27
|
+
return [0, Math.PI / 2, (sign * Math.PI) / 2]
|
|
28
|
+
}
|
|
29
|
+
const faceNormal =
|
|
30
|
+
descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal
|
|
31
|
+
return [faceNormal ? Math.PI / 2 : 0, 0, direction === -1 ? Math.PI : 0]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Offset, in node-local frame, that compensates for `position` drift on
|
|
35
|
+
// the mesh during an asymmetric resize. Width/length L+R recompute
|
|
36
|
+
// `position` so the anchored edge stays world-fixed — the renderer
|
|
37
|
+
// follows that override, the ride object moves, and every arrow under
|
|
38
|
+
// it would drift along with the mesh center. Subtracting this offset
|
|
39
|
+
// from a non-active arrow's local placement undoes that drift so it
|
|
40
|
+
// stays at its pre-drag world position.
|
|
41
|
+
//
|
|
42
|
+
// Rotation drags don't change `position`, so the offset collapses to
|
|
43
|
+
// zero and non-active arrows naturally rotate with the mesh — which is
|
|
44
|
+
// the desired behaviour (the whole rig rotates as a unit).
|
|
45
|
+
export function computeFreezeOffset(
|
|
46
|
+
liveNode: AnyNode,
|
|
47
|
+
preDragNode: AnyNode,
|
|
48
|
+
): [number, number, number] {
|
|
49
|
+
// Not every node in the union carries a `position` field (sites are the
|
|
50
|
+
// notable holdout — they don't have handles anyway, but TypeScript still
|
|
51
|
+
// requires us to discriminate). Guarded access keeps the freeze logic
|
|
52
|
+
// safe for the few node kinds that lack the field.
|
|
53
|
+
const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
|
|
54
|
+
const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
|
|
55
|
+
0, 0, 0,
|
|
56
|
+
]
|
|
57
|
+
const deltaWorldX = liveP[0] - preP[0]
|
|
58
|
+
const deltaWorldY = liveP[1] - preP[1]
|
|
59
|
+
const deltaWorldZ = liveP[2] - preP[2]
|
|
60
|
+
const rotation = (preDragNode as { rotation?: number | readonly number[] }).rotation
|
|
61
|
+
const rotY = typeof rotation === 'number' ? rotation : (rotation?.[1] ?? 0)
|
|
62
|
+
// World → node-local for Y-axis rotation by rotY (THREE.Object3D
|
|
63
|
+
// rotation-y convention): inverse is rotation by -rotY around +Y.
|
|
64
|
+
const cosR = Math.cos(rotY)
|
|
65
|
+
const sinR = Math.sin(rotY)
|
|
66
|
+
const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
|
|
67
|
+
const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
|
|
68
|
+
return [deltaLocalX, deltaWorldY, deltaLocalZ]
|
|
69
|
+
}
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
|
+
cascadeDirty,
|
|
4
5
|
type HandleDragModifiers,
|
|
5
6
|
type LinearResizeHandle,
|
|
7
|
+
type RadialResizeHandle,
|
|
6
8
|
type SceneApi,
|
|
7
9
|
useLiveNodeOverrides,
|
|
8
10
|
useScene,
|
|
9
11
|
} from '@pascal-app/core'
|
|
10
12
|
import { replacePreviewOverrideIds } from './preview-overrides'
|
|
11
13
|
|
|
14
|
+
export function linearResizeFactor<N>(
|
|
15
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
16
|
+
): number {
|
|
17
|
+
if (descriptor.kind === 'radial-resize') return 1
|
|
18
|
+
return descriptor.anchor === 'center' ? 2 : descriptor.anchor === 'min' ? 1 : -1
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export function createLinearResizeDragBinding({
|
|
13
22
|
descriptor,
|
|
14
23
|
initialNode,
|
|
@@ -52,14 +61,16 @@ export function createLinearResizeDragBinding({
|
|
|
52
61
|
previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record<string, unknown>]),
|
|
53
62
|
)
|
|
54
63
|
for (const [previewId] of previewEntries) {
|
|
55
|
-
|
|
64
|
+
for (const id of cascadeDirty(previewId, { scene: sceneApi }))
|
|
65
|
+
useScene.getState().markDirty(id)
|
|
56
66
|
}
|
|
57
67
|
return patch
|
|
58
68
|
},
|
|
59
69
|
clearPreview(): void {
|
|
60
70
|
for (const previewId of previewOverrideIds) {
|
|
61
71
|
useLiveNodeOverrides.getState().clear(previewId)
|
|
62
|
-
|
|
72
|
+
for (const id of cascadeDirty(previewId, { scene: sceneApi }))
|
|
73
|
+
useScene.getState().markDirty(id)
|
|
63
74
|
}
|
|
64
75
|
previewOverrideIds = new Set()
|
|
65
76
|
},
|