@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.
Files changed (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -2,44 +2,82 @@
2
2
 
3
3
  import {
4
4
  getCatalogMaterialById,
5
+ getDynamicLibraryMaterials,
5
6
  getLibraryMaterialIdFromRef,
7
+ getLibraryMaterialsVersion,
6
8
  getMaterialsForCategory,
7
9
  MATERIAL_CATEGORIES,
10
+ type MaterialCatalogItem,
11
+ type MaterialSource,
8
12
  type MaterialTarget,
13
+ subscribeLibraryMaterials,
9
14
  toLibraryMaterialRef,
10
15
  } from '@pascal-app/core'
11
- import { useEffect, useState } from 'react'
16
+ import { Plus } from 'lucide-react'
17
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
12
18
  import { triggerSFX } from '../../../lib/sfx-bus'
13
19
 
14
- type MaterialPickerProps = {
20
+ export type MaterialSourceFilter = 'all' | MaterialSource
21
+
22
+ export type MaterialPickerProps = {
15
23
  selectedMaterialPreset?: string
16
24
  onSelectMaterialPreset?: (materialPreset: string) => void
17
25
  disabled?: boolean
18
26
  nodeType?: MaterialTarget
19
27
  hideSideControl?: boolean
28
+ onCreateMaterialRequest?: () => void
20
29
  }
21
30
 
31
+ const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
32
+ { id: 'all', label: 'All' },
33
+ { id: 'pascal', label: 'Pascal' },
34
+ { id: 'mine', label: 'Mine' },
35
+ { id: 'workspace', label: 'Workspace' },
36
+ { id: 'community', label: 'Community' },
37
+ ]
38
+
22
39
  function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
23
40
  return category.charAt(0).toUpperCase() + category.slice(1)
24
41
  }
25
42
 
43
+ function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
44
+ if (filter === 'all') return items
45
+ return items.filter((item) => (item.source ?? 'pascal') === filter)
46
+ }
47
+
26
48
  /**
27
- * Catalog material picker: a fixed row of category tabs over a scrollable grid
28
- * of swatches. Custom-material creation lives in the scene-material section
29
- * (the host's `+` action), not here, so it's available from any category.
49
+ * Catalog material picker: a fixed row of category tabs and a source filter row
50
+ * over a scrollable grid of swatches. Scene-material creation lives in the
51
+ * scene-material section (the host's `+` action); `onCreateMaterialRequest` is
52
+ * the host's entry point for authoring a new *library* material.
30
53
  */
31
54
  export function MaterialPicker({
32
55
  selectedMaterialPreset,
33
56
  onSelectMaterialPreset,
34
57
  disabled = false,
58
+ onCreateMaterialRequest,
35
59
  }: MaterialPickerProps) {
36
60
  const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
37
61
  MATERIAL_CATEGORIES[0],
38
62
  )
63
+ const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('all')
64
+ // Version counter so host registrations/unregistrations re-render the picker.
65
+ const libraryVersion = useSyncExternalStore(
66
+ subscribeLibraryMaterials,
67
+ getLibraryMaterialsVersion,
68
+ getLibraryMaterialsVersion,
69
+ )
70
+ const hasWorkspaceMaterials = useMemo(
71
+ () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
72
+ [libraryVersion],
73
+ )
74
+ const visibleSourceFilters = SOURCE_FILTERS.filter(
75
+ (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials,
76
+ )
39
77
  const availableCategories = MATERIAL_CATEGORIES.filter(
40
78
  (category) => getMaterialsForCategory(category).length > 0,
41
79
  )
42
- const catalogItems = getMaterialsForCategory(selectedCategory)
80
+ const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter)
43
81
 
44
82
  // Keep the visible category in sync with the externally-selected catalog
45
83
  // material (a `scene:` ref matches no catalog entry, so the tab stays put).
@@ -72,7 +110,7 @@ export function MaterialPicker({
72
110
  setSelectedCategory(category)
73
111
  // Auto-select the first material in the category so the brush is
74
112
  // immediately ready (and the swatch shows as selected).
75
- const first = getMaterialsForCategory(category)[0]
113
+ const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0]
76
114
  if (first) handleCatalogSelect(first.id)
77
115
  }}
78
116
  type="button"
@@ -81,11 +119,52 @@ export function MaterialPicker({
81
119
  </button>
82
120
  ))}
83
121
  </div>
122
+ {/* Fixed source filter tabs — underline style, matching the catalog
123
+ browse surfaces (Items / Rooms / Build / Search) rather than the
124
+ pill-button category row above. */}
125
+ <div className="flex shrink-0 items-center gap-4 px-1">
126
+ {visibleSourceFilters.map((filter) => (
127
+ <button
128
+ className={`-mb-px border-b-2 px-0.5 py-1.5 font-medium text-xs transition-colors ${
129
+ sourceFilter === filter.id
130
+ ? 'border-foreground text-foreground'
131
+ : 'border-transparent text-muted-foreground hover:text-foreground'
132
+ }`}
133
+ key={filter.id}
134
+ onClick={() => {
135
+ triggerSFX('sfx:menu-click')
136
+ setSourceFilter(filter.id)
137
+ }}
138
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
139
+ type="button"
140
+ >
141
+ {filter.label}
142
+ </button>
143
+ ))}
144
+ </div>
84
145
  {/* The only scrolling region. */}
85
146
  <div
86
147
  className="subtle-scrollbar grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-1"
87
148
  style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
88
149
  >
150
+ {onCreateMaterialRequest ? (
151
+ <button
152
+ className="group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent"
153
+ onClick={() => {
154
+ triggerSFX('sfx:menu-click')
155
+ onCreateMaterialRequest()
156
+ }}
157
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
158
+ type="button"
159
+ >
160
+ <div className="flex aspect-square w-full items-center justify-center rounded-lg border border-border/45 border-dashed">
161
+ <Plus className="size-5 text-muted-foreground group-hover:text-foreground" />
162
+ </div>
163
+ <span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
164
+ New material
165
+ </span>
166
+ </button>
167
+ ) : null}
89
168
  {catalogItems.map((item) => {
90
169
  const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id)
91
170
  return (
@@ -0,0 +1,193 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ RAISE_METRES_PER_STROKE,
6
+ type SiteNode,
7
+ type TerrainVerb,
8
+ useScene,
9
+ } from '@pascal-app/core'
10
+ import { Mountain, Pipette } from 'lucide-react'
11
+ import { brushRadiusRange, flattenSite, resetSiteTerrain } from '../../../lib/terrain-sculpt'
12
+ import useEditor from '../../../store/use-editor'
13
+ import { Button } from '../primitives/button'
14
+ import { SegmentedControl } from './segmented-control'
15
+ import { SliderControl } from './slider-control'
16
+
17
+ const VERB_OPTIONS: Array<{ value: TerrainVerb; iconSrc: string; hint: string }> = [
18
+ { value: 'raise', iconSrc: '/icons/terrain-raise.webp', hint: 'Raise' },
19
+ { value: 'lower', iconSrc: '/icons/terrain-lower.webp', hint: 'Lower' },
20
+ { value: 'flatten', iconSrc: '/icons/terrain-flatten.webp', hint: 'Flatten' },
21
+ { value: 'smooth', iconSrc: '/icons/terrain-smooth.webp', hint: 'Smooth' },
22
+ ]
23
+
24
+ const VERB_HINTS: Record<TerrainVerb, string> = {
25
+ raise: `Drag to raise the ground. One pass moves it up to ${RAISE_METRES_PER_STROKE} m — release and drag again to go further.`,
26
+ lower: `Drag to lower the ground. One pass moves it down to ${RAISE_METRES_PER_STROKE} m.`,
27
+ flatten: 'Drag to level the ground toward the target height. It never overshoots.',
28
+ smooth: 'Drag to soften slopes and remove ridges. Flat ground stays flat.',
29
+ }
30
+
31
+ /**
32
+ * Sculpt controls for terrain mode — verb, brush, and the two lot-wide actions.
33
+ *
34
+ * A panel rather than a floating HUD because the brush settings are the sort of
35
+ * thing a user adjusts between strokes and then leaves alone, and because
36
+ * sculpting already owns the whole viewport pointer: putting controls over the
37
+ * canvas would put them over the surface being sculpted.
38
+ *
39
+ * Embedders mount this wherever their sculpt controls belong (the community
40
+ * editor puts it in the Build sidebar while sculpt mode is active), exactly like
41
+ * `MaterialPaintPanel`.
42
+ */
43
+ export function TerrainSculptPanel() {
44
+ const verb = useEditor((state) => state.terrainVerb)
45
+ const setTerrainVerb = useEditor((state) => state.setTerrainVerb)
46
+ const brush = useEditor((state) => state.terrainBrush)
47
+ const setTerrainBrush = useEditor((state) => state.setTerrainBrush)
48
+ const flattenTarget = useEditor((state) => state.terrainFlattenTarget)
49
+ const setTerrainFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget)
50
+ const sampling = useEditor((state) => state.terrainSampling)
51
+ const setTerrainSampling = useEditor((state) => state.setTerrainSampling)
52
+
53
+ const nodes = useScene((state) => state.nodes)
54
+ const rootNodeIds = useScene((state) => state.rootNodeIds)
55
+ const siteId = rootNodeIds[0]
56
+ const siteNode = siteId ? nodes[siteId as AnyNodeId] : undefined
57
+ const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
58
+ const [minRadius, maxRadius] = brushRadiusRange(site)
59
+
60
+ return (
61
+ <div className="flex flex-col gap-3">
62
+ <div className="flex flex-col gap-1.5">
63
+ <SegmentedControl
64
+ className="h-14"
65
+ onChange={(next) => setTerrainVerb(next)}
66
+ options={VERB_OPTIONS.map(({ value, iconSrc, hint }) => ({
67
+ value,
68
+ label: (
69
+ <span className="flex flex-col items-center gap-0.5">
70
+ <img
71
+ alt=""
72
+ aria-hidden
73
+ className="size-7 object-contain"
74
+ draggable={false}
75
+ height={28}
76
+ src={iconSrc}
77
+ width={28}
78
+ />
79
+ <span className="text-[9px] leading-none">{hint}</span>
80
+ </span>
81
+ ),
82
+ }))}
83
+ value={verb}
84
+ />
85
+ <p className="px-0.5 text-muted-foreground text-xs">{VERB_HINTS[verb]}</p>
86
+ </div>
87
+
88
+ <div className="flex flex-col gap-1.5">
89
+ {/*
90
+ Range from `brushRadiusRange`, shared with the `[`/`]` keys. The low end
91
+ is not a preference: it tracks the field's sample spacing, and a brush
92
+ under it lands between samples and paints nothing at all.
93
+ */}
94
+ <SliderControl
95
+ label="Size"
96
+ max={maxRadius}
97
+ min={minRadius}
98
+ onChange={(radius) => setTerrainBrush({ radius })}
99
+ precision={1}
100
+ step={0.5}
101
+ unit="m"
102
+ value={brush.radius}
103
+ />
104
+ <SliderControl
105
+ label="Strength"
106
+ max={1}
107
+ min={0.05}
108
+ onChange={(strength) => setTerrainBrush({ strength })}
109
+ precision={2}
110
+ step={0.05}
111
+ value={brush.strength}
112
+ />
113
+ <SliderControl
114
+ label="Softness"
115
+ max={1}
116
+ min={0}
117
+ onChange={(falloff) => setTerrainBrush({ falloff })}
118
+ precision={2}
119
+ step={0.05}
120
+ value={brush.falloff}
121
+ />
122
+ <SegmentedControl
123
+ onChange={(shape) => setTerrainBrush({ shape })}
124
+ options={[
125
+ { value: 'round', label: 'Round' },
126
+ { value: 'square', label: 'Square' },
127
+ ]}
128
+ value={brush.shape}
129
+ />
130
+ </div>
131
+
132
+ {verb === 'flatten' && (
133
+ <div className="flex flex-col gap-1.5 border-border/60 border-t pt-3">
134
+ <div className="flex items-center gap-2">
135
+ <div className="min-w-0 flex-1">
136
+ <SliderControl
137
+ label="Target"
138
+ max={50}
139
+ min={-50}
140
+ onChange={setTerrainFlattenTarget}
141
+ precision={2}
142
+ step={0.1}
143
+ unit="m"
144
+ value={flattenTarget ?? 0}
145
+ />
146
+ </div>
147
+ <Button
148
+ aria-label="Pick target height from the ground"
149
+ aria-pressed={sampling}
150
+ onClick={() => setTerrainSampling(!sampling)}
151
+ size="icon-sm"
152
+ type="button"
153
+ variant={sampling ? 'default' : 'outline'}
154
+ >
155
+ <Pipette />
156
+ </Button>
157
+ </div>
158
+ <p className="px-0.5 text-muted-foreground text-xs">
159
+ {sampling
160
+ ? 'Click the ground to pick its height as the target.'
161
+ : flattenTarget === null
162
+ ? 'No target yet — the first click samples the ground under it.'
163
+ : 'Every flatten stroke levels toward this height.'}
164
+ </p>
165
+ </div>
166
+ )}
167
+
168
+ <div className="flex items-center gap-2 border-border/60 border-t pt-3">
169
+ <Button
170
+ className="flex-1"
171
+ disabled={!site}
172
+ onClick={() => site && flattenSite(site, flattenTarget ?? 0)}
173
+ size="sm"
174
+ type="button"
175
+ variant="outline"
176
+ >
177
+ <Mountain />
178
+ Level lot
179
+ </Button>
180
+ <Button
181
+ className="flex-1"
182
+ disabled={!site?.terrain}
183
+ onClick={() => site && resetSiteTerrain(site)}
184
+ size="sm"
185
+ type="button"
186
+ variant="outline"
187
+ >
188
+ Clear terrain
189
+ </Button>
190
+ </div>
191
+ </div>
192
+ )
193
+ }
@@ -22,6 +22,8 @@ import {
22
22
  type AnyNode,
23
23
  type AnyNodeId,
24
24
  type BuildingNode,
25
+ DEFAULT_LEVEL_HEIGHT,
26
+ getStoredLevelHeight,
25
27
  LevelNode,
26
28
  useScene,
27
29
  } from '@pascal-app/core'
@@ -34,22 +36,19 @@ import {
34
36
  useEffect,
35
37
  useRef,
36
38
  useState,
37
- useSyncExternalStore,
38
39
  } from 'react'
39
40
  import { useShallow } from 'zustand/react/shallow'
41
+ import { pasteSelectionAndPickUp } from '../editor/group-actions'
40
42
  import {
41
43
  buildLevelDuplicateCreateOps,
42
44
  type LevelDuplicatePreset,
43
45
  } from '../../lib/level-duplication'
44
46
  import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
45
47
  import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
46
- import {
47
- getEditorClipboardSnapshot,
48
- pasteEditorClipboardToLevel,
49
- subscribeEditorClipboard,
50
- } from '../../lib/scene-clipboard'
51
- import { sfxEmitter } from '../../lib/sfx-bus'
48
+ import { useLinearDisplay } from '../../lib/use-linear-display'
52
49
  import { cn } from '../../lib/utils'
50
+ import { ActionButton } from './controls/action-button'
51
+ import { SliderControl } from './controls/slider-control'
53
52
  import { LevelDuplicateDialog } from './level-duplicate-dialog'
54
53
  import {
55
54
  Dialog,
@@ -145,6 +144,26 @@ function LevelRow({
145
144
  }) {
146
145
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
147
146
  const [isEditing, setIsEditing] = useState(false)
147
+ const updateNode = useScene((s) => s.updateNode)
148
+ const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2)
149
+
150
+ const storeyHeight = getStoredLevelHeight(level)
151
+ // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
152
+ const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
153
+
154
+ // Clean preset values per display system; imperial stores exact meters
155
+ // for whole-foot storey heights.
156
+ const heightPresets = isImperial
157
+ ? [
158
+ { label: '8 ft', height: 2.4384 },
159
+ { label: '9 ft', height: 2.7432 },
160
+ { label: '10 ft', height: 3.048 },
161
+ ]
162
+ : [
163
+ { label: '2.5 m', height: 2.5 },
164
+ { label: '3.0 m', height: 3.0 },
165
+ { label: '3.5 m', height: 3.5 },
166
+ ]
148
167
 
149
168
  return (
150
169
  <div className="group/level">
@@ -195,6 +214,48 @@ function LevelRow({
195
214
  <span className="truncate">{getLevelDisplayName(level)}</span>
196
215
  </button>
197
216
 
217
+ {/* Storey height badge — opens the height popover */}
218
+ <Popover>
219
+ <PopoverTrigger asChild>
220
+ <button
221
+ className="mr-0.5 shrink-0 whitespace-nowrap rounded px-1 py-0.5 font-mono text-[10px] text-muted-foreground/50 tabular-nums transition-colors hover:bg-white/5 hover:text-foreground"
222
+ onClick={(e) => e.stopPropagation()}
223
+ title="Level height"
224
+ type="button"
225
+ >
226
+ {storeyHeightLabel}
227
+ </button>
228
+ </PopoverTrigger>
229
+ <PopoverContent
230
+ align="start"
231
+ className="w-56 p-2"
232
+ onClick={(e) => e.stopPropagation()}
233
+ side="right"
234
+ sideOffset={8}
235
+ >
236
+ <SliderControl
237
+ label="Level height"
238
+ max={6}
239
+ min={1}
240
+ onChange={(v) => updateNode(level.id, { height: v })}
241
+ precision={3}
242
+ step={0.1}
243
+ unit="m"
244
+ value={Math.round(storeyHeight * 1000) / 1000}
245
+ />
246
+ <div className="mt-1.5 grid grid-cols-3 gap-1.5">
247
+ {heightPresets.map((preset) => (
248
+ <ActionButton
249
+ className="h-7 px-2"
250
+ key={preset.label}
251
+ label={preset.label}
252
+ onClick={() => updateNode(level.id, { height: preset.height })}
253
+ />
254
+ ))}
255
+ </div>
256
+ </PopoverContent>
257
+ </Popover>
258
+
198
259
  {/* Vertical three-dot menu — inside the pill */}
199
260
  <Popover>
200
261
  <PopoverTrigger asChild>
@@ -332,11 +393,6 @@ export function FloatingLevelSelector() {
332
393
 
333
394
  const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
334
395
  const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
335
- const clipboardSnapshot = useSyncExternalStore(
336
- subscribeEditorClipboard,
337
- getEditorClipboardSnapshot,
338
- getEditorClipboardSnapshot,
339
- )
340
396
  const sensors = useSensors(
341
397
  useSensor(PointerSensor, {
342
398
  activationConstraint: { distance: 4 },
@@ -371,6 +427,7 @@ export function FloatingLevelSelector() {
371
427
  const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1
372
428
  const newLevel = LevelNode.parse({
373
429
  level: maxLevel + 1,
430
+ height: DEFAULT_LEVEL_HEIGHT,
374
431
  children: [],
375
432
  parentId: resolvedBuildingId,
376
433
  })
@@ -383,6 +440,7 @@ export function FloatingLevelSelector() {
383
440
  const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1
384
441
  const newLevel = LevelNode.parse({
385
442
  level: minLevel - 1,
443
+ height: DEFAULT_LEVEL_HEIGHT,
386
444
  children: [],
387
445
  parentId: resolvedBuildingId,
388
446
  })
@@ -409,6 +467,7 @@ export function FloatingLevelSelector() {
409
467
 
410
468
  const newLevel = LevelNode.parse({
411
469
  level: newLevelNumber,
470
+ height: DEFAULT_LEVEL_HEIGHT,
412
471
  children: [],
413
472
  parentId: resolvedBuildingId,
414
473
  })
@@ -452,10 +511,7 @@ export function FloatingLevelSelector() {
452
511
  )
453
512
 
454
513
  const handlePasteToLevel = useCallback((level: LevelNode) => {
455
- const result = pasteEditorClipboardToLevel(level.id)
456
- if (result?.pastedIds.length) {
457
- sfxEmitter.emit('sfx:item-place')
458
- }
514
+ void pasteSelectionAndPickUp(level.id)
459
515
  }, [])
460
516
 
461
517
  const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -557,9 +613,7 @@ export function FloatingLevelSelector() {
557
613
  isSelected={isSelected}
558
614
  level={level}
559
615
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
560
- onPaste={
561
- clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
562
- }
616
+ onPaste={() => handlePasteToLevel(level)}
563
617
  onRequestDelete={() => setDeletingLevel(level)}
564
618
  onSelect={() =>
565
619
  setSelection(
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  nodeRegistry,
7
+ type TerrainVerb,
7
8
  useScene,
8
9
  } from '@pascal-app/core'
9
10
  import { useViewer } from '@pascal-app/viewer'
@@ -53,6 +54,35 @@ function reshapingHints(reshape: ReshapeKind): ContextualShortcutHint[] {
53
54
  ]
54
55
  }
55
56
 
57
+ // Sculpt mode's HUD. The verb is named rather than described because it is the one
58
+ // piece of state a user loses track of between strokes. This stays even though the
59
+ // brush ring now carries the verb in colour (`brushRingColor`): the ring says
60
+ // *which* verb only to someone who already knows the mapping, and the HUD is what
61
+ // teaches it.
62
+ function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualShortcutHint[] {
63
+ if (sampling) {
64
+ return [
65
+ { keys: ['Click'], label: 'Pick target height' },
66
+ { keys: ['Esc'], label: 'Cancel picking' },
67
+ ]
68
+ }
69
+ const action =
70
+ verb === 'raise'
71
+ ? 'Raise ground'
72
+ : verb === 'lower'
73
+ ? 'Lower ground'
74
+ : verb === 'flatten'
75
+ ? 'Level ground'
76
+ : 'Smooth ground'
77
+ return [
78
+ { keys: ['Drag'], label: action },
79
+ // Nested, so the two render as alternatives ("[ / ]") rather than a chord —
80
+ // a flat `['[', ']']` joins with "+" and would read as "press both".
81
+ { keys: [['[', ']']], label: 'Brush size' },
82
+ { keys: ['Esc'], label: 'Cancel stroke' },
83
+ ]
84
+ }
85
+
56
86
  type ActiveModifierKeys = {
57
87
  command: boolean
58
88
  shift: boolean
@@ -95,6 +125,9 @@ function useActiveModifierKeys(): ActiveModifierKeys {
95
125
  export function HelperManager() {
96
126
  const mode = useEditor((s) => s.mode)
97
127
  const tool = useEditor((s) => s.tool)
128
+ const terrainVerb = useEditor((s) => s.terrainVerb)
129
+ const terrainSampling = useEditor((s) => s.terrainSampling)
130
+ const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
98
131
  const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind)
99
132
  const workspaceMode = useEditor((s) => s.workspaceMode)
100
133
  const scope = useInteractionScope((s) => s.scope)
@@ -148,6 +181,10 @@ export function HelperManager() {
148
181
  // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
149
182
  if (isMobile) return null
150
183
 
184
+ // First-person walkthrough has its own HUD; editor shortcut hints (e.g. the
185
+ // Ctrl multi-select hint — Ctrl is crouch there) don't apply while walking.
186
+ if (isFirstPersonMode) return null
187
+
151
188
  // The studio workspace (compose panel / gallery) has no scene selection or
152
189
  // tools — editor shortcut hints would only mislead there.
153
190
  if (workspaceMode === 'studio') return null
@@ -209,6 +246,15 @@ export function HelperManager() {
209
246
  return <ContextualHelperPanel hints={[]} showPaintScope />
210
247
  }
211
248
 
249
+ // Sculpt mode. The HUD is what makes a sustained brush mode legible: it names
250
+ // the active verb (the same word the panel's segmented control shows, so the
251
+ // two never disagree), and it advertises the two keys whose behaviour is
252
+ // mode-specific — bracket resize, and an Esc that abandons the stroke rather
253
+ // than exiting the mode.
254
+ if (mode === 'terrain-sculpt') {
255
+ return <ContextualHelperPanel hints={terrainSculptHints(terrainVerb, terrainSampling)} />
256
+ }
257
+
212
258
  // Idle select only — an active scope (handle-drag, box-select, …) must not show
213
259
  // the idle selection hints.
214
260
  if (mode === 'select' && scope.kind === 'idle') {
@@ -0,0 +1,57 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { Copy, Trash2 } from 'lucide-react'
6
+ import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
7
+ import { ActionButton, ActionGroup } from '../controls/action-button'
8
+ import { PanelWrapper } from './panel-wrapper'
9
+ import { formatSelectionBreakdown } from './selection-breakdown'
10
+
11
+ /**
12
+ * Docked right-side panel for a MULTI-selection — the compact sibling of the
13
+ * single-node inspector, rendered by `PanelManager` when more than one node
14
+ * is selected. Same collapsed-by-default `PanelWrapper` shell (header always
15
+ * visible; the shared desktop collapse state carries across single ↔ multi
16
+ * swaps). Actions mirror the floating group pill: Duplicate clones the
17
+ * selection and picks the clones up, Delete removes the whole selection
18
+ * (including its bulk-delete confirm). Unlike the pill, the docked panel
19
+ * stays visible during interactions. `footer` is the host-injected slot
20
+ * (e.g. community's "Save to my catalog").
21
+ */
22
+ export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
23
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
24
+ const setSelection = useViewer((s) => s.setSelection)
25
+ // String selector — recomputed on scene ticks, but the === compare keeps
26
+ // unrelated mutations from re-rendering the panel.
27
+ const breakdown = useScene((s) =>
28
+ formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)),
29
+ )
30
+
31
+ return (
32
+ <PanelWrapper
33
+ footer={footer}
34
+ icon="/icons/select.webp"
35
+ onClose={() => setSelection({ selectedIds: [] })}
36
+ title={`${selectedIds.length} selected`}
37
+ width={320}
38
+ >
39
+ {breakdown && <div className="px-3 py-3 text-muted-foreground text-xs">{breakdown}</div>}
40
+ <div className="border-border/50 border-t p-3">
41
+ <ActionGroup>
42
+ <ActionButton
43
+ icon={<Copy className="h-4 w-4" />}
44
+ label="Duplicate"
45
+ onClick={() => duplicateSelectionAndPickUp()}
46
+ />
47
+ <ActionButton
48
+ className="border-red-500/40 text-red-200 hover:bg-red-500/15"
49
+ icon={<Trash2 className="h-4 w-4" />}
50
+ label="Delete"
51
+ onClick={() => deleteSelection()}
52
+ />
53
+ </ActionGroup>
54
+ </div>
55
+ </PanelWrapper>
56
+ )
57
+ }
@@ -28,6 +28,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
28
28
  import useEditor from '../../../store/use-editor'
29
29
  import { MobilePanelSheet } from './mobile-panel-sheet'
30
30
  import { MobileSelectionBar } from './mobile-selection-bar'
31
+ import { MultiSelectionPanel } from './multi-selection-panel'
31
32
  import { getNodeDisplay } from './node-display'
32
33
  import { resetDesktopInspectorCollapsed } from './panel-wrapper'
33
34
  import { ParametricInspector } from './parametric-inspector'
@@ -168,7 +169,13 @@ function MobilePanelLayer({
168
169
  )
169
170
  }
170
171
 
171
- export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
172
+ export function PanelManager({
173
+ inspectorFooter,
174
+ multiSelectionFooter,
175
+ }: {
176
+ inspectorFooter?: React.ReactNode
177
+ multiSelectionFooter?: React.ReactNode
178
+ }) {
172
179
  const isMobile = useIsMobile()
173
180
  const selectedIds = useViewer((s) => s.selection.selectedIds)
174
181
  const selectedZoneId = useViewer((s) => s.selection.zoneId)
@@ -237,5 +244,11 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
237
244
  )
238
245
  }
239
246
 
247
+ // Multi-selection: compact docked panel (desktop only — the mobile branch
248
+ // above keeps today's behavior and renders nothing for multi-selections).
249
+ if (selectedIds.length > 1) {
250
+ return <MultiSelectionPanel footer={multiSelectionFooter} />
251
+ }
252
+
240
253
  return panelForType(selectedNodeType, inspectorFooter)
241
254
  }