@pascal-app/editor 0.7.0 → 0.9.1
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 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type { SiteNode } from '@pascal-app/core'
|
|
4
|
-
import { sceneRegistry, useScene } from '@pascal-app/core'
|
|
5
|
-
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
|
5
|
+
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
|
6
6
|
import { Html } from '@react-three/drei'
|
|
7
|
-
import { createPortal, useFrame } from '@react-three/fiber'
|
|
8
|
-
import { useMemo, useRef, useState } from 'react'
|
|
9
|
-
import type
|
|
7
|
+
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
|
8
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
9
|
+
import { type Camera, type Object3D, Vector3 } from 'three'
|
|
10
|
+
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
|
11
|
+
import useEditor from '../../store/use-editor'
|
|
12
|
+
import { formatMeasurement } from './measurement-pill'
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
type ViewportSize = {
|
|
15
|
+
width: number
|
|
16
|
+
height: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const htmlPosition = new Vector3()
|
|
20
|
+
|
|
21
|
+
function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize) {
|
|
22
|
+
htmlPosition.setFromMatrixPosition(el.matrixWorld)
|
|
23
|
+
htmlPosition.project(camera)
|
|
24
|
+
|
|
25
|
+
const widthHalf = size.width / 2
|
|
26
|
+
const heightHalf = size.height / 2
|
|
27
|
+
return [htmlPosition.x * widthHalf + widthHalf, -htmlPosition.y * heightHalf + heightHalf]
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
export function SiteEdgeLabels() {
|
|
@@ -29,12 +37,28 @@ export function SiteEdgeLabels() {
|
|
|
29
37
|
const node = state.nodes[firstRoot]
|
|
30
38
|
return node?.type === 'site' ? (node as SiteNode) : null
|
|
31
39
|
})
|
|
40
|
+
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
|
32
41
|
const unit = useViewer((state) => state.unit)
|
|
33
|
-
const
|
|
42
|
+
const cameraMode = useViewer((state) => state.cameraMode)
|
|
43
|
+
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
|
44
|
+
const camera = useThree((state) => state.camera)
|
|
45
|
+
// Drei Html can hold the previous default camera across a camera-object swap.
|
|
46
|
+
const calculateLabelPosition = useCallback(
|
|
47
|
+
(el: Object3D, _camera: Camera, size: ViewportSize) => calculateHtmlPosition(el, camera, size),
|
|
48
|
+
[camera],
|
|
49
|
+
)
|
|
34
50
|
|
|
35
51
|
const siteNodeId = siteNode?.id
|
|
52
|
+
const livePolygon = useLiveNodeOverrides((state) => {
|
|
53
|
+
if (!siteNodeId) return null
|
|
54
|
+
return (state.overrides.get(siteNodeId)?.polygon as SiteNode['polygon'] | undefined) ?? null
|
|
55
|
+
})
|
|
56
|
+
const polygon = livePolygon?.points ?? siteNode?.polygon?.points ?? []
|
|
57
|
+
const shouldShowLabels =
|
|
58
|
+
Boolean(siteNodeId) &&
|
|
59
|
+
activeHandleDrag?.nodeId === siteNodeId &&
|
|
60
|
+
activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
|
|
36
61
|
|
|
37
|
-
const isNight = theme === 'dark'
|
|
38
62
|
const color = isNight ? '#ffffff' : '#111111'
|
|
39
63
|
const shadowColor = isNight ? '#111111' : '#ffffff'
|
|
40
64
|
|
|
@@ -55,7 +79,6 @@ export function SiteEdgeLabels() {
|
|
|
55
79
|
})
|
|
56
80
|
|
|
57
81
|
const edges = useMemo(() => {
|
|
58
|
-
const polygon = siteNode?.polygon?.points ?? []
|
|
59
82
|
if (polygon.length < 2) return []
|
|
60
83
|
return polygon.map(([x1, z1], i) => {
|
|
61
84
|
const [x2, z2] = polygon[(i + 1) % polygon.length]!
|
|
@@ -64,16 +87,17 @@ export function SiteEdgeLabels() {
|
|
|
64
87
|
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
|
|
65
88
|
return { midX, midZ, dist }
|
|
66
89
|
})
|
|
67
|
-
}, [
|
|
90
|
+
}, [polygon])
|
|
68
91
|
|
|
69
|
-
if (!siteObj || edges.length === 0) return null
|
|
92
|
+
if (!shouldShowLabels || !siteObj || edges.length === 0) return null
|
|
70
93
|
|
|
71
94
|
return createPortal(
|
|
72
95
|
<>
|
|
73
96
|
{edges.map((edge, i) => (
|
|
74
97
|
<Html
|
|
75
98
|
center
|
|
76
|
-
|
|
99
|
+
calculatePosition={calculateLabelPosition}
|
|
100
|
+
key={`${cameraMode}-${camera.uuid}-edge-${i}`}
|
|
77
101
|
occlude
|
|
78
102
|
position={[edge.midX, 0.5, edge.midZ]}
|
|
79
103
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
type BuildingNode,
|
|
6
|
+
type LevelNode,
|
|
7
|
+
resolveBuildingForLevel,
|
|
8
|
+
resolveLevelId,
|
|
9
|
+
type SlabNode,
|
|
10
|
+
type SurfaceHoleMetadata,
|
|
11
|
+
sceneRegistry,
|
|
12
|
+
useLiveNodeOverrides,
|
|
13
|
+
useScene,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
16
|
+
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
|
17
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
18
|
+
import {
|
|
19
|
+
BufferGeometry,
|
|
20
|
+
DoubleSide,
|
|
21
|
+
Float32BufferAttribute,
|
|
22
|
+
type Object3D,
|
|
23
|
+
ShapeUtils,
|
|
24
|
+
Vector2,
|
|
25
|
+
} from 'three'
|
|
26
|
+
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
|
27
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
28
|
+
import useEditor from '../../store/use-editor'
|
|
29
|
+
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
30
|
+
import { swallowNextClick } from './handles/use-handle-drag'
|
|
31
|
+
|
|
32
|
+
const ACCENT = 0x83_81_ed
|
|
33
|
+
const SURFACE_OFFSET = 0.01
|
|
34
|
+
const MIN_HIT_HEIGHT = 0.16
|
|
35
|
+
|
|
36
|
+
const NO_RAYCAST = () => null
|
|
37
|
+
let restoreNodeClickSuppression: (() => void) | null = null
|
|
38
|
+
|
|
39
|
+
const outlineMaterial = new LineBasicNodeMaterial({
|
|
40
|
+
color: ACCENT,
|
|
41
|
+
depthTest: false,
|
|
42
|
+
depthWrite: false,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const fillMaterial = new MeshBasicNodeMaterial({
|
|
46
|
+
color: ACCENT,
|
|
47
|
+
transparent: true,
|
|
48
|
+
opacity: 0.5,
|
|
49
|
+
depthTest: false,
|
|
50
|
+
depthWrite: false,
|
|
51
|
+
side: DoubleSide,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Invisible hit targets must stay out of the scene MRT pass; keep them on
|
|
55
|
+
// EDITOR_LAYER even though they never write color.
|
|
56
|
+
const hitMaterial = new MeshBasicNodeMaterial({
|
|
57
|
+
color: ACCENT,
|
|
58
|
+
colorWrite: false,
|
|
59
|
+
depthTest: false,
|
|
60
|
+
depthWrite: false,
|
|
61
|
+
opacity: 0,
|
|
62
|
+
side: DoubleSide,
|
|
63
|
+
transparent: true,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
type HolePolygon = Array<[number, number]>
|
|
67
|
+
|
|
68
|
+
function makeFillGeometry(hole: HolePolygon, y: number): BufferGeometry {
|
|
69
|
+
const contour2d = hole.map(([x, z]) => new Vector2(x, z))
|
|
70
|
+
const positions: number[] = []
|
|
71
|
+
const indices: number[] = []
|
|
72
|
+
|
|
73
|
+
for (const point of contour2d) {
|
|
74
|
+
positions.push(point.x, y, point.y)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const triangles = ShapeUtils.triangulateShape(contour2d, [])
|
|
78
|
+
for (const tri of triangles) {
|
|
79
|
+
indices.push(tri[0]!, tri[2]!, tri[1]!)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const geometry = new BufferGeometry()
|
|
83
|
+
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
84
|
+
geometry.setIndex(indices)
|
|
85
|
+
geometry.computeVertexNormals()
|
|
86
|
+
geometry.computeBoundingSphere()
|
|
87
|
+
return geometry
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry {
|
|
91
|
+
const positions: number[] = []
|
|
92
|
+
|
|
93
|
+
for (let index = 0; index < hole.length; index += 1) {
|
|
94
|
+
const [ax, az] = hole[index]!
|
|
95
|
+
const [bx, bz] = hole[(index + 1) % hole.length]!
|
|
96
|
+
positions.push(ax, y, az, bx, y, bz)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const geometry = new BufferGeometry()
|
|
100
|
+
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
101
|
+
geometry.computeBoundingSphere()
|
|
102
|
+
return geometry
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry {
|
|
106
|
+
const topY = centerY + height / 2
|
|
107
|
+
const bottomY = centerY - height / 2
|
|
108
|
+
const positions: number[] = []
|
|
109
|
+
const indices: number[] = []
|
|
110
|
+
|
|
111
|
+
for (const [x, z] of hole) positions.push(x, topY, z)
|
|
112
|
+
for (const [x, z] of hole) positions.push(x, bottomY, z)
|
|
113
|
+
|
|
114
|
+
const triangles = ShapeUtils.triangulateShape(
|
|
115
|
+
hole.map(([x, z]) => new Vector2(x, z)),
|
|
116
|
+
[],
|
|
117
|
+
)
|
|
118
|
+
const bottomOffset = hole.length
|
|
119
|
+
for (const tri of triangles) {
|
|
120
|
+
indices.push(tri[0]!, tri[2]!, tri[1]!)
|
|
121
|
+
indices.push(bottomOffset + tri[0]!, bottomOffset + tri[1]!, bottomOffset + tri[2]!)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (let index = 0; index < hole.length; index += 1) {
|
|
125
|
+
const nextIndex = (index + 1) % hole.length
|
|
126
|
+
indices.push(index, nextIndex, bottomOffset + nextIndex)
|
|
127
|
+
indices.push(index, bottomOffset + nextIndex, bottomOffset + index)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const geometry = new BufferGeometry()
|
|
131
|
+
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
132
|
+
geometry.setIndex(indices)
|
|
133
|
+
geometry.computeVertexNormals()
|
|
134
|
+
geometry.computeBoundingSphere()
|
|
135
|
+
return geometry
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getSurfaceY(slab: SlabNode): number {
|
|
139
|
+
const elevation = slab.elevation ?? 0.05
|
|
140
|
+
return (elevation > 0 ? elevation : 0) + SURFACE_OFFSET
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getHitCenterY(slab: SlabNode): number {
|
|
144
|
+
const elevation = slab.elevation ?? 0.05
|
|
145
|
+
const surfaceTop = elevation > 0 ? elevation : 0
|
|
146
|
+
// Keep the hit box's TOP at the slab surface (never poke above it) so the
|
|
147
|
+
// slab's centre thickness/height handle, which sits on top, always wins the
|
|
148
|
+
// raycast over the hole's selectable area.
|
|
149
|
+
return surfaceTop - getHitHeight(slab) / 2
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getHitHeight(slab: SlabNode): number {
|
|
153
|
+
return Math.max(Math.abs(slab.elevation ?? 0.05), MIN_HIT_HEIGHT)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clearHoveredHoleIfMatches(nodeId: string, holeIndex: number) {
|
|
157
|
+
const { hoveredHole, setHoveredHole } = useEditor.getState()
|
|
158
|
+
if (hoveredHole?.nodeId === nodeId && hoveredHole.holeIndex === holeIndex) {
|
|
159
|
+
setHoveredHole(null)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function selectOwnedNode(ownerId: string, expectedType: 'stair' | 'elevator') {
|
|
164
|
+
const nodes = useScene.getState().nodes
|
|
165
|
+
const owner = nodes[ownerId as AnyNodeId]
|
|
166
|
+
if (owner?.type !== expectedType) return
|
|
167
|
+
|
|
168
|
+
const selectedId = owner.id as AnyNodeId
|
|
169
|
+
|
|
170
|
+
if (owner.type === 'elevator') {
|
|
171
|
+
const buildingId =
|
|
172
|
+
owner.parentId && nodes[owner.parentId as AnyNodeId]?.type === 'building'
|
|
173
|
+
? (owner.parentId as BuildingNode['id'])
|
|
174
|
+
: null
|
|
175
|
+
|
|
176
|
+
useViewer
|
|
177
|
+
.getState()
|
|
178
|
+
.setSelection(
|
|
179
|
+
buildingId ? { buildingId, selectedIds: [selectedId] } : { selectedIds: [selectedId] },
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const levelId = resolveLevelId(owner, nodes)
|
|
185
|
+
const buildingId =
|
|
186
|
+
levelId && levelId !== 'default' ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null
|
|
187
|
+
|
|
188
|
+
useViewer.getState().setSelection({
|
|
189
|
+
...(buildingId ? { buildingId: buildingId as BuildingNode['id'] } : {}),
|
|
190
|
+
...(levelId && levelId !== 'default' ? { levelId: levelId as LevelNode['id'] } : {}),
|
|
191
|
+
selectedIds: [selectedId],
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resetPointerCursor() {
|
|
196
|
+
if (document.body.style.cursor === 'pointer') {
|
|
197
|
+
document.body.style.cursor = ''
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stopPointerPropagation(event: ThreeEvent<PointerEvent>) {
|
|
202
|
+
event.stopPropagation()
|
|
203
|
+
suppressBoxSelectForPointer(event)
|
|
204
|
+
event.nativeEvent.stopPropagation()
|
|
205
|
+
event.nativeEvent.stopImmediatePropagation()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function suppressNodeClickUntilPointerUp() {
|
|
209
|
+
restoreNodeClickSuppression?.()
|
|
210
|
+
const previousInputDragging = useViewer.getState().inputDragging
|
|
211
|
+
useViewer.getState().setInputDragging(true)
|
|
212
|
+
swallowNextClick()
|
|
213
|
+
|
|
214
|
+
const restore = () => {
|
|
215
|
+
if (restoreNodeClickSuppression !== restore) return
|
|
216
|
+
useViewer.getState().setInputDragging(previousInputDragging)
|
|
217
|
+
window.removeEventListener('pointerup', restore)
|
|
218
|
+
window.removeEventListener('pointercancel', restore)
|
|
219
|
+
restoreNodeClickSuppression = null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
restoreNodeClickSuppression = restore
|
|
223
|
+
window.addEventListener('pointerup', restore, { once: true })
|
|
224
|
+
window.addEventListener('pointercancel', restore, { once: true })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function releaseNodeClickSuppression() {
|
|
228
|
+
restoreNodeClickSuppression?.()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function SlabHoleHighlights() {
|
|
232
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
233
|
+
const hoveredHole = useEditor((state) => state.hoveredHole)
|
|
234
|
+
const setHoveredHole = useEditor((state) => state.setHoveredHole)
|
|
235
|
+
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (hoveredHole && !selectedIds.includes(hoveredHole.nodeId as AnyNodeId)) {
|
|
238
|
+
setHoveredHole(null)
|
|
239
|
+
}
|
|
240
|
+
}, [hoveredHole, selectedIds, setHoveredHole])
|
|
241
|
+
|
|
242
|
+
if (selectedIds.length === 0) return null
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<>
|
|
246
|
+
{selectedIds.map((id) => (
|
|
247
|
+
<SelectedSlabHoleHighlights key={id} slabId={id} />
|
|
248
|
+
))}
|
|
249
|
+
</>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) {
|
|
254
|
+
const node = useScene((state) => state.nodes[slabId as AnyNodeId])
|
|
255
|
+
const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
|
|
256
|
+
const hoveredHole = useEditor((state) => state.hoveredHole)
|
|
257
|
+
const editingHole = useEditor((state) => state.editingHole)
|
|
258
|
+
const setHoveredHole = useEditor((state) => state.setHoveredHole)
|
|
259
|
+
|
|
260
|
+
const slab = node?.type === 'slab' ? (node as SlabNode) : null
|
|
261
|
+
const effectiveSlab = slab ? ({ ...slab, ...(override ?? {}) } as SlabNode) : null
|
|
262
|
+
const holes = effectiveSlab?.holes ?? []
|
|
263
|
+
const holeMetadata = effectiveSlab?.holeMetadata ?? []
|
|
264
|
+
|
|
265
|
+
// Portal the highlights into the slab's OWN object — exactly how
|
|
266
|
+
// NodeArrowHandles portals into a node object. This is what makes R3F deliver
|
|
267
|
+
// pointer events to the hit meshes (portaling into the raw scene renders but
|
|
268
|
+
// never receives hover/click), and the children inherit the slab's world
|
|
269
|
+
// transform so the holes sit in slab-local space with no manual math.
|
|
270
|
+
const [slabObject, setSlabObject] = useState<Object3D | null>(
|
|
271
|
+
() => sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null,
|
|
272
|
+
)
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
let frameId = 0
|
|
275
|
+
const resolve = () => {
|
|
276
|
+
const next = sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null
|
|
277
|
+
setSlabObject((cur) => (cur === next ? cur : next))
|
|
278
|
+
if (!next) {
|
|
279
|
+
frameId = window.requestAnimationFrame(resolve)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
resolve()
|
|
283
|
+
return () => {
|
|
284
|
+
if (frameId) window.cancelAnimationFrame(frameId)
|
|
285
|
+
}
|
|
286
|
+
}, [slabId])
|
|
287
|
+
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (hoveredHole?.nodeId !== slabId) return
|
|
290
|
+
if (!effectiveSlab || hoveredHole.holeIndex >= holes.length) {
|
|
291
|
+
setHoveredHole(null)
|
|
292
|
+
}
|
|
293
|
+
}, [effectiveSlab, holes.length, hoveredHole, slabId, setHoveredHole])
|
|
294
|
+
|
|
295
|
+
if (!effectiveSlab || holes.length === 0 || !slabObject) return null
|
|
296
|
+
|
|
297
|
+
const surfaceY = getSurfaceY(effectiveSlab)
|
|
298
|
+
const hitCenterY = getHitCenterY(effectiveSlab)
|
|
299
|
+
const hitHeight = getHitHeight(effectiveSlab)
|
|
300
|
+
|
|
301
|
+
return createPortal(
|
|
302
|
+
<group>
|
|
303
|
+
{holes.map((hole, holeIndex) => {
|
|
304
|
+
const metadata = holeMetadata[holeIndex]
|
|
305
|
+
if (hole.length < 3) return null
|
|
306
|
+
|
|
307
|
+
const isActive =
|
|
308
|
+
(hoveredHole?.nodeId === slabId && hoveredHole.holeIndex === holeIndex) ||
|
|
309
|
+
(editingHole?.nodeId === slabId && editingHole.holeIndex === holeIndex)
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<SlabHoleHighlight
|
|
313
|
+
active={isActive}
|
|
314
|
+
hitCenterY={hitCenterY}
|
|
315
|
+
hitHeight={hitHeight}
|
|
316
|
+
hole={hole}
|
|
317
|
+
holeIndex={holeIndex}
|
|
318
|
+
key={`${slabId}:${holeIndex}`}
|
|
319
|
+
metadata={metadata}
|
|
320
|
+
slabId={slabId}
|
|
321
|
+
surfaceY={surfaceY}
|
|
322
|
+
/>
|
|
323
|
+
)
|
|
324
|
+
})}
|
|
325
|
+
</group>,
|
|
326
|
+
slabObject,
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function SlabHoleHighlight({
|
|
331
|
+
active,
|
|
332
|
+
hitCenterY,
|
|
333
|
+
hitHeight,
|
|
334
|
+
hole,
|
|
335
|
+
holeIndex,
|
|
336
|
+
metadata,
|
|
337
|
+
slabId,
|
|
338
|
+
surfaceY,
|
|
339
|
+
}: {
|
|
340
|
+
active: boolean
|
|
341
|
+
hitCenterY: number
|
|
342
|
+
hitHeight: number
|
|
343
|
+
hole: HolePolygon
|
|
344
|
+
holeIndex: number
|
|
345
|
+
metadata: SurfaceHoleMetadata | undefined
|
|
346
|
+
slabId: string
|
|
347
|
+
surfaceY: number
|
|
348
|
+
}) {
|
|
349
|
+
const fillGeometry = useMemo(() => makeFillGeometry(hole, surfaceY), [hole, surfaceY])
|
|
350
|
+
const outlineGeometry = useMemo(() => makeOutlineGeometry(hole, surfaceY), [hole, surfaceY])
|
|
351
|
+
const hitGeometry = useMemo(
|
|
352
|
+
() => makeHitGeometry(hole, hitCenterY, hitHeight),
|
|
353
|
+
[hitCenterY, hitHeight, hole],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
useEffect(() => () => fillGeometry.dispose(), [fillGeometry])
|
|
357
|
+
useEffect(() => () => outlineGeometry.dispose(), [outlineGeometry])
|
|
358
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
359
|
+
useEffect(() => () => clearHoveredHoleIfMatches(slabId, holeIndex), [holeIndex, slabId])
|
|
360
|
+
|
|
361
|
+
const handlePointerEnter = useCallback(
|
|
362
|
+
(event: ThreeEvent<PointerEvent>) => {
|
|
363
|
+
event.stopPropagation()
|
|
364
|
+
useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex })
|
|
365
|
+
if (document.body.style.cursor !== 'pointer') {
|
|
366
|
+
document.body.style.cursor = 'pointer'
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
[holeIndex, slabId],
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
const handlePointerLeave = useCallback(
|
|
373
|
+
(event: ThreeEvent<PointerEvent>) => {
|
|
374
|
+
event.stopPropagation()
|
|
375
|
+
clearHoveredHoleIfMatches(slabId, holeIndex)
|
|
376
|
+
resetPointerCursor()
|
|
377
|
+
},
|
|
378
|
+
[holeIndex, slabId],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const handlePointerDown = useCallback(
|
|
382
|
+
(event: ThreeEvent<PointerEvent>) => {
|
|
383
|
+
if (event.button !== 0) return
|
|
384
|
+
stopPointerPropagation(event)
|
|
385
|
+
suppressNodeClickUntilPointerUp()
|
|
386
|
+
useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex })
|
|
387
|
+
|
|
388
|
+
// Auto-managed cutouts (stair / elevator) jump to their owner so the
|
|
389
|
+
// user edits the source rather than the synced hole. Everything else —
|
|
390
|
+
// manual holes and holes that predate holeMetadata — opens the editor.
|
|
391
|
+
if (metadata?.source === 'stair' && metadata.stairId) {
|
|
392
|
+
useEditor.getState().setEditingHole(null)
|
|
393
|
+
useEditor.getState().setHoveredHole(null)
|
|
394
|
+
resetPointerCursor()
|
|
395
|
+
selectOwnedNode(metadata.stairId, 'stair')
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
if (metadata?.source === 'elevator' && metadata.elevatorId) {
|
|
399
|
+
useEditor.getState().setEditingHole(null)
|
|
400
|
+
useEditor.getState().setHoveredHole(null)
|
|
401
|
+
resetPointerCursor()
|
|
402
|
+
selectOwnedNode(metadata.elevatorId, 'elevator')
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex })
|
|
407
|
+
useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
|
|
408
|
+
},
|
|
409
|
+
[holeIndex, metadata, slabId],
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
const handlePointerUp = useCallback((event: ThreeEvent<PointerEvent>) => {
|
|
413
|
+
releaseNodeClickSuppression()
|
|
414
|
+
stopPointerPropagation(event)
|
|
415
|
+
}, [])
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<>
|
|
419
|
+
<mesh
|
|
420
|
+
frustumCulled={false}
|
|
421
|
+
geometry={fillGeometry}
|
|
422
|
+
material={fillMaterial}
|
|
423
|
+
raycast={NO_RAYCAST}
|
|
424
|
+
renderOrder={1004}
|
|
425
|
+
/>
|
|
426
|
+
<lineSegments
|
|
427
|
+
frustumCulled={false}
|
|
428
|
+
geometry={outlineGeometry}
|
|
429
|
+
material={outlineMaterial}
|
|
430
|
+
raycast={NO_RAYCAST}
|
|
431
|
+
renderOrder={1005}
|
|
432
|
+
/>
|
|
433
|
+
{active && (
|
|
434
|
+
<>
|
|
435
|
+
<mesh
|
|
436
|
+
frustumCulled={false}
|
|
437
|
+
geometry={fillGeometry}
|
|
438
|
+
material={fillMaterial}
|
|
439
|
+
raycast={NO_RAYCAST}
|
|
440
|
+
renderOrder={1006}
|
|
441
|
+
/>
|
|
442
|
+
<lineSegments
|
|
443
|
+
frustumCulled={false}
|
|
444
|
+
geometry={outlineGeometry}
|
|
445
|
+
material={outlineMaterial}
|
|
446
|
+
raycast={NO_RAYCAST}
|
|
447
|
+
renderOrder={1007}
|
|
448
|
+
/>
|
|
449
|
+
</>
|
|
450
|
+
)}
|
|
451
|
+
<mesh
|
|
452
|
+
frustumCulled={false}
|
|
453
|
+
geometry={hitGeometry}
|
|
454
|
+
layers={EDITOR_LAYER}
|
|
455
|
+
material={hitMaterial}
|
|
456
|
+
onPointerDown={handlePointerDown}
|
|
457
|
+
onPointerEnter={handlePointerEnter}
|
|
458
|
+
onPointerLeave={handlePointerLeave}
|
|
459
|
+
onPointerUp={handlePointerUp}
|
|
460
|
+
renderOrder={1006}
|
|
461
|
+
/>
|
|
462
|
+
</>
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export default SlabHoleHighlights
|