@pascal-app/editor 0.9.2 → 1.0.0-beta.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 +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
minBrushRadius,
|
|
5
|
+
raycastTerrain,
|
|
6
|
+
type SiteNode,
|
|
7
|
+
surfaceHeightAt,
|
|
8
|
+
type TerrainField,
|
|
9
|
+
terrainFieldOf,
|
|
10
|
+
useLiveTerrain,
|
|
11
|
+
} from '@pascal-app/core'
|
|
12
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
13
|
+
import { Html } from '@react-three/drei'
|
|
14
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
15
|
+
import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
|
|
16
|
+
import {
|
|
17
|
+
BufferGeometry,
|
|
18
|
+
Float32BufferAttribute,
|
|
19
|
+
type Group,
|
|
20
|
+
type Line,
|
|
21
|
+
Raycaster,
|
|
22
|
+
Vector2,
|
|
23
|
+
} from 'three'
|
|
24
|
+
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
25
|
+
import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt'
|
|
26
|
+
import { brushRingColor } from '../../../lib/terrain-verb-color'
|
|
27
|
+
import useEditor from '../../../store/use-editor'
|
|
28
|
+
import { formatMeasurement } from '../../editor/measurement-pill'
|
|
29
|
+
|
|
30
|
+
/** Segments in the cursor ring. 64 reads as a circle at any usable brush size. */
|
|
31
|
+
const RING_SEGMENTS = 64
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lift above the surface, so the ring is not z-fighting the ground it traces.
|
|
35
|
+
*
|
|
36
|
+
* Two centimetres only works because the ring samples `surfaceHeightAt` — the
|
|
37
|
+
* plane of the triangle under each point — and not the bilinear `heightAt`. Those
|
|
38
|
+
* differ by up to a quarter of a cell's warp, which on steep ground is decimetres,
|
|
39
|
+
* so a bilinear ring would submerge on half of every cell no matter how large this
|
|
40
|
+
* gets. `depthTest={false}` hides that on screen but not in the exported GLB.
|
|
41
|
+
*/
|
|
42
|
+
const RING_LIFT = 0.02
|
|
43
|
+
|
|
44
|
+
/** The ring is decoration; it must never intercept a pointer ray. */
|
|
45
|
+
const NO_RAYCAST = () => null
|
|
46
|
+
|
|
47
|
+
export type TerrainBrushFocus = {
|
|
48
|
+
radius: number
|
|
49
|
+
x: number
|
|
50
|
+
z: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The brush ring, drawn *on the terrain surface* rather than as a flat disc.
|
|
55
|
+
*
|
|
56
|
+
* Following the surface is the whole point: a flat ring on sloped ground tells
|
|
57
|
+
* the user nothing about what the brush will touch, because the footprint is a
|
|
58
|
+
* cylinder in XZ and the interesting question is which part of the slope falls
|
|
59
|
+
* inside it. Sampling the ring's height per segment answers that directly.
|
|
60
|
+
*
|
|
61
|
+
* Updated in `useFrame` from a ref, never through React state — the ring tracks
|
|
62
|
+
* the pointer, and routing that through a re-render would re-run every hook in
|
|
63
|
+
* the tool subtree at pointer rate.
|
|
64
|
+
*
|
|
65
|
+
* On `EDITOR_LAYER`, non-negotiably: an overlay mesh left on the scene layer
|
|
66
|
+
* poisons the MRT scene pass and makes FrontSide geometry render see-through.
|
|
67
|
+
*/
|
|
68
|
+
export const TerrainBrushCursor: React.FC<{
|
|
69
|
+
focusRef: MutableRefObject<TerrainBrushFocus | null>
|
|
70
|
+
site: SiteNode
|
|
71
|
+
}> = ({ focusRef, site }) => {
|
|
72
|
+
const { camera, gl } = useThree()
|
|
73
|
+
const radius = useEditor((state) => state.terrainBrush.radius)
|
|
74
|
+
const shape = useEditor((state) => state.terrainBrush.shape)
|
|
75
|
+
// The ring is the only thing at the point of action that can say what the next
|
|
76
|
+
// drag will do — its geometry is identical for all four verbs, so colour is the
|
|
77
|
+
// one channel left. Keep the mapping centralized in `lib/terrain-verb-color`.
|
|
78
|
+
const verb = useEditor((state) => state.terrainVerb)
|
|
79
|
+
const sampling = useEditor((state) => state.terrainSampling)
|
|
80
|
+
const color = brushRingColor(verb, sampling)
|
|
81
|
+
const unit = useViewer((state) => state.unit)
|
|
82
|
+
|
|
83
|
+
const lineRef = useRef<Line>(null)
|
|
84
|
+
const centerRef = useRef<Group>(null)
|
|
85
|
+
const heightRef = useRef<HTMLSpanElement>(null)
|
|
86
|
+
const lastHeightLabelRef = useRef('')
|
|
87
|
+
const pointerRef = useRef<{ x: number; y: number } | null>(null)
|
|
88
|
+
const raycaster = useRef(new Raycaster())
|
|
89
|
+
const ndc = useRef(new Vector2())
|
|
90
|
+
|
|
91
|
+
const geometry = useMemo(() => {
|
|
92
|
+
const positions = new Float32Array((RING_SEGMENTS + 1) * 3)
|
|
93
|
+
const buffer = new BufferGeometry()
|
|
94
|
+
buffer.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
95
|
+
return buffer
|
|
96
|
+
}, [])
|
|
97
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const canvas = gl.domElement
|
|
101
|
+
const track = (event: PointerEvent) => {
|
|
102
|
+
const rect = canvas.getBoundingClientRect()
|
|
103
|
+
pointerRef.current = {
|
|
104
|
+
x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
105
|
+
y: -((event.clientY - rect.top) / rect.height) * 2 + 1,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const freeze = () => {
|
|
109
|
+
pointerRef.current = null
|
|
110
|
+
}
|
|
111
|
+
canvas.addEventListener('pointermove', track)
|
|
112
|
+
canvas.addEventListener('pointerleave', freeze)
|
|
113
|
+
return () => {
|
|
114
|
+
canvas.removeEventListener('pointermove', track)
|
|
115
|
+
canvas.removeEventListener('pointerleave', freeze)
|
|
116
|
+
}
|
|
117
|
+
}, [gl])
|
|
118
|
+
|
|
119
|
+
useFrame(() => {
|
|
120
|
+
const line = lineRef.current
|
|
121
|
+
const center = centerRef.current
|
|
122
|
+
const pointer = pointerRef.current
|
|
123
|
+
if (!(line && center)) return
|
|
124
|
+
if (!(pointer || focusRef.current)) {
|
|
125
|
+
line.visible = false
|
|
126
|
+
center.visible = false
|
|
127
|
+
if (heightRef.current) heightRef.current.style.display = 'none'
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Two fields, deliberately. Aiming uses the stroke's *snapshot* so the ring
|
|
132
|
+
// sits exactly where the next dab will land — the tool raycasts the snapshot
|
|
133
|
+
// too, and a cursor that disagreed with the brush by even a few centimetres
|
|
134
|
+
// would read as lag. Drawing uses the live field so the ring drapes over the
|
|
135
|
+
// hill as it rises. Outside a stroke the two are the same field.
|
|
136
|
+
const stroke = useLiveTerrain.getState().strokeOf(site.id)
|
|
137
|
+
const surface: TerrainField = stroke?.field ?? terrainFieldOf(site) ?? sculptFieldForSite(site)
|
|
138
|
+
const aim: TerrainField = stroke?.snapshot ?? surface
|
|
139
|
+
|
|
140
|
+
let hit: { x: number; z: number } | null = null
|
|
141
|
+
if (pointer) {
|
|
142
|
+
ndc.current.set(pointer.x, pointer.y)
|
|
143
|
+
raycaster.current.setFromCamera(ndc.current, camera)
|
|
144
|
+
const { origin, direction } = raycaster.current.ray
|
|
145
|
+
hit = raycastTerrain(
|
|
146
|
+
aim,
|
|
147
|
+
[origin.x, origin.y, origin.z],
|
|
148
|
+
[direction.x, direction.y, direction.z],
|
|
149
|
+
)
|
|
150
|
+
if (hit && terrainPointInsideSite(site, hit.x, hit.z)) {
|
|
151
|
+
focusRef.current = { radius: Math.max(radius, minBrushRadius(aim)), x: hit.x, z: hit.z }
|
|
152
|
+
} else {
|
|
153
|
+
hit = null
|
|
154
|
+
focusRef.current = null
|
|
155
|
+
}
|
|
156
|
+
} else if (focusRef.current) {
|
|
157
|
+
hit = terrainPointInsideSite(site, focusRef.current.x, focusRef.current.z)
|
|
158
|
+
? focusRef.current
|
|
159
|
+
: null
|
|
160
|
+
}
|
|
161
|
+
if (!hit) {
|
|
162
|
+
line.visible = false
|
|
163
|
+
center.visible = false
|
|
164
|
+
if (heightRef.current) heightRef.current.style.display = 'none'
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
line.visible = true
|
|
168
|
+
center.visible = true
|
|
169
|
+
|
|
170
|
+
const attribute = line.geometry.getAttribute('position')
|
|
171
|
+
const array = attribute.array as Float32Array
|
|
172
|
+
// The same floor the tool applies at pointer-down. Drawing the stored radius
|
|
173
|
+
// instead would draw a ring smaller than the footprint the next dab actually
|
|
174
|
+
// gets — on a coarse lot the ring would be the one thing insisting the brush
|
|
175
|
+
// is 2 m wide while the stroke is 1.5 spacings wide.
|
|
176
|
+
const drawn = Math.max(radius, minBrushRadius(aim))
|
|
177
|
+
focusRef.current = { radius: drawn, x: hit.x, z: hit.z }
|
|
178
|
+
for (let index = 0; index <= RING_SEGMENTS; index++) {
|
|
179
|
+
const angle = (index / RING_SEGMENTS) * Math.PI * 2
|
|
180
|
+
// The square brush uses Chebyshev distance, so its rim is a square: scale
|
|
181
|
+
// the unit circle out to the box so the ring shows the actual footprint
|
|
182
|
+
// rather than an inscribed circle that under-reports it.
|
|
183
|
+
const cos = Math.cos(angle)
|
|
184
|
+
const sin = Math.sin(angle)
|
|
185
|
+
const scale =
|
|
186
|
+
shape === 'square' ? 1 / Math.max(Math.abs(cos), Math.abs(sin), Number.EPSILON) : 1
|
|
187
|
+
const x = hit.x + cos * drawn * scale
|
|
188
|
+
const z = hit.z + sin * drawn * scale
|
|
189
|
+
array[index * 3] = x
|
|
190
|
+
array[index * 3 + 1] = surfaceHeightAt(surface, x, z) + RING_LIFT
|
|
191
|
+
array[index * 3 + 2] = z
|
|
192
|
+
}
|
|
193
|
+
attribute.needsUpdate = true
|
|
194
|
+
line.geometry.computeBoundingSphere()
|
|
195
|
+
|
|
196
|
+
const centerHeight = surfaceHeightAt(surface, hit.x, hit.z)
|
|
197
|
+
center.position.set(hit.x, centerHeight + RING_LIFT, hit.z)
|
|
198
|
+
const heightLabel = `H ${formatMeasurement(centerHeight, unit)}`
|
|
199
|
+
if (heightRef.current) {
|
|
200
|
+
heightRef.current.style.display = ''
|
|
201
|
+
if (heightLabel !== lastHeightLabelRef.current) {
|
|
202
|
+
heightRef.current.textContent = heightLabel
|
|
203
|
+
lastHeightLabelRef.current = heightLabel
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<>
|
|
210
|
+
<line
|
|
211
|
+
frustumCulled={false}
|
|
212
|
+
geometry={geometry}
|
|
213
|
+
layers={EDITOR_LAYER}
|
|
214
|
+
raycast={NO_RAYCAST}
|
|
215
|
+
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
|
216
|
+
ref={lineRef}
|
|
217
|
+
renderOrder={13}
|
|
218
|
+
>
|
|
219
|
+
<lineBasicNodeMaterial color={color} depthTest={false} depthWrite={false} linewidth={2} />
|
|
220
|
+
</line>
|
|
221
|
+
<group ref={centerRef} visible={false}>
|
|
222
|
+
<mesh
|
|
223
|
+
layers={EDITOR_LAYER}
|
|
224
|
+
raycast={NO_RAYCAST}
|
|
225
|
+
renderOrder={14}
|
|
226
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
227
|
+
>
|
|
228
|
+
<circleGeometry args={[0.055, 24]} />
|
|
229
|
+
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
|
|
230
|
+
</mesh>
|
|
231
|
+
<Html
|
|
232
|
+
center
|
|
233
|
+
position={[0, 0.18, 0]}
|
|
234
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
235
|
+
zIndexRange={[20, 0]}
|
|
236
|
+
>
|
|
237
|
+
<span
|
|
238
|
+
className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-2 py-1 font-medium font-mono text-foreground text-xs shadow-sm backdrop-blur"
|
|
239
|
+
ref={heightRef}
|
|
240
|
+
/>
|
|
241
|
+
</Html>
|
|
242
|
+
</group>
|
|
243
|
+
</>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export default TerrainBrushCursor
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type HeightPatch,
|
|
5
|
+
type SiteNode,
|
|
6
|
+
type TerrainField,
|
|
7
|
+
terrainFieldOf,
|
|
8
|
+
useLiveTerrain,
|
|
9
|
+
useScene,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
|
12
|
+
import { useFrame } from '@react-three/fiber'
|
|
13
|
+
import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
|
|
14
|
+
import { BufferAttribute, BufferGeometry, type LineSegments, Vector2 } from 'three'
|
|
15
|
+
import { float, positionLocal, smoothstep, uniform } from 'three/tsl'
|
|
16
|
+
import { LineBasicNodeMaterial } from 'three/webgpu'
|
|
17
|
+
import { sculptFieldForSite, terrainPointInsideSite } from '../../../lib/terrain-sculpt'
|
|
18
|
+
import type { TerrainBrushFocus } from './terrain-brush-cursor'
|
|
19
|
+
|
|
20
|
+
const GRID_LIFT = 0.012
|
|
21
|
+
const NO_RAYCAST = () => null
|
|
22
|
+
|
|
23
|
+
function writeGridHeights(
|
|
24
|
+
attribute: BufferAttribute,
|
|
25
|
+
field: TerrainField,
|
|
26
|
+
patch: HeightPatch | null,
|
|
27
|
+
): void {
|
|
28
|
+
const col0 = patch ? Math.max(0, patch.col0) : 0
|
|
29
|
+
const row0 = patch ? Math.max(0, patch.row0) : 0
|
|
30
|
+
const col1 = patch ? Math.min(field.cols, patch.col0 + patch.cols) : field.cols
|
|
31
|
+
const row1 = patch ? Math.min(field.rows, patch.row0 + patch.rows) : field.rows
|
|
32
|
+
if (col0 >= col1 || row0 >= row1) return
|
|
33
|
+
|
|
34
|
+
const positions = attribute.array as Float32Array
|
|
35
|
+
for (let row = row0; row < row1; row += 1) {
|
|
36
|
+
for (let col = col0; col < col1; col += 1) {
|
|
37
|
+
const sampleIndex = row * field.cols + col
|
|
38
|
+
positions[sampleIndex * 3 + 1] = (field.heights[sampleIndex] ?? 0) * field.step + GRID_LIFT
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
attribute.clearUpdateRanges()
|
|
43
|
+
const first = (row0 * field.cols + col0) * 3
|
|
44
|
+
const last = ((row1 - 1) * field.cols + (col1 - 1)) * 3 + 2
|
|
45
|
+
attribute.addUpdateRange(first, last - first + 1)
|
|
46
|
+
attribute.needsUpdate = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createGridGeometry(field: TerrainField, site: Pick<SiteNode, 'polygon'>): BufferGeometry {
|
|
50
|
+
const geometry = new BufferGeometry()
|
|
51
|
+
const positions = new Float32Array(field.cols * field.rows * 3)
|
|
52
|
+
const indices: number[] = []
|
|
53
|
+
|
|
54
|
+
for (let row = 0; row < field.rows; row += 1) {
|
|
55
|
+
for (let col = 0; col < field.cols; col += 1) {
|
|
56
|
+
const sampleIndex = row * field.cols + col
|
|
57
|
+
positions[sampleIndex * 3] = field.origin[0] + col * field.spacing
|
|
58
|
+
positions[sampleIndex * 3 + 2] = field.origin[1] + row * field.spacing
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let indexOffset = 0
|
|
63
|
+
for (let row = 0; row < field.rows; row += 1) {
|
|
64
|
+
for (let col = 0; col < field.cols - 1; col += 1) {
|
|
65
|
+
const sampleIndex = row * field.cols + col
|
|
66
|
+
const x = field.origin[0] + col * field.spacing
|
|
67
|
+
const z = field.origin[1] + row * field.spacing
|
|
68
|
+
if (
|
|
69
|
+
terrainPointInsideSite(site, x, z) &&
|
|
70
|
+
terrainPointInsideSite(site, x + field.spacing, z)
|
|
71
|
+
) {
|
|
72
|
+
indices[indexOffset++] = sampleIndex
|
|
73
|
+
indices[indexOffset++] = sampleIndex + 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (let col = 0; col < field.cols; col += 1) {
|
|
78
|
+
for (let row = 0; row < field.rows - 1; row += 1) {
|
|
79
|
+
const sampleIndex = row * field.cols + col
|
|
80
|
+
const x = field.origin[0] + col * field.spacing
|
|
81
|
+
const z = field.origin[1] + row * field.spacing
|
|
82
|
+
if (
|
|
83
|
+
terrainPointInsideSite(site, x, z) &&
|
|
84
|
+
terrainPointInsideSite(site, x, z + field.spacing)
|
|
85
|
+
) {
|
|
86
|
+
indices[indexOffset++] = sampleIndex
|
|
87
|
+
indices[indexOffset++] = sampleIndex + field.cols
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const positionAttribute = new BufferAttribute(positions, 3)
|
|
93
|
+
geometry.setAttribute('position', positionAttribute)
|
|
94
|
+
geometry.setIndex(indices)
|
|
95
|
+
writeGridHeights(positionAttribute, field, null)
|
|
96
|
+
geometry.computeBoundingSphere()
|
|
97
|
+
return geometry
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A terrain-following lattice shown for the entire lifetime of sculpt mode.
|
|
102
|
+
*
|
|
103
|
+
* The ordinary editor grid is a planar snapping aid, so making it visible here
|
|
104
|
+
* would leave it cutting through hills and hiding below excavations. This grid
|
|
105
|
+
* shares the terrain samples instead: every row and column visibly bends with
|
|
106
|
+
* the surface, and live dabs rewrite only the affected height span. It stays on
|
|
107
|
+
* `GRID_LAYER` so scene geometry depth-occludes it instead of the overlay pass
|
|
108
|
+
* compositing it through walls and objects.
|
|
109
|
+
*/
|
|
110
|
+
export function TerrainSculptGrid({
|
|
111
|
+
focusRef,
|
|
112
|
+
site,
|
|
113
|
+
}: {
|
|
114
|
+
focusRef: MutableRefObject<TerrainBrushFocus | null>
|
|
115
|
+
site: SiteNode
|
|
116
|
+
}) {
|
|
117
|
+
const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance)
|
|
118
|
+
const targetRef = useRef<LineSegments>(null)
|
|
119
|
+
|
|
120
|
+
const field = useMemo(() => sculptFieldForSite(site), [site])
|
|
121
|
+
const geometry = useMemo(() => createGridGeometry(field, site), [field, site])
|
|
122
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
123
|
+
|
|
124
|
+
const focusCenter = useMemo(() => uniform(new Vector2()), [])
|
|
125
|
+
const focusRadius = useMemo(() => uniform(1), [])
|
|
126
|
+
const focusVisible = useMemo(() => uniform(0), [])
|
|
127
|
+
const material = useMemo(() => {
|
|
128
|
+
const baseOpacity = appearance === 'dark' ? 0.07 : 0.05
|
|
129
|
+
const focusBoost = appearance === 'dark' ? 0.11 : 0.09
|
|
130
|
+
const distance = positionLocal.xz.sub(focusCenter).length()
|
|
131
|
+
const focus = float(1)
|
|
132
|
+
.sub(smoothstep(focusRadius.mul(1.05), focusRadius.mul(2.5), distance))
|
|
133
|
+
.mul(focusVisible)
|
|
134
|
+
|
|
135
|
+
return new LineBasicNodeMaterial({
|
|
136
|
+
color: appearance === 'dark' ? '#cbd5e1' : '#475569',
|
|
137
|
+
depthTest: true,
|
|
138
|
+
depthWrite: false,
|
|
139
|
+
opacityNode: float(baseOpacity).add(focus.mul(focusBoost)),
|
|
140
|
+
transparent: true,
|
|
141
|
+
})
|
|
142
|
+
}, [appearance, focusCenter, focusRadius, focusVisible])
|
|
143
|
+
useEffect(() => () => material.dispose(), [material])
|
|
144
|
+
|
|
145
|
+
useFrame(() => {
|
|
146
|
+
const focus = focusRef.current
|
|
147
|
+
focusVisible.value = focus ? 1 : 0
|
|
148
|
+
if (!focus) return
|
|
149
|
+
focusCenter.value.set(focus.x, focus.z)
|
|
150
|
+
focusRadius.value = focus.radius
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
let lastPatch = useLiveTerrain.getState().strokeOf(site.id)?.lastPatch ?? null
|
|
155
|
+
|
|
156
|
+
return useLiveTerrain.subscribe((state) => {
|
|
157
|
+
const target = targetRef.current
|
|
158
|
+
if (!target) return
|
|
159
|
+
const attribute = target.geometry.getAttribute('position') as BufferAttribute
|
|
160
|
+
const stroke = state.strokes.get(site.id)
|
|
161
|
+
|
|
162
|
+
if (stroke?.lastPatch && stroke.lastPatch !== lastPatch) {
|
|
163
|
+
lastPatch = stroke.lastPatch
|
|
164
|
+
writeGridHeights(attribute, stroke.field, stroke.lastPatch)
|
|
165
|
+
target.geometry.computeBoundingSphere()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!stroke && lastPatch) {
|
|
170
|
+
lastPatch = null
|
|
171
|
+
const current = useScene.getState().nodes[site.id]
|
|
172
|
+
if (current?.type !== 'site') return
|
|
173
|
+
const restored = terrainFieldOf(current) ?? sculptFieldForSite(current)
|
|
174
|
+
if (restored.cols !== field.cols || restored.rows !== field.rows) return
|
|
175
|
+
writeGridHeights(attribute, restored, null)
|
|
176
|
+
target.geometry.computeBoundingSphere()
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}, [field.cols, field.rows, site.id])
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<lineSegments
|
|
183
|
+
frustumCulled={false}
|
|
184
|
+
geometry={geometry}
|
|
185
|
+
layers={GRID_LAYER}
|
|
186
|
+
material={material}
|
|
187
|
+
raycast={NO_RAYCAST}
|
|
188
|
+
ref={targetRef}
|
|
189
|
+
renderOrder={11}
|
|
190
|
+
/>
|
|
191
|
+
)
|
|
192
|
+
}
|