@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,301 @@
|
|
|
1
|
+
// Sculpt-mode helpers that are pure enough to test without a canvas: sizing the
|
|
2
|
+
// field to a site, resolving the flatten target, and the commit.
|
|
3
|
+
//
|
|
4
|
+
// The tool component owns pointer plumbing; everything here is a function of
|
|
5
|
+
// data, which is what lets the interesting decisions (grid extent, undo
|
|
6
|
+
// granularity, when a stroke is worth persisting) be asserted in unit tests
|
|
7
|
+
// instead of e2e.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type BrushSettings,
|
|
11
|
+
commitTerrainField,
|
|
12
|
+
createTerrainField,
|
|
13
|
+
DEFAULT_TERRAIN_SPACING,
|
|
14
|
+
type HeightPatch,
|
|
15
|
+
isDatumField,
|
|
16
|
+
minBrushRadius,
|
|
17
|
+
pointInPolygon2D,
|
|
18
|
+
quantize,
|
|
19
|
+
runAsSingleSceneHistoryStep,
|
|
20
|
+
type SiteNode,
|
|
21
|
+
sampleTarget,
|
|
22
|
+
type TerrainField,
|
|
23
|
+
type TerrainVerb,
|
|
24
|
+
terrainFieldOf,
|
|
25
|
+
useLiveTerrain,
|
|
26
|
+
useScene,
|
|
27
|
+
} from '@pascal-app/core'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Field sizes we allow, all `2ⁿ+1` so a future LOD halving lands on existing
|
|
31
|
+
* samples instead of interpolating a seam.
|
|
32
|
+
*/
|
|
33
|
+
const FIELD_SIZES = [33, 65, 129, 257] as const
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sample count a fresh field gets, chosen to cover the site polygon at roughly
|
|
37
|
+
* `DEFAULT_TERRAIN_SPACING` and never exceeding 257² (66 049 samples — about
|
|
38
|
+
* four furnished rooms' worth of vertices).
|
|
39
|
+
*
|
|
40
|
+
* Sizing from the polygon rather than a fixed extent matters because the extent
|
|
41
|
+
* is baked at creation: the field is the ground for the whole session, and a
|
|
42
|
+
* grid that stops short of the lot line leaves a visible cliff where the terrain
|
|
43
|
+
* mesh ends and the flat fill resumes. Padding by one spacing keeps the boundary
|
|
44
|
+
* line itself on interpolated ground rather than on the clamped edge row.
|
|
45
|
+
*/
|
|
46
|
+
export function fieldExtentForSite(site: Pick<SiteNode, 'polygon'> | null | undefined): {
|
|
47
|
+
origin: [number, number]
|
|
48
|
+
cols: number
|
|
49
|
+
rows: number
|
|
50
|
+
spacing: number
|
|
51
|
+
} {
|
|
52
|
+
const points = site?.polygon?.points ?? []
|
|
53
|
+
if (points.length < 3) {
|
|
54
|
+
const half = ((65 - 1) / 2) * DEFAULT_TERRAIN_SPACING
|
|
55
|
+
return { origin: [-half, -half], cols: 65, rows: 65, spacing: DEFAULT_TERRAIN_SPACING }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let minX = Number.POSITIVE_INFINITY
|
|
59
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
60
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
61
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
62
|
+
for (const [x, z] of points) {
|
|
63
|
+
if (x < minX) minX = x
|
|
64
|
+
if (x > maxX) maxX = x
|
|
65
|
+
if (z < minZ) minZ = z
|
|
66
|
+
if (z > maxZ) maxZ = z
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The grid is square: a rectangular field would need two spacings or two
|
|
70
|
+
// sample counts, and both make every downstream index calculation branchier
|
|
71
|
+
// for no visual gain.
|
|
72
|
+
const pad = DEFAULT_TERRAIN_SPACING * 2
|
|
73
|
+
const span = Math.max(maxX - minX, maxZ - minZ) + pad * 2
|
|
74
|
+
const centerX = (minX + maxX) / 2
|
|
75
|
+
const centerZ = (minZ + maxZ) / 2
|
|
76
|
+
|
|
77
|
+
const wanted = Math.ceil(span / DEFAULT_TERRAIN_SPACING) + 1
|
|
78
|
+
const size = FIELD_SIZES.find((candidate) => candidate >= wanted) ?? FIELD_SIZES.at(-1)!
|
|
79
|
+
// Spacing stretches when the lot is bigger than 257 samples can cover at the
|
|
80
|
+
// default: coarser ground beats ground that stops at the property line.
|
|
81
|
+
const spacing = Math.max(DEFAULT_TERRAIN_SPACING, span / (size - 1))
|
|
82
|
+
const half = ((size - 1) / 2) * spacing
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
origin: [centerX - half, centerZ - half],
|
|
86
|
+
cols: size,
|
|
87
|
+
rows: size,
|
|
88
|
+
spacing,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The field a sculpt stroke on `site` should start from: its existing terrain,
|
|
94
|
+
* or a fresh flat one sized to the lot.
|
|
95
|
+
*
|
|
96
|
+
* Not `terrainFieldForEdit` — that takes explicit options, and the whole point
|
|
97
|
+
* here is that the tool must not have to know how to size a grid.
|
|
98
|
+
*/
|
|
99
|
+
export function sculptFieldForSite(site: SiteNode): TerrainField {
|
|
100
|
+
return terrainFieldOf(site) ?? createTerrainField(fieldExtentForSite(site))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether an XZ point belongs to the editable property footprint. */
|
|
104
|
+
export function terrainPointInsideSite(
|
|
105
|
+
site: Pick<SiteNode, 'polygon'>,
|
|
106
|
+
x: number,
|
|
107
|
+
z: number,
|
|
108
|
+
): boolean {
|
|
109
|
+
const polygon = site.polygon?.points ?? []
|
|
110
|
+
return polygon.length >= 3 && pointInPolygon2D([x, z], polygon, { includeBoundary: true })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Keep a rectangular brush patch inside the property footprint.
|
|
115
|
+
*
|
|
116
|
+
* The heightfield stays padded and square for compact storage and predictable
|
|
117
|
+
* partial uploads, but that implementation extent is not editable land. Samples
|
|
118
|
+
* outside the polygon retain their current values even when a brush overlaps the
|
|
119
|
+
* property line.
|
|
120
|
+
*/
|
|
121
|
+
export function clipTerrainPatchToSite(
|
|
122
|
+
field: TerrainField,
|
|
123
|
+
patch: HeightPatch,
|
|
124
|
+
site: Pick<SiteNode, 'polygon'>,
|
|
125
|
+
): HeightPatch {
|
|
126
|
+
let heights: Int16Array | null = null
|
|
127
|
+
|
|
128
|
+
for (let row = 0; row < patch.rows; row += 1) {
|
|
129
|
+
for (let col = 0; col < patch.cols; col += 1) {
|
|
130
|
+
const fieldCol = patch.col0 + col
|
|
131
|
+
const fieldRow = patch.row0 + row
|
|
132
|
+
const x = field.origin[0] + fieldCol * field.spacing
|
|
133
|
+
const z = field.origin[1] + fieldRow * field.spacing
|
|
134
|
+
if (terrainPointInsideSite(site, x, z)) continue
|
|
135
|
+
|
|
136
|
+
const patchIndex = row * patch.cols + col
|
|
137
|
+
const previous = field.heights[fieldRow * field.cols + fieldCol] ?? 0
|
|
138
|
+
if ((patch.heights[patchIndex] ?? 0) === previous) continue
|
|
139
|
+
heights ??= patch.heights.slice()
|
|
140
|
+
heights[patchIndex] = previous
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return heights ? { ...patch, heights } : patch
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The site node the sculpt tool acts on, read straight from the scene.
|
|
149
|
+
*
|
|
150
|
+
* The tool and panel get this from a subscribed selector; the keyboard handler
|
|
151
|
+
* cannot (it is one imperative listener, not a component), and open-coding
|
|
152
|
+
* "root[0], is it a site?" a third time is how the three drift apart.
|
|
153
|
+
*/
|
|
154
|
+
export function activeSiteNode(): SiteNode | null {
|
|
155
|
+
const { nodes, rootNodeIds } = useScene.getState()
|
|
156
|
+
const root = rootNodeIds[0]
|
|
157
|
+
const node = root ? nodes[root] : undefined
|
|
158
|
+
return node?.type === 'site' ? (node as SiteNode) : null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Largest brush radius offered, in metres.
|
|
163
|
+
*
|
|
164
|
+
* A taste call, unlike the minimum: 20 m covers a building pad in one pass and is
|
|
165
|
+
* about where a bigger brush stops being aimable and starts being `flattenSite`.
|
|
166
|
+
*/
|
|
167
|
+
export const MAX_BRUSH_RADIUS = 20
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The legal brush-radius range for `site`, low end first.
|
|
171
|
+
*
|
|
172
|
+
* Exists because two controls set this value — the panel's Size slider and the
|
|
173
|
+
* `[`/`]` keys — and both used to carry their own hardcoded `0.5`/`20`, with a
|
|
174
|
+
* comment in the keyboard handler asserting they matched. They did, but nothing
|
|
175
|
+
* held them to it, and the shared minimum was wrong anyway: it was a flat 0.5 m
|
|
176
|
+
* while the real floor scales with the field's spacing, which
|
|
177
|
+
* `fieldExtentForSite` stretches on large lots. Below that floor a drag paints
|
|
178
|
+
* nothing at all (see `MIN_BRUSH_RADIUS_IN_SPACINGS`).
|
|
179
|
+
*
|
|
180
|
+
* The max is clamped to stay above the min so the range never inverts on a lot
|
|
181
|
+
* big enough that the minimum passes 20 m — the brush ends up pinned to one
|
|
182
|
+
* usable size, which is honest, rather than to an empty range.
|
|
183
|
+
*/
|
|
184
|
+
export function brushRadiusRange(site: SiteNode | null | undefined): [number, number] {
|
|
185
|
+
// Spacing, not the field: this runs on every render of the panel, and
|
|
186
|
+
// `sculptFieldForSite` would decode or allocate up to 257² samples to read one
|
|
187
|
+
// number off the result. An existing field's spacing is on the node; a lot with
|
|
188
|
+
// no terrain yet gets the spacing it *would* be given.
|
|
189
|
+
const spacing = site
|
|
190
|
+
? (site.terrain?.spacing ?? fieldExtentForSite(site).spacing)
|
|
191
|
+
: DEFAULT_TERRAIN_SPACING
|
|
192
|
+
const min = minBrushRadius({ spacing })
|
|
193
|
+
return [min, Math.max(min, MAX_BRUSH_RADIUS)]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `radius` clamped into `brushRadiusRange(site)`. */
|
|
197
|
+
export function clampBrushRadius(site: SiteNode | null | undefined, radius: number): number {
|
|
198
|
+
const [min, max] = brushRadiusRange(site)
|
|
199
|
+
return Math.min(max, Math.max(min, radius))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The absolute height a `flatten` stroke aims at.
|
|
204
|
+
*
|
|
205
|
+
* `null` target means the user never picked one, so the first click samples the
|
|
206
|
+
* ground under the cursor — flatten stays usable without ever opening a number
|
|
207
|
+
* field, and "flatten this pad to match that corner" is a two-step gesture.
|
|
208
|
+
*/
|
|
209
|
+
export function resolveFlattenTarget(
|
|
210
|
+
field: TerrainField,
|
|
211
|
+
explicitTarget: number | null,
|
|
212
|
+
x: number,
|
|
213
|
+
z: number,
|
|
214
|
+
): number {
|
|
215
|
+
return explicitTarget ?? sampleTarget(field, x, z)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type SculptCommit = {
|
|
219
|
+
verb: TerrainVerb
|
|
220
|
+
settings: BrushSettings
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Drop any stroke in flight on `site` before a write that replaces the whole field.
|
|
225
|
+
*
|
|
226
|
+
* Every reader prefers the live stroke over the persisted terrain (that is what
|
|
227
|
+
* makes a drag consistent), so a lot-wide write landing *under* a live stroke is
|
|
228
|
+
* invisible: the ground keeps showing the stroke, and the stroke's own commit —
|
|
229
|
+
* computed from a snapshot taken before the write — then overwrites it. The user
|
|
230
|
+
* sees a button that did nothing, with no error and nothing in history to undo.
|
|
231
|
+
*
|
|
232
|
+
* Reachable because the brush and these controls are usable at the same moment.
|
|
233
|
+
* On touch it takes no timing at all: one finger holds a stroke while the other
|
|
234
|
+
* taps the panel. With a mouse a stroke stays live across a wheel zoom's 500 ms
|
|
235
|
+
* `cameraDragging` window, and a held pointer that leaves the canvas keeps its
|
|
236
|
+
* capture until release.
|
|
237
|
+
*
|
|
238
|
+
* Abandoning rather than committing first: the pending dabs are the thing the
|
|
239
|
+
* user is replacing, so keeping them would mean "level the lot, except where I
|
|
240
|
+
* happened to be mid-drag". Ending the live stroke without committing reverts it
|
|
241
|
+
* exactly, the same as Escape.
|
|
242
|
+
*/
|
|
243
|
+
function abandonLiveStroke(site: Pick<SiteNode, 'id'>): void {
|
|
244
|
+
useLiveTerrain.getState().end(site.id)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Flatten the whole site to one height in a single step.
|
|
249
|
+
*
|
|
250
|
+
* The brush cannot do this well and should not have to: covering a lot with dabs
|
|
251
|
+
* is dozens of strokes, and any sample the brush misses leaves a ridge that is
|
|
252
|
+
* invisible until a slab lands on it. Sizing from `fieldExtentForSite` means the
|
|
253
|
+
* result covers the padded extent, so the property line stays on flat ground too.
|
|
254
|
+
*/
|
|
255
|
+
export function flattenSite(site: SiteNode, metres: number): void {
|
|
256
|
+
abandonLiveStroke(site)
|
|
257
|
+
const field = sculptFieldForSite(site)
|
|
258
|
+
const value = quantize(field, metres)
|
|
259
|
+
const heights = new Int16Array(field.heights.length)
|
|
260
|
+
heights.fill(value)
|
|
261
|
+
commitStroke(site.id, { ...field, heights })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Drop a site's terrain entirely, back to the implicit flat ground.
|
|
266
|
+
*
|
|
267
|
+
* Distinct from flattening to 0: this removes the `terrain` field, so the scene
|
|
268
|
+
* returns to the exact state it had before terrain was ever touched rather than
|
|
269
|
+
* carrying a field of zeroes. Undo-able like any other sculpt.
|
|
270
|
+
*/
|
|
271
|
+
export function resetSiteTerrain(site: SiteNode): void {
|
|
272
|
+
// Before the `site.terrain` guard, not after: a stroke on a site that has no
|
|
273
|
+
// persisted terrain yet is exactly the case that reaches the early return, and
|
|
274
|
+
// leaving it live would keep showing sculpted ground the scene graph has none of.
|
|
275
|
+
abandonLiveStroke(site)
|
|
276
|
+
if (!site.terrain) return
|
|
277
|
+
runAsSingleSceneHistoryStep(useScene, () => {
|
|
278
|
+
useScene.getState().updateNode(site.id, { terrain: undefined })
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Persist a finished stroke as exactly one undo step.
|
|
284
|
+
*
|
|
285
|
+
* `runAsSingleSceneHistoryStep` collapses the write, which matters because a
|
|
286
|
+
* stroke is one user action even though it produced dozens of dabs — without it,
|
|
287
|
+
* undo would walk back through the stroke dab by dab, which is both surprising
|
|
288
|
+
* and unbounded in length.
|
|
289
|
+
*
|
|
290
|
+
* A stroke that leaves the field at the datum writes `undefined` rather than
|
|
291
|
+
* ~11 KB of base64 zeroes: sculpting up and then flattening back down should
|
|
292
|
+
* return the scene to the state it would have had if terrain were never touched,
|
|
293
|
+
* not merely to a visually identical one.
|
|
294
|
+
*/
|
|
295
|
+
export function commitStroke(siteId: SiteNode['id'], field: TerrainField): void {
|
|
296
|
+
runAsSingleSceneHistoryStep(useScene, () => {
|
|
297
|
+
useScene.getState().updateNode(siteId, {
|
|
298
|
+
terrain: isDatumField(field) ? undefined : commitTerrainField(field),
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { TerrainVerb } from '@pascal-app/core'
|
|
3
|
+
import { brushRingColor } from './terrain-verb-color'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The brush ring is the only thing at the point of action that can say what the
|
|
7
|
+
* next drag will do. Sculpt is a *sustained* mode, so the verb a user armed a
|
|
8
|
+
* minute ago is still armed — and the ring's geometry is identical for all four
|
|
9
|
+
* verbs, which leaves colour as the only channel carrying that state.
|
|
10
|
+
*
|
|
11
|
+
* These assert the property rather than the palette: which hex is taste, but "no
|
|
12
|
+
* two states look alike" is correctness, and it is the thing that silently breaks
|
|
13
|
+
* when a fifth verb is added (terrace is planned).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth']
|
|
17
|
+
|
|
18
|
+
describe('brushRingColor', () => {
|
|
19
|
+
test('no two verbs share a colour', () => {
|
|
20
|
+
const colors = VERBS.map((verb) => brushRingColor(verb, false))
|
|
21
|
+
expect(new Set(colors).size).toBe(VERBS.length)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('raise and lower are the pair that must never be confused', () => {
|
|
25
|
+
// The original bug: one amber ring for both, so the only way to know which way
|
|
26
|
+
// the ground would move was to look away from the cursor at the panel.
|
|
27
|
+
expect(brushRingColor('raise', false)).not.toBe(brushRingColor('lower', false))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('flatten and smooth differ too', () => {
|
|
31
|
+
// Both are "neither up nor down", but one drives ground to a target height you
|
|
32
|
+
// can set wrongly and the other only softens what is there.
|
|
33
|
+
expect(brushRingColor('flatten', false)).not.toBe(brushRingColor('smooth', false))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('sampling overrides every verb with one distinct colour', () => {
|
|
37
|
+
// While the eyedropper is armed the click does not apply the verb at all, so
|
|
38
|
+
// the ring must not read as any of them.
|
|
39
|
+
const sampled = VERBS.map((verb) => brushRingColor(verb, true))
|
|
40
|
+
expect(new Set(sampled).size).toBe(1)
|
|
41
|
+
for (const verb of VERBS) {
|
|
42
|
+
expect(brushRingColor(verb, true)).not.toBe(brushRingColor(verb, false))
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('every verb resolves to a colour, so a new verb cannot fall through', () => {
|
|
47
|
+
for (const verb of VERBS) {
|
|
48
|
+
expect(brushRingColor(verb, false)).toMatch(/^#[0-9a-f]{6}$/)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The verb→colour mapping, shared by the brush ring on the canvas and the verb
|
|
2
|
+
// picker in the panel.
|
|
3
|
+
//
|
|
4
|
+
// Its own module because the two consumers cannot import each other: the ring is an
|
|
5
|
+
// R3F component (`useFrame`, `three`) and the panel is a plain sidebar surface that
|
|
6
|
+
// must not drag a render loop into its module graph. Duplicating four hex strings
|
|
7
|
+
// across them would be the version that silently drifts — and drift here is not
|
|
8
|
+
// cosmetic, since the whole point is that the two surfaces agree.
|
|
9
|
+
|
|
10
|
+
import type { TerrainVerb } from '@pascal-app/core'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One colour per verb, because the brush ring's geometry is identical for all four.
|
|
14
|
+
*
|
|
15
|
+
* A single colour made raise and lower visually indistinguishable: same circle, same
|
|
16
|
+
* amber, opposite effect — so the only way to know which way the next drag moves the
|
|
17
|
+
* ground was to look away from the cursor at the panel. The verb is also the one piece
|
|
18
|
+
* of sculpt state a user loses track of between strokes, since the mode is sustained
|
|
19
|
+
* and an intent set a minute ago is still armed.
|
|
20
|
+
*
|
|
21
|
+
* Values are the repo's existing palette (`ui/primitives/color-dot.tsx`) used with its
|
|
22
|
+
* established meanings — green for additive, red for destructive (`#ef4444` is already
|
|
23
|
+
* the delete cursor badge and the 2D delete stroke), blue and violet for the two
|
|
24
|
+
* operations that move ground neither up nor down. Amber is deliberately retired rather
|
|
25
|
+
* than kept for one verb: reusing the old colour for `raise` alone would leave every
|
|
26
|
+
* existing screenshot ambiguous.
|
|
27
|
+
*
|
|
28
|
+
* All four differ, including flatten vs smooth. Giving those two the same blue would
|
|
29
|
+
* reproduce the gap this closes, one pair over: both are "neither up nor down", but one
|
|
30
|
+
* drives ground to a *target height* you can set wrongly and the other only softens
|
|
31
|
+
* what is already there — confusing them costs a stroke.
|
|
32
|
+
*/
|
|
33
|
+
export const TERRAIN_VERB_COLOR: Record<TerrainVerb, string> = {
|
|
34
|
+
raise: '#22c55e',
|
|
35
|
+
lower: '#ef4444',
|
|
36
|
+
flatten: '#3b82f6',
|
|
37
|
+
smooth: '#a855f7',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Picking a height, not applying a verb — so the ring must not read as any of them.
|
|
42
|
+
* The eyedropper is a one-shot arm whose UI lives in the panel; the cursor is the only
|
|
43
|
+
* thing at the point of action that can say "this click samples rather than sculpts".
|
|
44
|
+
*/
|
|
45
|
+
export const TERRAIN_SAMPLING_COLOR = '#f59e0b'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The brush ring's colour for a given brush state.
|
|
49
|
+
*
|
|
50
|
+
* A function rather than a lookup at the call site because sampling *outranks* the
|
|
51
|
+
* verb — while the eyedropper is armed the click does not apply the verb at all, so
|
|
52
|
+
* showing the verb's colour would promise the wrong action. Keeping that precedence in
|
|
53
|
+
* one place also makes it assertable: "no two states look alike" is a property, and one
|
|
54
|
+
* worth checking rather than re-eyeballing every time a verb is added.
|
|
55
|
+
*/
|
|
56
|
+
export function brushRingColor(verb: TerrainVerb, sampling: boolean): string {
|
|
57
|
+
return sampling ? TERRAIN_SAMPLING_COLOR : TERRAIN_VERB_COLOR[verb]
|
|
58
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Mode } from '../store/use-editor'
|
|
3
|
+
import { editorOwnsOneFingerDrag } from './touch-gesture-priority'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One finger on a touch screen is one pointer, so the camera and the editor
|
|
7
|
+
* cannot both have it. These lock which side wins, because the failure is silent:
|
|
8
|
+
* the loser's handlers simply never fire.
|
|
9
|
+
*/
|
|
10
|
+
describe('editorOwnsOneFingerDrag', () => {
|
|
11
|
+
const ALL_MODES: Mode[] = [
|
|
12
|
+
'select',
|
|
13
|
+
'edit',
|
|
14
|
+
'delete',
|
|
15
|
+
'build',
|
|
16
|
+
'material-paint',
|
|
17
|
+
'terrain-sculpt',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
test('the camera keeps one finger when nothing is going on', () => {
|
|
21
|
+
expect(editorOwnsOneFingerDrag({ mode: 'select', activeGesture: false })).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('both brush modes claim it with no transient gesture at all', () => {
|
|
25
|
+
// The regression: a brush mode arms nothing that the transient terms can see,
|
|
26
|
+
// so before this the camera orbited under a one-finger sculpt or paint drag.
|
|
27
|
+
expect(editorOwnsOneFingerDrag({ mode: 'terrain-sculpt', activeGesture: false })).toBe(true)
|
|
28
|
+
expect(editorOwnsOneFingerDrag({ mode: 'material-paint', activeGesture: false })).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('a live gesture claims it in every mode', () => {
|
|
32
|
+
for (const mode of ALL_MODES) {
|
|
33
|
+
expect(editorOwnsOneFingerDrag({ mode, activeGesture: true })).toBe(true)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('the non-brush modes leave it to the camera when idle', () => {
|
|
38
|
+
// Asserted as the complement of the brush set rather than a list, so adding a
|
|
39
|
+
// third brush mode cannot quietly land here as "camera wins".
|
|
40
|
+
const idle = ALL_MODES.filter(
|
|
41
|
+
(mode) => !editorOwnsOneFingerDrag({ mode, activeGesture: false }),
|
|
42
|
+
)
|
|
43
|
+
expect(idle).toEqual(['select', 'edit', 'delete', 'build'])
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Mode } from '../store/use-editor'
|
|
2
|
+
import { isBrushMode } from '../store/use-editor'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the editor — rather than the camera — owns a one-finger touch drag.
|
|
6
|
+
*
|
|
7
|
+
* On touch there is exactly one pointer, so the camera and the editor cannot both
|
|
8
|
+
* have it: `custom-camera-controls` maps one finger to `TOUCH_ROTATE` by default
|
|
9
|
+
* (orbiting a phone with one thumb is the common case) and to `NONE` while an
|
|
10
|
+
* editor gesture is live, which lets the tool's own pointer handlers through.
|
|
11
|
+
* Two fingers always pinch and three always orbit, so the camera is never
|
|
12
|
+
* unreachable — that is what makes yielding one finger safe.
|
|
13
|
+
*
|
|
14
|
+
* Extracted from the component because the terms are not obviously exhaustive and
|
|
15
|
+
* one was missing. A *brush mode* owns the drag exactly the way an armed tool
|
|
16
|
+
* does, but none of the other terms notice it: entering paint or sculpt **clears**
|
|
17
|
+
* `tool`, and the scope it holds is `painting`/`sculpting`, not `handle-drag`. So
|
|
18
|
+
* on a tablet a one-finger drag over the ground orbited the camera instead of
|
|
19
|
+
* sculpting — and set `cameraDragging`, which the sculpt tool gates pointer-down
|
|
20
|
+
* on, so the stroke could not start either. The brush was simply inert on touch,
|
|
21
|
+
* in both brush modes.
|
|
22
|
+
*
|
|
23
|
+
* `activeGesture` is the OR of the transient signals the component already reads
|
|
24
|
+
* (an armed tool, a moving node, an endpoint reshape, a handle drag, marquee
|
|
25
|
+
* box-select); they stay in the component because each is its own subscription,
|
|
26
|
+
* while what is worth pinning down here is that a sustained *mode* counts too.
|
|
27
|
+
*/
|
|
28
|
+
export function editorOwnsOneFingerDrag(args: { mode: Mode; activeGesture: boolean }): boolean {
|
|
29
|
+
return args.activeGesture || isBrushMode(args.mode)
|
|
30
|
+
}
|
|
@@ -23,7 +23,7 @@ export function isOperableWindowType(windowType: string | undefined) {
|
|
|
23
23
|
)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
|
|
26
|
+
export function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
|
|
27
27
|
const interactive = useInteractive.getState()
|
|
28
28
|
const runtimeValue = interactive.windows[windowId]?.operationState
|
|
29
29
|
if (runtimeValue !== undefined) return runtimeValue
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { CameraPose } from '@pascal-app/core'
|
|
3
|
+
import { cameraPoseStore, publishCameraPose, subscribeCameraPose } from './camera-pose-store'
|
|
4
|
+
|
|
5
|
+
const pose: CameraPose = {
|
|
6
|
+
position: [3, 4, 5],
|
|
7
|
+
projection: 'perspective',
|
|
8
|
+
target: [0, 1, 0],
|
|
9
|
+
viewWidth: 12,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cameraPoseStore.setState({ pose: null })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('camera pose store', () => {
|
|
17
|
+
test('replays the latest pose and streams later poses to focused subscribers', () => {
|
|
18
|
+
publishCameraPose(pose)
|
|
19
|
+
const received: CameraPose[] = []
|
|
20
|
+
const unsubscribe = subscribeCameraPose((nextPose) => received.push(nextPose))
|
|
21
|
+
const nextPose = { ...pose, viewWidth: 8 }
|
|
22
|
+
|
|
23
|
+
publishCameraPose(nextPose)
|
|
24
|
+
unsubscribe()
|
|
25
|
+
publishCameraPose({ ...pose, viewWidth: 4 })
|
|
26
|
+
|
|
27
|
+
expect(received).toEqual([pose, nextPose])
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CameraPose } from '@pascal-app/core'
|
|
2
|
+
import { subscribeWithSelector } from 'zustand/middleware'
|
|
3
|
+
import { createStore } from 'zustand/vanilla'
|
|
4
|
+
|
|
5
|
+
type CameraPoseState = {
|
|
6
|
+
pose: CameraPose | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const cameraPoseStore = createStore<CameraPoseState>()(
|
|
10
|
+
subscribeWithSelector<CameraPoseState>(() => ({ pose: null })),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
export function publishCameraPose(pose: CameraPose) {
|
|
14
|
+
cameraPoseStore.setState({ pose })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function subscribeCameraPose(listener: (pose: CameraPose) => void) {
|
|
18
|
+
const currentPose = cameraPoseStore.getState().pose
|
|
19
|
+
if (currentPose) listener(currentPose)
|
|
20
|
+
|
|
21
|
+
return cameraPoseStore.subscribe(
|
|
22
|
+
(state) => state.pose,
|
|
23
|
+
(pose) => {
|
|
24
|
+
if (pose) listener(pose)
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
}
|