@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,218 @@
1
+ 'use client'
2
+
3
+ import { Icon } from '@iconify/react'
4
+ import { type IconRef, useScene } from '@pascal-app/core'
5
+ import { ChevronLeft, ChevronRight, ExternalLink, Puzzle } from 'lucide-react'
6
+ import { lazy, type ReactNode, Suspense, useState, useSyncExternalStore } from 'react'
7
+ import { editorHostPanelRegistry } from '../../../../lib/plugin-panels'
8
+ import { Button } from '../../primitives/button'
9
+
10
+ const PLUGIN_AUTHORING_URL =
11
+ 'https://editor.pascal.app/docs/developers/plugins'
12
+
13
+ function renderPluginIcon(ref: IconRef): ReactNode {
14
+ if (ref.kind === 'url') {
15
+ return <img alt="" className="h-8 w-8 object-contain" src={ref.src} />
16
+ }
17
+ if (ref.kind === 'iconify') {
18
+ return <Icon height={28} icon={ref.name} width={28} />
19
+ }
20
+ if (ref.kind === 'svg') {
21
+ return (
22
+ <svg height={28} viewBox={ref.viewBox} width={28}>
23
+ <path d={ref.path} fill="currentColor" />
24
+ </svg>
25
+ )
26
+ }
27
+ const LazyIcon = lazy(ref.module)
28
+ return (
29
+ <Suspense fallback={<Puzzle className="h-7 w-7" />}>
30
+ <LazyIcon />
31
+ </Suspense>
32
+ )
33
+ }
34
+
35
+ export function PluginsPanel() {
36
+ const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null)
37
+ const panels = useSyncExternalStore(
38
+ editorHostPanelRegistry.subscribe,
39
+ editorHostPanelRegistry.getSnapshot,
40
+ editorHostPanelRegistry.getSnapshot,
41
+ )
42
+ const installedPlugins = useScene((state) => state.installedPlugins)
43
+ const setInstalledPlugins = useScene((state) => state.setInstalledPlugins)
44
+ const readOnly = useScene((state) => state.readOnly)
45
+ const plugins = Array.from(
46
+ new Map(
47
+ panels
48
+ .filter((panel) => panel.pluginId)
49
+ .map((panel) => [panel.pluginId as string, panel]),
50
+ ).entries(),
51
+ )
52
+ const selectedPlugin = selectedPluginId
53
+ ? plugins.find(([pluginId]) => pluginId === selectedPluginId)
54
+ : undefined
55
+
56
+ if (selectedPlugin) {
57
+ const [pluginId, panel] = selectedPlugin
58
+ const installed = installedPlugins.includes(pluginId)
59
+
60
+ return (
61
+ <div className="flex h-full flex-col overflow-y-auto p-4">
62
+ <div>
63
+ <Button
64
+ className="rounded-full"
65
+ onClick={() => setSelectedPluginId(null)}
66
+ size="sm"
67
+ variant="ghost"
68
+ >
69
+ <ChevronLeft className="h-4 w-4" />
70
+ All plugins
71
+ </Button>
72
+
73
+ <div className="mt-5 flex items-start gap-4">
74
+ <div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-background/60">
75
+ {renderPluginIcon(panel.icon)}
76
+ </div>
77
+ <div className="min-w-0 pt-1">
78
+ <h2 className="font-semibold text-lg text-sidebar-foreground">{panel.label}</h2>
79
+ <p className="text-sidebar-foreground/50 text-sm">
80
+ {installed ? 'Installed' : 'Not installed'}
81
+ </p>
82
+ </div>
83
+ </div>
84
+
85
+ <p className="mt-5 text-sidebar-foreground/70 text-sm">
86
+ {panel.description ?? 'Adds a new tool panel to the editor.'}
87
+ </p>
88
+
89
+ <dl className="mt-6 divide-y divide-border/50 rounded-xl border border-border/60">
90
+ <div className="p-3">
91
+ <dt className="text-sidebar-foreground/50 text-xs">Plugin ID</dt>
92
+ <dd className="mt-1 break-all text-sidebar-foreground text-sm">{pluginId}</dd>
93
+ </div>
94
+ {panel.creator && (
95
+ <div className="p-3">
96
+ <dt className="text-sidebar-foreground/50 text-xs">Creator</dt>
97
+ <dd className="mt-1 text-sm">
98
+ {panel.creator.url ? (
99
+ <a
100
+ className="inline-flex items-center gap-1 text-sidebar-foreground underline-offset-4 hover:underline"
101
+ href={panel.creator.url}
102
+ rel="noreferrer"
103
+ target="_blank"
104
+ >
105
+ {panel.creator.name}
106
+ <ExternalLink className="h-3 w-3" />
107
+ </a>
108
+ ) : (
109
+ panel.creator.name
110
+ )}
111
+ </dd>
112
+ </div>
113
+ )}
114
+ {panel.pluginUrl && (
115
+ <div className="p-3">
116
+ <dt className="text-sidebar-foreground/50 text-xs">Plugin</dt>
117
+ <dd className="mt-1 text-sm">
118
+ <a
119
+ className="inline-flex items-center gap-1 text-sidebar-foreground underline-offset-4 hover:underline"
120
+ href={panel.pluginUrl}
121
+ rel="noreferrer"
122
+ target="_blank"
123
+ >
124
+ View plugin
125
+ <ExternalLink className="h-3 w-3" />
126
+ </a>
127
+ </dd>
128
+ </div>
129
+ )}
130
+ </dl>
131
+
132
+ <Button
133
+ className="mt-5 rounded-full"
134
+ disabled={readOnly}
135
+ onClick={() => {
136
+ const next = installed
137
+ ? installedPlugins.filter((id) => id !== pluginId)
138
+ : [...installedPlugins, pluginId]
139
+ setInstalledPlugins(next, { explicit: true })
140
+ }}
141
+ variant={installed ? 'outline' : 'default'}
142
+ >
143
+ {installed ? 'Uninstall' : 'Install'}
144
+ </Button>
145
+ </div>
146
+
147
+ <div className="mt-auto pt-6">
148
+ <a
149
+ className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 text-sm underline-offset-4 hover:text-sidebar-foreground hover:underline"
150
+ href={PLUGIN_AUTHORING_URL}
151
+ rel="noreferrer"
152
+ target="_blank"
153
+ >
154
+ Create a Pascal plugin
155
+ <ExternalLink className="h-3.5 w-3.5" />
156
+ </a>
157
+ </div>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ return (
163
+ <div className="flex h-full flex-col overflow-y-auto p-4">
164
+ <div className="mb-5">
165
+ <h2 className="font-semibold text-lg text-sidebar-foreground">Plugins</h2>
166
+ <p className="mt-1 text-sidebar-foreground/60 text-sm">
167
+ Add focused tools and content to this project.
168
+ </p>
169
+ </div>
170
+
171
+ <div className="flex flex-col gap-3">
172
+ {plugins.map(([pluginId, panel]) => {
173
+ const installed = installedPlugins.includes(pluginId)
174
+ return (
175
+ <button
176
+ className="w-full rounded-xl border border-border/60 bg-accent/20 p-3 text-left transition-colors hover:bg-accent/40"
177
+ key={pluginId}
178
+ onClick={() => setSelectedPluginId(pluginId)}
179
+ type="button"
180
+ >
181
+ <div className="flex items-start gap-3">
182
+ <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-background/60">
183
+ {renderPluginIcon(panel.icon)}
184
+ </div>
185
+ <div className="min-w-0 flex-1">
186
+ <div className="flex items-center justify-between gap-3">
187
+ <div>
188
+ <h3 className="font-medium text-sidebar-foreground">{panel.label}</h3>
189
+ <p className="text-sidebar-foreground/50 text-xs">
190
+ {installed ? 'Installed' : 'Not installed'}
191
+ </p>
192
+ </div>
193
+ <ChevronRight className="h-4 w-4 shrink-0 text-sidebar-foreground/50" />
194
+ </div>
195
+ <p className="mt-2 text-sidebar-foreground/60 text-sm">
196
+ {panel.description ?? 'Adds a new tool panel to the editor.'}
197
+ </p>
198
+ </div>
199
+ </div>
200
+ </button>
201
+ )
202
+ })}
203
+ </div>
204
+
205
+ <div className="mt-auto pt-6">
206
+ <a
207
+ className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 text-sm underline-offset-4 hover:text-sidebar-foreground hover:underline"
208
+ href={PLUGIN_AUTHORING_URL}
209
+ rel="noreferrer"
210
+ target="_blank"
211
+ >
212
+ Create a Pascal plugin
213
+ <ExternalLink className="h-3.5 w-3.5" />
214
+ </a>
215
+ </div>
216
+ </div>
217
+ )
218
+ }
@@ -1,7 +1,7 @@
1
- import { emitter, useScene, validateBuildJson } from '@pascal-app/core'
1
+ import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { TreeView, VisualJson } from '@visual-json/react'
4
- import { Camera, Download, Save, Trash2, Upload } from 'lucide-react'
4
+ import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react'
5
5
  import {
6
6
  type KeyboardEvent,
7
7
  type SyntheticEvent,
@@ -10,6 +10,7 @@ import {
10
10
  useRef,
11
11
  useState,
12
12
  } from 'react'
13
+ import { exportFloorplanPdf } from '../../../../../lib/floorplan/floorplan-export'
13
14
  import { Button } from './../../../../../components/ui/primitives/button'
14
15
  import {
15
16
  Dialog,
@@ -179,11 +180,11 @@ export function SettingsPanel({
179
180
  const fileInputRef = useRef<HTMLInputElement>(null)
180
181
  const nodes = useScene((state) => state.nodes)
181
182
  const rootNodeIds = useScene((state) => state.rootNodeIds)
183
+ const installedPlugins = useScene((state) => state.installedPlugins)
182
184
  const setScene = useScene((state) => state.setScene)
183
185
  const clearScene = useScene((state) => state.clearScene)
184
186
  const resetSelection = useViewer((state) => state.resetSelection)
185
187
  const exportScene = useViewer((state) => state.exportScene)
186
- const showGrid = useViewer((state) => state.showGrid)
187
188
  const shadows = useViewer((state) => state.shadows)
188
189
  const setPhase = useEditor((state) => state.setPhase)
189
190
  const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
@@ -206,7 +207,7 @@ export function SettingsPanel({
206
207
  const isLocalProject = false // Props-based; only show cloud sections when projectId provided
207
208
 
208
209
  const handleSaveBuild = () => {
209
- const sceneData = { nodes, rootNodeIds }
210
+ const sceneData = { nodes, rootNodeIds, installedPlugins }
210
211
  const json = JSON.stringify(sceneData, null, 2)
211
212
  const blob = new Blob([json], { type: 'application/json' })
212
213
  const url = URL.createObjectURL(blob)
@@ -262,11 +263,24 @@ export function SettingsPanel({
262
263
  e.target.value = ''
263
264
  }
264
265
 
265
- const handleConfirmImport = (parsed: { nodes: Record<string, unknown>; rootNodeIds: string[] }) => {
266
+ const handleConfirmImport = (parsed: {
267
+ nodes: Record<string, unknown>
268
+ rootNodeIds: string[]
269
+ installedPlugins?: string[]
270
+ }) => {
271
+ const currentScene = useScene.getState()
266
272
  setScene(
267
273
  parsed.nodes as Parameters<typeof setScene>[0],
268
274
  parsed.rootNodeIds as Parameters<typeof setScene>[1],
275
+ {
276
+ installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
277
+ hasExplicitPluginInstallState:
278
+ parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
279
+ },
269
280
  )
281
+ // An import is a scene load: it becomes the undo floor. Without this,
282
+ // undo could step back into the pre-import scene state.
283
+ clearSceneHistory()
270
284
  resetSelection()
271
285
  setPhase('site')
272
286
  setPendingImport(null)
@@ -274,6 +288,9 @@ export function SettingsPanel({
274
288
 
275
289
  const handleResetToDefault = () => {
276
290
  clearScene()
291
+ // Same floor rule as import — undo after a reset must not resurrect the
292
+ // old scene (or land on the empty intermediate `unloadScene` state).
293
+ clearSceneHistory()
277
294
  resetSelection()
278
295
  setPhase('structure')
279
296
  selectDefaultBuildingAndLevel()
@@ -331,16 +348,6 @@ export function SettingsPanel({
331
348
  onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
332
349
  />
333
350
  </div>
334
- <div className="flex items-center justify-between">
335
- <div>
336
- <div className="font-medium text-sm">Show Grid</div>
337
- <div className="text-muted-foreground text-xs">Visible only in the editor</div>
338
- </div>
339
- <Switch
340
- checked={showGrid}
341
- onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
342
- />
343
- </div>
344
351
  <div className="flex items-center justify-between">
345
352
  <div>
346
353
  <div className="font-medium text-sm">Shadows</div>
@@ -355,32 +362,56 @@ export function SettingsPanel({
355
362
  )}
356
363
 
357
364
  {/* Export Section */}
358
- <div className="space-y-2">
365
+ <div className="space-y-4">
359
366
  <label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
360
- <Button
361
- className="w-full justify-start gap-2"
362
- onClick={() => exportScene?.('glb')}
363
- variant="outline"
364
- >
365
- <Download className="size-4" />
366
- Export GLB
367
- </Button>
368
- <Button
369
- className="w-full justify-start gap-2"
370
- onClick={() => exportScene?.('stl')}
371
- variant="outline"
372
- >
373
- <Download className="size-4" />
374
- Export STL
375
- </Button>
376
- <Button
377
- className="w-full justify-start gap-2"
378
- onClick={() => exportScene?.('obj')}
379
- variant="outline"
380
- >
381
- <Download className="size-4" />
382
- Export OBJ
383
- </Button>
367
+
368
+ <div className="space-y-2">
369
+ <div className="font-medium text-muted-foreground text-xs">3D model</div>
370
+ <Button
371
+ className="w-full justify-start gap-2"
372
+ onClick={() => exportScene?.('glb')}
373
+ variant="outline"
374
+ >
375
+ <Download className="size-4" />
376
+ Export GLB
377
+ </Button>
378
+ <Button
379
+ className="w-full justify-start gap-2"
380
+ onClick={() => exportScene?.('stl')}
381
+ variant="outline"
382
+ >
383
+ <Download className="size-4" />
384
+ Export STL
385
+ </Button>
386
+ <Button
387
+ className="w-full justify-start gap-2"
388
+ onClick={() => exportScene?.('obj')}
389
+ variant="outline"
390
+ >
391
+ <Download className="size-4" />
392
+ Export OBJ
393
+ </Button>
394
+ </div>
395
+
396
+ <div className="space-y-2">
397
+ <div className="font-medium text-muted-foreground text-xs">Floorplan</div>
398
+ <Button
399
+ className="w-full justify-start gap-2"
400
+ onClick={() => exportFloorplanPdf('full')}
401
+ variant="outline"
402
+ >
403
+ <MapIcon className="size-4" />
404
+ Full floorplan
405
+ </Button>
406
+ <Button
407
+ className="w-full justify-start gap-2"
408
+ onClick={() => exportFloorplanPdf('structure')}
409
+ variant="outline"
410
+ >
411
+ <MapIcon className="size-4" />
412
+ Structure only
413
+ </Button>
414
+ </div>
384
415
  </div>
385
416
 
386
417
  {/* Thumbnail Section (only for cloud projects) */}
@@ -55,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
55
55
  shortcuts: [
56
56
  { keys: ['V'], action: 'Switch to Select mode' },
57
57
  { keys: ['B'], action: 'Switch to Build mode' },
58
+ { keys: ['M'], action: 'Activate the last measurement tool' },
58
59
  { keys: ['X'], action: 'Switch to Delete mode' },
59
60
  {
60
61
  keys: ['Esc'],
@@ -71,7 +72,48 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
71
72
  {
72
73
  keys: ['Cmd/Ctrl', 'Left click'],
73
74
  action: 'Add or remove an object from multi-selection',
74
- note: 'Works while in Select mode.',
75
+ note: 'Works in Select mode on the 3D canvas, the 2D floor plan, and the scene graph.',
76
+ },
77
+ {
78
+ keys: ['Shift', 'Left click'],
79
+ action: 'Add or remove an object from canvas multi-selection',
80
+ note: 'In the scene graph, Shift-click selects the visible range like a file browser.',
81
+ },
82
+ {
83
+ keys: ['Left click'],
84
+ action: 'Move the whole multi-selection',
85
+ note:
86
+ 'With 2+ objects selected, in 2D and 3D alike: drag the selection (or its dashed box) to slide it; click it to pick it up and place with the next click.',
87
+ },
88
+ {
89
+ keys: ['R', 'T'],
90
+ action: 'Rotate a multi-selection ±45° around its center',
91
+ note: 'Also works mid-move while carrying the selection.',
92
+ },
93
+ {
94
+ keys: ['Esc'],
95
+ action: 'Clear the selection',
96
+ note: 'Clicking empty space does the same.',
97
+ },
98
+ ],
99
+ },
100
+ {
101
+ title: 'Direct Manipulation',
102
+ shortcuts: [
103
+ {
104
+ keys: ['Cmd/Ctrl', 'Left click'],
105
+ action: 'Move the selected movable object under the cursor',
106
+ note: 'Drag in Select mode with a single object selected. Guided snapping and guides are enabled by default.',
107
+ },
108
+ {
109
+ keys: ['Cmd/Ctrl', 'Right click'],
110
+ action: 'Rotate the selected object under the cursor',
111
+ note: 'Drag left or right in Select mode with a single object selected. Rotation snaps to 15° increments by default.',
112
+ },
113
+ {
114
+ keys: ['Cmd/Ctrl', 'Shift', 'Right click'],
115
+ action: 'Rotate freely',
116
+ note: 'Hold Shift during the drag to bypass the 15° rotation increment.',
75
117
  },
76
118
  ],
77
119
  },
@@ -80,16 +122,27 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
80
122
  shortcuts: [
81
123
  {
82
124
  keys: ['Shift'],
83
- action: 'Temporarily disable angle snapping while drawing walls, slabs, and ceilings',
84
- note: 'Hold while drawing.',
125
+ action: 'Bypass guided snapping and angle constraints',
126
+ note: 'Hold during the active gesture. Passive guide or measurement feedback may stay visible.',
127
+ },
128
+ {
129
+ keys: ['Shift'],
130
+ action: 'Rotate freely, bypassing the default 15° rotation snap',
131
+ note: 'Hold while dragging a rotate handle or direct-rotation gesture.',
85
132
  },
86
133
  ],
87
134
  },
88
135
  {
89
136
  title: 'Item Placement',
90
137
  shortcuts: [
91
- { keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
92
- { keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
138
+ {
139
+ keys: ['R', 'T'],
140
+ action: 'Rotate item; with a door selected, R toggles open/closed and T closes',
141
+ },
142
+ {
143
+ keys: ['E'],
144
+ action: 'Operate the selected node — doors, windows, and cabinet doors/drawers animate open/closed',
145
+ },
93
146
  {
94
147
  keys: ['Shift'],
95
148
  action: 'Temporarily bypass placement validation constraints',
@@ -157,7 +210,8 @@ export function KeyboardShortcutsDialog() {
157
210
  <DialogHeader className="shrink-0 border-b px-6 py-4">
158
211
  <DialogTitle>Keyboard Shortcuts</DialogTitle>
159
212
  <DialogDescription>
160
- Shortcuts are context-aware and depend on the current phase or tool.
213
+ Shortcuts are context-aware. Guided constraints are enabled by default; hold Shift
214
+ during an active gesture to build freely.
161
215
  </DialogDescription>
162
216
  </DialogHeader>
163
217
 
@@ -1,4 +1,5 @@
1
1
  import type { BuildStats, SchemaIssue, ValidateBuildJsonResult } from '@pascal-app/core'
2
+ import { useViewer } from '@pascal-app/viewer'
2
3
  import {
3
4
  AlertTriangle,
4
5
  AppWindow,
@@ -22,6 +23,11 @@ import {
22
23
  DialogHeader,
23
24
  DialogTitle,
24
25
  } from '../../../../../components/ui/primitives/dialog'
26
+ import {
27
+ getAreaUnitLabel,
28
+ type LinearUnit,
29
+ squareMetersToAreaUnit,
30
+ } from '../../../../../lib/measurements'
25
31
 
26
32
  export type PendingImport = {
27
33
  fileName: string
@@ -80,15 +86,17 @@ function formatFileSize(bytes: number): string {
80
86
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
81
87
  }
82
88
 
83
- function formatFloorArea(m2: number): string {
89
+ function formatFloorArea(m2: number, unit: LinearUnit): string {
84
90
  if (m2 === 0) return '—'
85
- if (m2 < 10) return `${m2.toFixed(2)} m²`
86
- return `${m2.toFixed(1)} m²`
91
+ const value = squareMetersToAreaUnit(m2, unit)
92
+ const label = getAreaUnitLabel(unit)
93
+ return `${value < 10 ? value.toFixed(2) : value.toFixed(1)} ${label}`
87
94
  }
88
95
 
89
96
  export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
90
97
  const [showAllWarnings, setShowAllWarnings] = useState(false)
91
98
  const [showSchemaIssues, setShowSchemaIssues] = useState(false)
99
+ const unit = useViewer((state) => state.unit)
92
100
 
93
101
  if (!pending) return null
94
102
 
@@ -165,7 +173,7 @@ export function LoadBuildDialog({ pending, onCancel, onConfirm }: Props) {
165
173
  <div className="flex items-center justify-between border-t px-3 py-2">
166
174
  <span className="text-muted-foreground text-sm">Floor area</span>
167
175
  <span className="font-medium text-sm">
168
- {formatFloorArea(stats.floorAreaM2)}
176
+ {formatFloorArea(stats.floorAreaM2, unit)}
169
177
  </span>
170
178
  </div>
171
179
  )}
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
4
  import { memo, useCallback, useEffect, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
6
+ import { formatAreaLabel } from './../../../../../lib/measurements'
6
7
  import useEditor from './../../../../../store/use-editor'
7
8
  import { InlineRenameInput } from './inline-rename-input'
8
9
  import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
@@ -32,6 +33,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({
32
33
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
33
34
  const setSelection = useViewer((state) => state.setSelection)
34
35
  const setHoveredId = useViewer((state) => state.setHoveredId)
36
+ const unit = useViewer((state) => state.unit)
35
37
 
36
38
  // Expand when a descendant is selected — imperative to avoid subscribing to the full selectedIds array
37
39
  useEffect(() => {
@@ -75,8 +77,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({
75
77
  const handleStartEditing = useCallback(() => setIsEditing(true), [])
76
78
  const handleStopEditing = useCallback(() => setIsEditing(false), [])
77
79
 
78
- const area = calculatePolygonArea(polygon).toFixed(1)
79
- const defaultName = `Ceiling (${area}m²)`
80
+ const defaultName = `Ceiling (${formatAreaLabel(calculatePolygonArea(polygon), unit)})`
80
81
 
81
82
  return (
82
83
  <TreeNodeWrapper
@@ -85,7 +86,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({
85
86
  expanded={expanded}
86
87
  hasChildren={children.length > 0}
87
88
  icon={
88
- <Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
89
+ <Image alt="" className="object-contain" height={14} src="/icons/ceiling.webp" width={14} />
89
90
  }
90
91
  isHovered={isHovered}
91
92
  isLast={isLast}
@@ -2,6 +2,7 @@ import { type AnyNodeId, type ChimneyNode, useScene } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
4
  import { memo, useCallback, useState } from 'react'
5
+ import { SnapTargetIcon } from '../../../snap-target-badge'
5
6
  import useEditor from './../../../../../store/use-editor'
6
7
  import { InlineRenameInput } from './inline-rename-input'
7
8
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -51,13 +52,15 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
51
52
  expanded={false}
52
53
  hasChildren={false}
53
54
  icon={
54
- <Image
55
- alt=""
56
- className="object-contain opacity-60"
57
- height={14}
58
- src="/icons/roof.png"
59
- width={14}
60
- />
55
+ <SnapTargetIcon target="roof">
56
+ <Image
57
+ alt=""
58
+ className="object-contain opacity-60"
59
+ height={14}
60
+ src="/icons/roof.webp"
61
+ width={14}
62
+ />
63
+ </SnapTargetIcon>
61
64
  }
62
65
  isHovered={isHovered}
63
66
  isLast={isLast}
@@ -51,7 +51,7 @@ export const ColumnTreeNode = memo(function ColumnTreeNode({
51
51
  expanded={false}
52
52
  hasChildren={false}
53
53
  icon={
54
- <Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
54
+ <Image alt="" className="object-contain" height={14} src="/icons/column.webp" width={14} />
55
55
  }
56
56
  isHovered={isHovered}
57
57
  isLast={isLast}
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, useScene } from '@pascal-app/core'
3
+ import { type AnyNodeId, type DoorNode, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
5
  import Image from 'next/image'
6
6
  import { memo, useCallback, useState } from 'react'
7
+ import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
7
8
  import useEditor from './../../../../../store/use-editor'
8
9
  import { InlineRenameInput } from './inline-rename-input'
9
10
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -22,6 +23,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
22
23
  }: DoorTreeNodeProps) {
23
24
  const [isEditing, setIsEditing] = useState(false)
24
25
  const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
26
+ const node = useScene((s) => s.nodes[nodeId] as DoorNode | undefined)
25
27
  const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
26
28
  const isHovered = useViewer((state) => state.hoveredId === nodeId)
27
29
  const setSelection = useViewer((state) => state.setSelection)
@@ -45,6 +47,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
45
47
 
46
48
  const handleStartEditing = useCallback(() => setIsEditing(true), [])
47
49
  const handleStopEditing = useCallback(() => setIsEditing(false), [])
50
+ const snapTarget = resolveNodeSnapTarget(node) ?? 'wall'
48
51
 
49
52
  return (
50
53
  <TreeNodeWrapper
@@ -53,7 +56,9 @@ export const DoorTreeNode = memo(function DoorTreeNode({
53
56
  expanded={false}
54
57
  hasChildren={false}
55
58
  icon={
56
- <Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} />
59
+ <SnapTargetIcon target={snapTarget}>
60
+ <Image alt="" className="object-contain" height={14} src="/icons/door.webp" width={14} />
61
+ </SnapTargetIcon>
57
62
  }
58
63
  isHovered={isHovered}
59
64
  isLast={isLast}