@pascal-app/editor 1.0.0-beta.4 → 1.0.0-beta.5
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 -6
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/editor-layout-v2.tsx +8 -3
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/first-person-controls.tsx +2 -45
- package/src/components/editor/floating-action-menu.tsx +13 -4
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +5 -45
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/index.tsx +112 -25
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +29 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +28 -5
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
- package/src/components/editor/thumbnail-generator.tsx +27 -11
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
- package/src/components/tools/item/placement-strategies.test.ts +167 -0
- package/src/components/tools/item/placement-strategies.ts +57 -22
- package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
- package/src/components/tools/shared/placement-box.tsx +9 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/wall/wall-drafting.test.ts +207 -2
- package/src/components/tools/wall/wall-drafting.ts +84 -400
- package/src/components/ui/command-palette/editor-commands.tsx +3 -1
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
- package/src/components/ui/sidebar/tab-bar.tsx +7 -1
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +218 -0
- package/src/components/viewer-overlay.tsx +12 -8
- package/src/hooks/use-auto-save.test.ts +50 -1
- package/src/hooks/use-auto-save.ts +44 -9
- package/src/hooks/use-keyboard.ts +35 -1
- package/src/index.tsx +27 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +84 -0
- package/src/lib/history.test.ts +75 -10
- package/src/lib/history.ts +58 -13
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/selection-routing.test.ts +20 -5
- package/src/lib/selection-routing.ts +15 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/store/use-session-groups.ts +103 -0
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
createSnapshotPipeline,
|
|
7
7
|
GRID_LAYER,
|
|
8
8
|
heroCameraPose,
|
|
9
|
+
SNAPSHOT_MAX_EDGE,
|
|
10
|
+
SNAPSHOT_MIME,
|
|
11
|
+
SNAPSHOT_QUALITY,
|
|
9
12
|
type SnapshotPipeline,
|
|
10
13
|
snapLevelsToTruePositions,
|
|
11
14
|
THUMBNAIL_HEIGHT,
|
|
@@ -33,6 +36,14 @@ interface ThumbnailGeneratorProps {
|
|
|
33
36
|
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
|
|
40
|
+
const maxEdge = Math.max(width, height)
|
|
41
|
+
if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }
|
|
42
|
+
|
|
43
|
+
const scale = SNAPSHOT_MAX_EDGE / maxEdge
|
|
44
|
+
return { w: Math.round(width * scale), h: Math.round(height * scale) }
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
|
|
37
48
|
const gl = useThree((state) => state.gl)
|
|
38
49
|
const scene = useThree((state) => state.scene)
|
|
@@ -234,33 +245,37 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|
|
234
245
|
let outH: number
|
|
235
246
|
|
|
236
247
|
if (captureMode === 'viewport') {
|
|
237
|
-
outW = width
|
|
238
|
-
outH = height
|
|
248
|
+
;({ w: outW, h: outH } = clampSnapshotSize(width, height))
|
|
239
249
|
const offscreen = document.createElement('canvas')
|
|
240
250
|
offscreen.width = outW
|
|
241
251
|
offscreen.height = outH
|
|
242
|
-
offscreen.getContext('2d')
|
|
252
|
+
const ctx = offscreen.getContext('2d')!
|
|
253
|
+
if (outW !== width || outH !== height) ctx.imageSmoothingQuality = 'high'
|
|
254
|
+
ctx.drawImage(gl.domElement, 0, 0, width, height, 0, 0, outW, outH)
|
|
243
255
|
blob = await new Promise<Blob>((resolve, reject) =>
|
|
244
256
|
offscreen.toBlob(
|
|
245
257
|
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
|
246
|
-
|
|
258
|
+
SNAPSHOT_MIME,
|
|
259
|
+
SNAPSHOT_QUALITY,
|
|
247
260
|
),
|
|
248
261
|
)
|
|
249
262
|
} else if (captureMode === 'area' && cropRegion) {
|
|
250
263
|
const sx = Math.round(cropRegion.x * width)
|
|
251
264
|
const sy = Math.round(cropRegion.y * height)
|
|
252
|
-
|
|
253
|
-
|
|
265
|
+
const sourceW = Math.round(cropRegion.width * width)
|
|
266
|
+
const sourceH = Math.round(cropRegion.height * height)
|
|
267
|
+
;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
|
|
254
268
|
const offscreen = document.createElement('canvas')
|
|
255
269
|
offscreen.width = outW
|
|
256
270
|
offscreen.height = outH
|
|
257
|
-
offscreen
|
|
258
|
-
|
|
259
|
-
|
|
271
|
+
const ctx = offscreen.getContext('2d')!
|
|
272
|
+
if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
|
|
273
|
+
ctx.drawImage(gl.domElement, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
|
|
260
274
|
blob = await new Promise<Blob>((resolve, reject) =>
|
|
261
275
|
offscreen.toBlob(
|
|
262
276
|
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
|
263
|
-
|
|
277
|
+
SNAPSHOT_MIME,
|
|
278
|
+
SNAPSHOT_QUALITY,
|
|
264
279
|
),
|
|
265
280
|
)
|
|
266
281
|
} else {
|
|
@@ -288,7 +303,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|
|
288
303
|
blob = await new Promise<Blob>((resolve, reject) =>
|
|
289
304
|
offscreen.toBlob(
|
|
290
305
|
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
|
291
|
-
|
|
306
|
+
SNAPSHOT_MIME,
|
|
307
|
+
SNAPSHOT_QUALITY,
|
|
292
308
|
),
|
|
293
309
|
)
|
|
294
310
|
}
|
|
@@ -510,6 +510,7 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode })
|
|
|
510
510
|
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
511
511
|
const nodes = useScene((state) => state.nodes)
|
|
512
512
|
const unit = useViewer((state) => state.unit)
|
|
513
|
+
const metricNotation = useViewer((state) => state.metricNotation)
|
|
513
514
|
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
514
515
|
const color = isNight ? '#ffffff' : '#111111'
|
|
515
516
|
const shadowColor = isNight ? '#111111' : '#ffffff'
|
|
@@ -528,9 +529,9 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
|
|
528
529
|
}
|
|
529
530
|
return total
|
|
530
531
|
}, [guide, wall])
|
|
531
|
-
const label = formatLinearMeasurement(length, unit)
|
|
532
|
+
const label = formatLinearMeasurement(length, unit, metricNotation)
|
|
532
533
|
const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall])
|
|
533
|
-
const heightLabel = `H ${formatLinearMeasurement(height, unit)}`
|
|
534
|
+
const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}`
|
|
534
535
|
|
|
535
536
|
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
|
|
536
537
|
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
3
4
|
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
-
import { useEffect, useState } from 'react'
|
|
5
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
5
6
|
import { createPortal } from 'react-dom'
|
|
6
7
|
import { isActive } from '../../lib/interaction/scope'
|
|
8
|
+
import { canCreateSessionGroup, selectionIntersectsSessionGroup } from '../../lib/session-groups'
|
|
7
9
|
import useEditor from '../../store/use-editor'
|
|
8
10
|
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
|
|
11
|
+
import useSessionGroups, {
|
|
12
|
+
groupCurrentSelection,
|
|
13
|
+
ungroupCurrentSelection,
|
|
14
|
+
} from '../../store/use-session-groups'
|
|
9
15
|
import {
|
|
10
16
|
deleteSelection,
|
|
11
17
|
duplicateSelectionAndPickUp,
|
|
@@ -14,25 +20,27 @@ import {
|
|
|
14
20
|
import { NodeActionMenu } from '../editor/node-action-menu'
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
|
-
* Floating
|
|
18
|
-
* floor plan — the group sibling of `FloorplanRegistryActionMenu` (which is
|
|
19
|
-
* sole-selection only). Anchored above the dashed group selection box; every
|
|
20
|
-
* action targets the whole selection: Move picks the group up (it rides the
|
|
21
|
-
* cursor until a click places it), Duplicate clones the selection and picks
|
|
22
|
-
* the clones up, Delete removes everything selected.
|
|
23
|
-
*
|
|
24
|
-
* Gated on floorplan hover so it never coexists with the 3D group menu in
|
|
25
|
-
* split view (that one hides while the floor plan is hovered), and hidden
|
|
26
|
-
* during any active interaction so it never competes with a live drag.
|
|
23
|
+
* Floating multi-select pill on the floor plan: Move, Group, Ungroup, Duplicate, Delete.
|
|
27
24
|
*/
|
|
28
25
|
export function FloorplanGroupActionMenu() {
|
|
29
|
-
const
|
|
26
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
27
|
+
const isMultiSelect = selectedIds.length > 1
|
|
30
28
|
const movingNode = useMovingNode()
|
|
31
29
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
32
30
|
const scopeActive = useInteractionScope((s) => isActive(s.scope))
|
|
31
|
+
const sessionGroups = useSessionGroups((s) => s.groups)
|
|
32
|
+
const sceneNodes = useScene((s) => s.nodes)
|
|
33
|
+
const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
|
|
34
|
+
const showGroup = useMemo(
|
|
35
|
+
() => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
36
|
+
[sessionGroups, selectedIds, liveIds],
|
|
37
|
+
)
|
|
38
|
+
const showUngroup = useMemo(
|
|
39
|
+
() => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
40
|
+
[sessionGroups, selectedIds, liveIds],
|
|
41
|
+
)
|
|
33
42
|
|
|
34
43
|
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
|
35
|
-
|
|
36
44
|
const isVisible = isMultiSelect && !movingNode && isFloorplanHovered && !scopeActive
|
|
37
45
|
|
|
38
46
|
useEffect(() => {
|
|
@@ -43,9 +51,6 @@ export function FloorplanGroupActionMenu() {
|
|
|
43
51
|
let raf = 0
|
|
44
52
|
const tick = () => {
|
|
45
53
|
raf = requestAnimationFrame(tick)
|
|
46
|
-
// The dashed group box exists exactly while the multi-selection has
|
|
47
|
-
// transformable participants — anchor to its top edge. Only publish
|
|
48
|
-
// actual changes so the idle poll doesn't re-render every frame.
|
|
49
54
|
const box = document.querySelector('[data-group-selection-box]') as SVGGElement | null
|
|
50
55
|
if (!box) {
|
|
51
56
|
setPosition((prev) => (prev === null ? prev : null))
|
|
@@ -77,9 +82,11 @@ export function FloorplanGroupActionMenu() {
|
|
|
77
82
|
<NodeActionMenu
|
|
78
83
|
onDelete={() => deleteSelection()}
|
|
79
84
|
onDuplicate={() => duplicateSelectionAndPickUp()}
|
|
85
|
+
onGroup={showGroup ? () => groupCurrentSelection() : undefined}
|
|
80
86
|
onMove={() => startGroupPickUp()}
|
|
81
87
|
onPointerDown={(event) => event.stopPropagation()}
|
|
82
88
|
onPointerUp={(event) => event.stopPropagation()}
|
|
89
|
+
onUngroup={showUngroup ? () => ungroupCurrentSelection() : undefined}
|
|
83
90
|
/>
|
|
84
91
|
</div>,
|
|
85
92
|
document.body,
|
|
@@ -67,6 +67,10 @@ import {
|
|
|
67
67
|
resolveFloorplanAnnotationVisibility,
|
|
68
68
|
resolveFloorplanWallDimensionReference,
|
|
69
69
|
} from '../../../lib/floorplan/floorplan-mode'
|
|
70
|
+
import {
|
|
71
|
+
buildFloorplanContext,
|
|
72
|
+
floorplanLayerRank,
|
|
73
|
+
} from '../../../lib/floorplan/floorplan-readonly'
|
|
70
74
|
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
|
|
71
75
|
import {
|
|
72
76
|
type ActiveInteractionScope,
|
|
@@ -91,6 +95,7 @@ import useInteractionScope, {
|
|
|
91
95
|
useEndpointReshape,
|
|
92
96
|
useMovingNode,
|
|
93
97
|
} from '../../../store/use-interaction-scope'
|
|
98
|
+
import { expandSessionSelectionForNode } from '../../../store/use-session-groups'
|
|
94
99
|
import { startGroupPickUp } from '../../editor/group-actions'
|
|
95
100
|
import { classifyParticipant } from '../../editor/group-transform-shared'
|
|
96
101
|
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
|
|
@@ -589,13 +594,19 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
589
594
|
}, [bumpAffectedSiblingEpochs])
|
|
590
595
|
|
|
591
596
|
const applyEntrySelection = useCallback(
|
|
592
|
-
(id: AnyNodeId, shouldToggle: boolean) => {
|
|
597
|
+
(id: AnyNodeId, options: { shouldToggle: boolean; isolateMember: boolean }) => {
|
|
593
598
|
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
594
|
-
|
|
595
|
-
|
|
599
|
+
let nextSelectedIds: string[]
|
|
600
|
+
if (options.shouldToggle) {
|
|
601
|
+
nextSelectedIds = currentSelectedIds.includes(id)
|
|
596
602
|
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
|
597
603
|
: [...currentSelectedIds, id]
|
|
598
|
-
|
|
604
|
+
} else if (options.isolateMember) {
|
|
605
|
+
nextSelectedIds = [id]
|
|
606
|
+
} else {
|
|
607
|
+
const expanded = expandSessionSelectionForNode(id)
|
|
608
|
+
nextSelectedIds = expanded && expanded.length > 1 ? expanded : [id]
|
|
609
|
+
}
|
|
599
610
|
setSelection({ selectedIds: nextSelectedIds })
|
|
600
611
|
if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
|
|
601
612
|
const node = useScene.getState().nodes[id]
|
|
@@ -618,7 +629,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
618
629
|
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
|
619
630
|
if (event.button !== 0) return
|
|
620
631
|
event.stopPropagation()
|
|
621
|
-
applyEntrySelection(id,
|
|
632
|
+
applyEntrySelection(id, {
|
|
633
|
+
shouldToggle: event.metaKey || event.ctrlKey || event.shiftKey,
|
|
634
|
+
isolateMember: event.altKey && !(event.metaKey || event.ctrlKey || event.shiftKey),
|
|
635
|
+
})
|
|
622
636
|
},
|
|
623
637
|
[applyEntrySelection],
|
|
624
638
|
)
|
|
@@ -691,7 +705,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
691
705
|
if (endEvent.pointerId !== pointerId) return
|
|
692
706
|
cleanup()
|
|
693
707
|
if (!engaged) {
|
|
694
|
-
|
|
708
|
+
// Cmd/Ctrl+click without drag: toggle member (options object, not bare boolean).
|
|
709
|
+
applyEntrySelection(id, {
|
|
710
|
+
shouldToggle: true,
|
|
711
|
+
isolateMember: false,
|
|
712
|
+
})
|
|
695
713
|
}
|
|
696
714
|
}
|
|
697
715
|
|
|
@@ -3144,73 +3162,7 @@ function isFloorplanHierarchyVisible(
|
|
|
3144
3162
|
return true
|
|
3145
3163
|
}
|
|
3146
3164
|
|
|
3147
|
-
export
|
|
3148
|
-
node: AnyNode,
|
|
3149
|
-
nodes: Record<string, AnyNode>,
|
|
3150
|
-
viewState: {
|
|
3151
|
-
automaticDimensions?: boolean
|
|
3152
|
-
selected: boolean
|
|
3153
|
-
unit: 'metric' | 'imperial'
|
|
3154
|
-
metricNotation?: 'meters' | 'millimeters'
|
|
3155
|
-
purpose?: 'edit' | 'document'
|
|
3156
|
-
wallDimensionReference?: FloorplanWallDimensionReference
|
|
3157
|
-
highlighted: boolean
|
|
3158
|
-
hovered: boolean
|
|
3159
|
-
moving: boolean
|
|
3160
|
-
palette: FloorplanPalette | undefined
|
|
3161
|
-
},
|
|
3162
|
-
levelData?: unknown,
|
|
3163
|
-
): GeometryContext {
|
|
3164
|
-
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
|
3165
|
-
|
|
3166
|
-
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
|
3167
|
-
const children: AnyNode[] = Array.isArray(childIds)
|
|
3168
|
-
? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
|
|
3169
|
-
: []
|
|
3170
|
-
|
|
3171
|
-
const parentId = node.parentId as AnyNodeId | null
|
|
3172
|
-
const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
|
|
3173
|
-
|
|
3174
|
-
let siblings: AnyNode[] = []
|
|
3175
|
-
if (parent) {
|
|
3176
|
-
const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
|
|
3177
|
-
if (Array.isArray(parentChildIds)) {
|
|
3178
|
-
for (const sid of parentChildIds) {
|
|
3179
|
-
if (sid === node.id) continue
|
|
3180
|
-
const s = nodes[sid]
|
|
3181
|
-
if (s && s.type === node.type) siblings.push(s)
|
|
3182
|
-
}
|
|
3183
|
-
} else {
|
|
3184
|
-
siblings = Object.values(nodes).filter(
|
|
3185
|
-
(n) => n !== node && n.type === node.type && n.parentId === parentId,
|
|
3186
|
-
)
|
|
3187
|
-
}
|
|
3188
|
-
}
|
|
3189
|
-
|
|
3190
|
-
return {
|
|
3191
|
-
resolve,
|
|
3192
|
-
children,
|
|
3193
|
-
siblings,
|
|
3194
|
-
parent,
|
|
3195
|
-
levelData,
|
|
3196
|
-
extensions: createFloorplanContextExtensions({
|
|
3197
|
-
automaticDimensions: viewState.automaticDimensions,
|
|
3198
|
-
metricNotation: viewState.metricNotation ?? 'meters',
|
|
3199
|
-
purpose: viewState.purpose ?? 'edit',
|
|
3200
|
-
wallDimensionReference: viewState.wallDimensionReference,
|
|
3201
|
-
}),
|
|
3202
|
-
viewState: viewState.palette
|
|
3203
|
-
? {
|
|
3204
|
-
selected: viewState.selected,
|
|
3205
|
-
unit: viewState.unit,
|
|
3206
|
-
highlighted: viewState.highlighted,
|
|
3207
|
-
hovered: viewState.hovered,
|
|
3208
|
-
moving: viewState.moving,
|
|
3209
|
-
palette: viewState.palette,
|
|
3210
|
-
}
|
|
3211
|
-
: undefined,
|
|
3212
|
-
}
|
|
3213
|
-
}
|
|
3165
|
+
export const buildContext = buildFloorplanContext
|
|
3214
3166
|
|
|
3215
3167
|
export function collectFloorplanLinkedLevelNodes(
|
|
3216
3168
|
nodes: Record<string, AnyNode>,
|
|
@@ -3512,17 +3464,7 @@ function depsValueEqual(a: unknown, b: unknown): boolean {
|
|
|
3512
3464
|
* Sort is stable in modern JS engines, so siblings within the same
|
|
3513
3465
|
* bucket keep their DFS order (= scene tree order).
|
|
3514
3466
|
*/
|
|
3515
|
-
export
|
|
3516
|
-
switch (type) {
|
|
3517
|
-
case 'zone':
|
|
3518
|
-
return 0
|
|
3519
|
-
case 'slab':
|
|
3520
|
-
case 'ceiling':
|
|
3521
|
-
return 1
|
|
3522
|
-
default:
|
|
3523
|
-
return 2
|
|
3524
|
-
}
|
|
3525
|
-
}
|
|
3467
|
+
export { floorplanLayerRank }
|
|
3526
3468
|
|
|
3527
3469
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
3528
3470
|
if (a === b) return true
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { ItemNode, WallEvent, WallNode } from '@pascal-app/core'
|
|
3
|
+
import { Mesh, type Object3D, Vector3 } from 'three'
|
|
4
|
+
import { wallStrategy } from './placement-strategies'
|
|
5
|
+
import type { PlacementContext, SpatialValidators } from './placement-types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The wall frame the runtime builds in `updateWallGeometry`: origin at
|
|
9
|
+
* `wall.start` lifted to the supporting slab's elevation, yawed by the wall
|
|
10
|
+
* angle. Wall-local Y is therefore measured from the slab, NOT from world zero
|
|
11
|
+
* — which is exactly why snapping the world hit and the wall-local hit
|
|
12
|
+
* separately used to put the preview box and the item on different points.
|
|
13
|
+
*/
|
|
14
|
+
function makeWallFrame(wall: WallNode, slabElevation: number): Mesh {
|
|
15
|
+
const wallMesh = new Mesh()
|
|
16
|
+
wallMesh.position.set(wall.start[0], slabElevation, wall.start[1])
|
|
17
|
+
wallMesh.rotation.y = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
|
18
|
+
const collisionMesh = new Mesh()
|
|
19
|
+
wallMesh.add(collisionMesh)
|
|
20
|
+
wallMesh.updateMatrixWorld(true)
|
|
21
|
+
return collisionMesh
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeWall(overrides: Partial<WallNode> = {}): WallNode {
|
|
25
|
+
return {
|
|
26
|
+
id: 'wall_test',
|
|
27
|
+
type: 'wall',
|
|
28
|
+
parentId: 'level_test',
|
|
29
|
+
children: [],
|
|
30
|
+
start: [2.3, 1.7],
|
|
31
|
+
end: [8.3, 1.7],
|
|
32
|
+
thickness: 0.2,
|
|
33
|
+
...overrides,
|
|
34
|
+
} as WallNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeDraft(): ItemNode {
|
|
38
|
+
return {
|
|
39
|
+
id: 'item_draft',
|
|
40
|
+
type: 'item',
|
|
41
|
+
position: [0, 0, 0],
|
|
42
|
+
rotation: [0, 0, 0],
|
|
43
|
+
scale: [1, 1, 1],
|
|
44
|
+
children: [],
|
|
45
|
+
asset: {
|
|
46
|
+
id: 'asset_hold',
|
|
47
|
+
category: 'sport',
|
|
48
|
+
name: 'Climbing hold',
|
|
49
|
+
thumbnail: '',
|
|
50
|
+
source: 'library',
|
|
51
|
+
src: '',
|
|
52
|
+
dimensions: [0.65, 0.33, 0.63],
|
|
53
|
+
attachTo: 'wall-side',
|
|
54
|
+
offset: [0, 0.165, 0.1],
|
|
55
|
+
rotation: [0, 0, 0],
|
|
56
|
+
scale: [1, 1, 1],
|
|
57
|
+
},
|
|
58
|
+
} as unknown as ItemNode
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Front face of the wall: wall-local +Z. */
|
|
62
|
+
const FRONT_NORMAL: [number, number, number] = [0, 0, 1]
|
|
63
|
+
|
|
64
|
+
function makeWallEvent(wall: WallNode, collisionMesh: Object3D, localHit: Vector3): WallEvent {
|
|
65
|
+
const world = collisionMesh.localToWorld(localHit.clone())
|
|
66
|
+
return {
|
|
67
|
+
node: wall,
|
|
68
|
+
position: [world.x, world.y, world.z],
|
|
69
|
+
localPosition: [localHit.x, localHit.y, localHit.z],
|
|
70
|
+
normal: FRONT_NORMAL,
|
|
71
|
+
object: collisionMesh,
|
|
72
|
+
stopPropagation: () => undefined,
|
|
73
|
+
} as unknown as WallEvent
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const validators: SpatialValidators = {
|
|
77
|
+
canPlaceOnFloor: () => ({ valid: true }),
|
|
78
|
+
canPlaceOnWall: () => ({ valid: true }),
|
|
79
|
+
canPlaceOnCeiling: () => ({ valid: true }),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeContext(draft: ItemNode): PlacementContext {
|
|
83
|
+
return {
|
|
84
|
+
asset: draft.asset,
|
|
85
|
+
levelId: 'level_test',
|
|
86
|
+
draftItem: draft,
|
|
87
|
+
gridPosition: new Vector3(),
|
|
88
|
+
state: {
|
|
89
|
+
surface: 'wall',
|
|
90
|
+
wallId: 'wall_test',
|
|
91
|
+
roofSegmentId: null,
|
|
92
|
+
ceilingId: null,
|
|
93
|
+
surfaceItemId: null,
|
|
94
|
+
shelfId: null,
|
|
95
|
+
},
|
|
96
|
+
currentCursorRotationY: 0,
|
|
97
|
+
} as unknown as PlacementContext
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('wallStrategy.move', () => {
|
|
101
|
+
/**
|
|
102
|
+
* The preview wireframe (`cursorPosition`, world) and the committed node
|
|
103
|
+
* (`gridPosition`, wall-local) must describe ONE point. Snapping them
|
|
104
|
+
* independently drifted the box off the item by the slab elevation plus up to
|
|
105
|
+
* a grid step, and the commit then landed where the box was not.
|
|
106
|
+
*/
|
|
107
|
+
test.each([
|
|
108
|
+
['axis-aligned wall on an elevated slab', makeWall(), 0.4],
|
|
109
|
+
[
|
|
110
|
+
'diagonal wall off the world grid',
|
|
111
|
+
makeWall({ start: [1.15, 0.35], end: [5.15, 4.35] } as Partial<WallNode>),
|
|
112
|
+
0.15,
|
|
113
|
+
],
|
|
114
|
+
])('keeps the preview box on the committed point — %s', (_label, wall, slabElevation) => {
|
|
115
|
+
const collisionMesh = makeWallFrame(wall, slabElevation)
|
|
116
|
+
const draft = makeDraft()
|
|
117
|
+
const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
|
|
118
|
+
|
|
119
|
+
const result = wallStrategy.move(makeContext(draft), event, validators)
|
|
120
|
+
if (!result) throw new Error('expected a placement result')
|
|
121
|
+
|
|
122
|
+
const cursorFromNode = collisionMesh.localToWorld(new Vector3(...result.gridPosition))
|
|
123
|
+
expect(cursorFromNode.x).toBeCloseTo(result.cursorPosition[0], 6)
|
|
124
|
+
expect(cursorFromNode.y).toBeCloseTo(result.cursorPosition[1], 6)
|
|
125
|
+
expect(cursorFromNode.z).toBeCloseTo(result.cursorPosition[2], 6)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('mounts the wall-side preview on the hit face, not through the wall', () => {
|
|
129
|
+
const wall = makeWall()
|
|
130
|
+
const collisionMesh = makeWallFrame(wall, 0.4)
|
|
131
|
+
const draft = makeDraft()
|
|
132
|
+
const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
|
|
133
|
+
|
|
134
|
+
const result = wallStrategy.move(makeContext(draft), event, validators)
|
|
135
|
+
if (!result) throw new Error('expected a placement result')
|
|
136
|
+
|
|
137
|
+
// Front face → wall-local +thickness/2, matching ItemSystem's per-frame push.
|
|
138
|
+
expect(result.gridPosition[2]).toBeCloseTo(0.1, 6)
|
|
139
|
+
// The cursor frame IS the item frame, so the box's +Z (its depth) points out
|
|
140
|
+
// of the same face the item body extends from.
|
|
141
|
+
const outward = new Vector3(0, 0, 1).applyAxisAngle(
|
|
142
|
+
new Vector3(0, 1, 0),
|
|
143
|
+
result.cursorRotationY,
|
|
144
|
+
)
|
|
145
|
+
const wallNormal = new Vector3(0, 0, 1).applyAxisAngle(
|
|
146
|
+
new Vector3(0, 1, 0),
|
|
147
|
+
collisionMesh.parent!.rotation.y,
|
|
148
|
+
)
|
|
149
|
+
expect(outward.dot(wallNormal)).toBeCloseTo(1, 6)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('carries the wall auto-adjusted Y into the preview box', () => {
|
|
153
|
+
const wall = makeWall()
|
|
154
|
+
const collisionMesh = makeWallFrame(wall, 0.4)
|
|
155
|
+
const draft = makeDraft()
|
|
156
|
+
const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
|
|
157
|
+
|
|
158
|
+
const result = wallStrategy.move(makeContext(draft), event, {
|
|
159
|
+
...validators,
|
|
160
|
+
canPlaceOnWall: () => ({ valid: true, adjustedY: 0.05, wasAdjusted: true }),
|
|
161
|
+
})
|
|
162
|
+
if (!result) throw new Error('expected a placement result')
|
|
163
|
+
|
|
164
|
+
expect(result.gridPosition[1]).toBeCloseTo(0.05, 6)
|
|
165
|
+
expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -30,7 +30,6 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
|
|
30
30
|
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
|
|
31
31
|
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
|
|
32
32
|
import {
|
|
33
|
-
calculateCursorRotation,
|
|
34
33
|
calculateItemRotation,
|
|
35
34
|
getGridAlignedDimensions,
|
|
36
35
|
getSideFromNormal,
|
|
@@ -178,6 +177,51 @@ export const floorStrategy = {
|
|
|
178
177
|
// WALL STRATEGY
|
|
179
178
|
// ============================================================================
|
|
180
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the wall-local node position AND the world pose of the placement
|
|
182
|
+
* wireframe from ONE wall-local point, so the box can't drift from the item it
|
|
183
|
+
* previews.
|
|
184
|
+
*
|
|
185
|
+
* `event.object` is the wall's collision mesh — the frame `event.localPosition`
|
|
186
|
+
* was measured in — so `localToWorld` is the exact inverse of the hit. Snapping
|
|
187
|
+
* the raw world hit per axis instead (the old path) put the box on a different
|
|
188
|
+
* lattice than the node: wall-local X runs from `wall.start`, and wall-local Y
|
|
189
|
+
* is measured from the supporting slab's elevation, not from world zero.
|
|
190
|
+
*
|
|
191
|
+
* `z` follows the hosting convention rather than the hit depth: `wall` items
|
|
192
|
+
* center in the thickness, `wall-side` items mount on the hit face — mirroring
|
|
193
|
+
* `ItemSystem`'s per-frame push, so the wireframe's `z = 0` face lands flush
|
|
194
|
+
* with the wall instead of extending through it.
|
|
195
|
+
*/
|
|
196
|
+
function resolveWallPlacementPose(
|
|
197
|
+
event: WallEvent,
|
|
198
|
+
localX: number,
|
|
199
|
+
localY: number,
|
|
200
|
+
attachTo: 'wall' | 'wall-side',
|
|
201
|
+
side: 'front' | 'back',
|
|
202
|
+
itemRotation: number,
|
|
203
|
+
): {
|
|
204
|
+
position: [number, number, number]
|
|
205
|
+
cursorPosition: [number, number, number]
|
|
206
|
+
cursorRotationY: number
|
|
207
|
+
} {
|
|
208
|
+
const localZ =
|
|
209
|
+
attachTo === 'wall-side' ? ((event.node.thickness ?? 0.1) / 2) * (side === 'front' ? 1 : -1) : 0
|
|
210
|
+
event.object.updateWorldMatrix(true, false)
|
|
211
|
+
const world = event.object.localToWorld(new Vector3(localX, localY, localZ))
|
|
212
|
+
const wallYaw = -Math.atan2(
|
|
213
|
+
event.node.end[1] - event.node.start[1],
|
|
214
|
+
event.node.end[0] - event.node.start[0],
|
|
215
|
+
)
|
|
216
|
+
return {
|
|
217
|
+
position: [localX, localY, localZ],
|
|
218
|
+
cursorPosition: [world.x, world.y, world.z],
|
|
219
|
+
// Same composition the 2D floorplan resolves a wall child with
|
|
220
|
+
// (`resolveItemTransform`): the cursor frame IS the item frame.
|
|
221
|
+
cursorRotationY: wallYaw + itemRotation,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
181
225
|
export const wallStrategy = {
|
|
182
226
|
/**
|
|
183
227
|
* Handle wall:enter — transition from floor to wall surface.
|
|
@@ -201,11 +245,9 @@ export const wallStrategy = {
|
|
|
201
245
|
|
|
202
246
|
const side = getSideFromNormal(event.normal)
|
|
203
247
|
const itemRotation = calculateItemRotation(event.normal)
|
|
204
|
-
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
|
205
248
|
|
|
206
249
|
const x = snapToHalf(event.localPosition[0])
|
|
207
250
|
const y = snapToHalf(event.localPosition[1])
|
|
208
|
-
const z = snapToHalf(event.localPosition[2])
|
|
209
251
|
|
|
210
252
|
// Get auto-adjusted Y position from validator
|
|
211
253
|
const rawDims = ctx.draftItem
|
|
@@ -223,11 +265,12 @@ export const wallStrategy = {
|
|
|
223
265
|
)
|
|
224
266
|
|
|
225
267
|
const adjustedY = validation.adjustedY ?? y
|
|
268
|
+
const pose = resolveWallPlacementPose(event, x, adjustedY, attachTo, side, itemRotation)
|
|
226
269
|
|
|
227
270
|
return {
|
|
228
271
|
stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
|
|
229
272
|
nodeUpdate: {
|
|
230
|
-
position:
|
|
273
|
+
position: pose.position,
|
|
231
274
|
parentId: event.node.id,
|
|
232
275
|
// The draft may arrive from a roof-segment wall face.
|
|
233
276
|
roofSegmentId: undefined,
|
|
@@ -235,13 +278,9 @@ export const wallStrategy = {
|
|
|
235
278
|
side,
|
|
236
279
|
rotation: [0, itemRotation, 0],
|
|
237
280
|
},
|
|
238
|
-
cursorRotationY:
|
|
239
|
-
gridPosition:
|
|
240
|
-
cursorPosition:
|
|
241
|
-
snapToHalf(event.position[0]),
|
|
242
|
-
snapToHalf(event.position[1]),
|
|
243
|
-
snapToHalf(event.position[2]),
|
|
244
|
-
],
|
|
281
|
+
cursorRotationY: pose.cursorRotationY,
|
|
282
|
+
gridPosition: pose.position,
|
|
283
|
+
cursorPosition: pose.cursorPosition,
|
|
245
284
|
stopPropagation: true,
|
|
246
285
|
}
|
|
247
286
|
},
|
|
@@ -262,11 +301,10 @@ export const wallStrategy = {
|
|
|
262
301
|
|
|
263
302
|
const side = getSideFromNormal(event.normal)
|
|
264
303
|
const itemRotation = calculateItemRotation(event.normal)
|
|
265
|
-
const
|
|
304
|
+
const attachTo = ctx.draftItem.asset.attachTo as 'wall' | 'wall-side'
|
|
266
305
|
|
|
267
306
|
const snappedX = snapToHalf(event.localPosition[0])
|
|
268
307
|
const snappedY = snapToHalf(event.localPosition[1])
|
|
269
|
-
const snappedZ = snapToHalf(event.localPosition[2])
|
|
270
308
|
|
|
271
309
|
// Get auto-adjusted Y position from validator
|
|
272
310
|
const validation = validators.canPlaceOnWall(
|
|
@@ -275,23 +313,20 @@ export const wallStrategy = {
|
|
|
275
313
|
snappedX,
|
|
276
314
|
snappedY,
|
|
277
315
|
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
|
278
|
-
|
|
316
|
+
attachTo,
|
|
279
317
|
side,
|
|
280
318
|
[ctx.draftItem.id],
|
|
281
319
|
)
|
|
282
320
|
|
|
283
321
|
const adjustedY = validation.adjustedY ?? snappedY
|
|
322
|
+
const pose = resolveWallPlacementPose(event, snappedX, adjustedY, attachTo, side, itemRotation)
|
|
284
323
|
|
|
285
324
|
return {
|
|
286
|
-
gridPosition:
|
|
287
|
-
cursorPosition:
|
|
288
|
-
|
|
289
|
-
snapToHalf(event.position[1]),
|
|
290
|
-
snapToHalf(event.position[2]),
|
|
291
|
-
],
|
|
292
|
-
cursorRotationY: cursorRotation,
|
|
325
|
+
gridPosition: pose.position,
|
|
326
|
+
cursorPosition: pose.cursorPosition,
|
|
327
|
+
cursorRotationY: pose.cursorRotationY,
|
|
293
328
|
nodeUpdate: {
|
|
294
|
-
position:
|
|
329
|
+
position: pose.position,
|
|
295
330
|
side,
|
|
296
331
|
rotation: [0, itemRotation, 0],
|
|
297
332
|
},
|