@pascal-app/editor 1.0.0-beta.3 → 1.0.0-beta.5

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 (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. package/src/store/use-session-groups.ts +103 -0
@@ -1,17 +1,40 @@
1
1
  'use client'
2
2
 
3
+ import { Icon } from '@iconify/react'
4
+ import {
5
+ type AnyNode,
6
+ type AnyNodeId,
7
+ getInspectorExtensions,
8
+ type IconRef,
9
+ type InspectorExtension,
10
+ useRegistryVersion,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
3
14
  import { ChevronDown, ChevronLeft, GripHorizontal, RotateCcw, X } from 'lucide-react'
4
15
  import Image from 'next/image'
5
16
  import {
17
+ type ComponentType,
6
18
  createContext,
19
+ lazy,
20
+ Suspense,
7
21
  useCallback,
8
22
  useContext,
23
+ useEffect,
9
24
  useLayoutEffect,
25
+ useMemo,
10
26
  useRef,
11
27
  useState,
12
28
  } from 'react'
13
29
  import { useIsMobile } from '../../../hooks/use-mobile'
30
+ import {
31
+ resolveActiveExtension,
32
+ toggleCard,
33
+ toggleExtension,
34
+ } from '../../../lib/inspector-card-mode'
14
35
  import { cn } from '../../../lib/utils'
36
+ import { PanelSection } from '../controls/panel-section'
37
+ import { ErrorBoundary } from '../primitives/error-boundary'
15
38
 
16
39
  const DRAG_MARGIN = 8
17
40
  // Pointer travel (px) below which a header press is treated as a click
@@ -92,6 +115,36 @@ export function PanelWrapper({
92
115
 
93
116
  const panelRef = useRef<HTMLDivElement>(null)
94
117
 
118
+ // ── Plugin inspector extensions ────────────────────────────────────
119
+ // The wrapper self-resolves the selected node instead of taking a prop:
120
+ // kind-owned `customPanel`s (wall, slab, …) render their own
121
+ // <PanelWrapper>, so this is the one spot every inspector card flows
122
+ // through. Extensions only apply to a single-node selection.
123
+ const registryVersion = useRegistryVersion()
124
+ const selectedId = useViewer((s) =>
125
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
126
+ ) as AnyNodeId | undefined
127
+ // Subscribe to the selected node's *type* only — a string primitive that
128
+ // doesn't change as fields are edited (same trick as ParametricInspector).
129
+ const selectedType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
130
+ const installedPlugins = useScene((s) => s.installedPlugins)
131
+ const extensions = useMemo(() => {
132
+ // re-derive when plugin extensions register after mount (async plugin load)
133
+ void registryVersion
134
+ if (!selectedType) return []
135
+ return getInspectorExtensions(selectedType).filter(
136
+ (extension) => !extension.pluginId || installedPlugins.includes(extension.pluginId),
137
+ )
138
+ }, [selectedType, installedPlugins, registryVersion])
139
+
140
+ // Which extension's content fills the card body (extension mode). The two
141
+ // expanded modes are EITHER/OR: extension mode replaces the regular
142
+ // controls; null shows the regular controls with no extension sections.
143
+ // See `lib/inspector-card-mode.ts` for the transition table.
144
+ const [activeExtensionId, setActiveExtensionId] = useState<string | null>(null)
145
+ // Stale ids (kind changed, plugin gated off) fall back to regular mode.
146
+ const activeExtension = resolveActiveExtension(activeExtensionId, extensions)
147
+
95
148
  // The whole panel is collapsed to just its header by default; the chevron
96
149
  // expands it to reveal the inspector body. Keep the desktop value shared
97
150
  // across inspector swaps (roof ↔ segment, etc.) so navigating between
@@ -109,6 +162,26 @@ export function PanelWrapper({
109
162
  [],
110
163
  )
111
164
 
165
+ const applyMode = useCallback(
166
+ (next: { collapsed: boolean; activeExtensionId: string | null }) => {
167
+ setCollapsed(next.collapsed)
168
+ setActiveExtensionId(next.activeExtensionId)
169
+ },
170
+ [setCollapsed],
171
+ )
172
+
173
+ // Chevron / header press — collapsed → regular, regular → collapsed,
174
+ // extension mode → regular (exit the extension first, stay expanded).
175
+ const handleCardToggle = useCallback(() => {
176
+ applyMode(toggleCard({ collapsed, activeExtensionId }))
177
+ }, [applyMode, collapsed, activeExtensionId])
178
+
179
+ // Folding the card forgets the active extension — extension mode is a
180
+ // one-shot affordance of the header icon, not sticky panel state.
181
+ useEffect(() => {
182
+ if (collapsed) setActiveExtensionId(null)
183
+ }, [collapsed])
184
+
112
185
  // Drag-to-reposition from the header. `offset` is a translation applied on
113
186
  // top of the default `top-20 right-4` anchor; null until first dragged.
114
187
  // Dragging is clamped so no edge of the panel leaves the viewport.
@@ -173,15 +246,19 @@ export function PanelWrapper({
173
246
  setOffset({ x: drag.baseX + (left - drag.rectLeft), y: drag.baseY + (top - drag.rectTop) })
174
247
  }, [])
175
248
 
176
- const handleHeaderPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
177
- const drag = dragRef.current
178
- if (!drag) return
179
- dragRef.current = null
180
- setIsDragging(false)
181
- e.currentTarget.releasePointerCapture(e.pointerId)
182
- // A press that never turned into a drag is a click → toggle collapse.
183
- if (!drag.moved) setCollapsed((c) => !c)
184
- }, [])
249
+ const handleHeaderPointerUp = useCallback(
250
+ (e: React.PointerEvent<HTMLDivElement>) => {
251
+ const drag = dragRef.current
252
+ if (!drag) return
253
+ dragRef.current = null
254
+ setIsDragging(false)
255
+ e.currentTarget.releasePointerCapture(e.pointerId)
256
+ // A press that never turned into a drag is a click → same mode toggle
257
+ // as the chevron.
258
+ if (!drag.moved) handleCardToggle()
259
+ },
260
+ [handleCardToggle],
261
+ )
185
262
 
186
263
  // Expanding can grow the panel past an edge if it was dragged there while
187
264
  // collapsed — nudge it back inside the viewer bounds.
@@ -278,11 +355,39 @@ export function PanelWrapper({
278
355
  <RotateCcw className="h-4 w-4" />
279
356
  </button>
280
357
  )}
358
+ {/* Extension mode buttons — one icon per registered inspector
359
+ extension, left of the chevron. Click swaps the card body to
360
+ ONLY that extension's content (either/or with the regular
361
+ controls); the active icon (highlighted) or the chevron
362
+ returns to the regular controls. */}
363
+ {extensions.map((extension) => {
364
+ const isActive = !collapsed && activeExtensionId === extension.id
365
+ return (
366
+ <button
367
+ aria-label={isActive ? `Close ${extension.title}` : `Open ${extension.title}`}
368
+ aria-pressed={isActive}
369
+ className={cn(
370
+ 'flex h-7 w-7 items-center justify-center rounded-md transition-colors',
371
+ isActive
372
+ ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
373
+ : 'bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
374
+ )}
375
+ key={extension.id}
376
+ onClick={() =>
377
+ applyMode(toggleExtension({ collapsed, activeExtensionId }, extension.id))
378
+ }
379
+ title={extension.title}
380
+ type="button"
381
+ >
382
+ {renderExtensionIcon(extension.icon)}
383
+ </button>
384
+ )
385
+ })}
281
386
  <button
282
387
  aria-expanded={!collapsed}
283
388
  aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
284
389
  className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
285
- onClick={() => setCollapsed((c) => !c)}
390
+ onClick={handleCardToggle}
286
391
  type="button"
287
392
  >
288
393
  <ChevronDown
@@ -302,9 +407,37 @@ export function PanelWrapper({
302
407
  </div>
303
408
  )}
304
409
 
305
- {/* Content — hidden while the panel is collapsed (desktop). */}
410
+ {/* Content — hidden while the panel is collapsed (desktop). The two
411
+ expanded modes are EITHER/OR: extension mode renders ONLY that
412
+ extension's content; regular mode renders ONLY the kind's own
413
+ controls (`children`). A stale extension id falls back to regular
414
+ via `resolveActiveExtension`. */}
306
415
  {!(collapsed && !isMobile) && (
307
- <div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
416
+ <div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
417
+ {!isMobile && selectedId && activeExtension ? (
418
+ <InspectorExtensionSection
419
+ extension={activeExtension}
420
+ key={activeExtension.id}
421
+ nodeId={selectedId}
422
+ />
423
+ ) : (
424
+ <>
425
+ {children}
426
+ {/* Mobile sheet has no header icons to swap modes — keep the
427
+ plugin sections appended after the kind's controls there. */}
428
+ {isMobile &&
429
+ selectedId &&
430
+ extensions.map((extension) => (
431
+ <InspectorExtensionSection
432
+ defaultExpanded={false}
433
+ extension={extension}
434
+ key={extension.id}
435
+ nodeId={selectedId}
436
+ />
437
+ ))}
438
+ </>
439
+ )}
440
+ </div>
308
441
  )}
309
442
 
310
443
  {resolvedFooter && !(collapsed && !isMobile) && (
@@ -313,3 +446,86 @@ export function PanelWrapper({
313
446
  </div>
314
447
  )
315
448
  }
449
+
450
+ // ─── Plugin inspector extensions ──────────────────────────────────────
451
+
452
+ /** 16px icon for an extension's header button — mirrors the parametric
453
+ * inspector's `renderIcon` (plain <img> so no next/image server deps). */
454
+ function renderExtensionIcon(ref: IconRef): React.ReactNode {
455
+ if (ref.kind === 'url') {
456
+ return <img alt="" className="h-4 w-4 shrink-0 object-contain" src={ref.src} />
457
+ }
458
+ if (ref.kind === 'iconify') {
459
+ return <Icon height={16} icon={ref.name} width={16} />
460
+ }
461
+ if (ref.kind === 'svg') {
462
+ return (
463
+ <svg height={16} viewBox={ref.viewBox} width={16}>
464
+ <path d={ref.path} fill="currentColor" />
465
+ </svg>
466
+ )
467
+ }
468
+ const LazyIcon = lazy(ref.module)
469
+ return (
470
+ <Suspense fallback={null}>
471
+ <LazyIcon />
472
+ </Suspense>
473
+ )
474
+ }
475
+
476
+ // `React.lazy` once per loader so the resolved component keeps a stable
477
+ // identity across renders — same WeakMap pattern as `resolveCustomPanel`
478
+ // in parametric-inspector.tsx.
479
+ const extensionComponentCache = new WeakMap<
480
+ InspectorExtension['component'],
481
+ ComponentType<{ node: AnyNode }>
482
+ >()
483
+
484
+ function resolveExtensionComponent(
485
+ extension: InspectorExtension,
486
+ ): ComponentType<{ node: AnyNode }> {
487
+ const cached = extensionComponentCache.get(extension.component)
488
+ if (cached) return cached
489
+ // `LazyComponent` is prop-agnostic; the inspector-extension contract is
490
+ // that the default export accepts `{ node }` (see the type's docs).
491
+ const Comp = lazy(extension.component) as unknown as ComponentType<{ node: AnyNode }>
492
+ extensionComponentCache.set(extension.component, Comp)
493
+ return Comp
494
+ }
495
+
496
+ /**
497
+ * One plugin-contributed inspector section. Subscribes to the full node —
498
+ * the section body reflects any edit — and hands it to the extension's
499
+ * lazy component inside its own error boundary so a crashing plugin
500
+ * section can't take down the inspector card. On desktop this is the
501
+ * card's SOLE body while its extension is active (either/or with the
502
+ * regular controls); the mobile sheet appends it after them instead.
503
+ */
504
+ function InspectorExtensionSection({
505
+ defaultExpanded = true,
506
+ extension,
507
+ nodeId,
508
+ }: {
509
+ defaultExpanded?: boolean
510
+ extension: InspectorExtension
511
+ nodeId: AnyNodeId
512
+ }) {
513
+ const node = useScene((s) => s.nodes[nodeId])
514
+ if (!node) return null
515
+ const Extension = resolveExtensionComponent(extension)
516
+ return (
517
+ <PanelSection defaultExpanded={defaultExpanded} title={extension.title}>
518
+ <ErrorBoundary
519
+ fallback={
520
+ <p className="p-1 text-muted-foreground text-xs">
521
+ “{extension.title}” hit an error and was unloaded for this session.
522
+ </p>
523
+ }
524
+ >
525
+ <Suspense fallback={null}>
526
+ <Extension node={node} />
527
+ </Suspense>
528
+ </ErrorBoundary>
529
+ </PanelSection>
530
+ )
531
+ }
@@ -118,9 +118,14 @@ export function FunctionTreePanel({
118
118
  <div className="flex h-full flex-col">
119
119
  {/* Root nodes as a category grid — icon when available, otherwise a
120
120
  two-letter abbreviation, with the full name in a hover tooltip.
121
- Mirrors the Build tab's tile grid so the two panels read the same. */}
121
+ Mirrors the Build tab's tile grid so the two panels read the same.
122
+
123
+ `functionTree` is embedder-supplied and unbounded, and these tiles are
124
+ `aspect-square`, so row count grows with it while the item list below is
125
+ the only scroller. Without the cap a large taxonomy pushes the list out
126
+ of the panel entirely and nothing can scroll it back. */}
122
127
  <TooltipProvider delayDuration={0} disableHoverableContent>
123
- <div className="grid shrink-0 grid-cols-5 gap-1.5 border-border/70 border-b p-2">
128
+ <div className="grid max-h-[40%] shrink-0 grid-cols-5 gap-1.5 overflow-y-auto border-border/70 border-b p-2">
124
129
  {functionTree.map((root) => {
125
130
  const isActive = activeRoot?.slug === root.slug
126
131
  return (
@@ -106,6 +106,17 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
106
106
  action: 'Rotate a multi-selection ±45° around its center',
107
107
  note: 'Also works mid-move while carrying the selection.',
108
108
  },
109
+ {
110
+ keys: ['Cmd/Ctrl', 'G'],
111
+ action: 'Group the multi-selection (session only)',
112
+ note:
113
+ 'Editor-only. Plain click a member later to reselect the whole group. Not saved with the project.',
114
+ },
115
+ {
116
+ keys: ['Cmd/Ctrl', 'Shift', 'G'],
117
+ action: 'Ungroup the session selection',
118
+ note: 'Keeps the current selection; only dissolves the session group.',
119
+ },
109
120
  {
110
121
  keys: ['Esc'],
111
122
  action: 'Clear the selection',
@@ -51,6 +51,7 @@ import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
51
51
  import { cn } from './../../../../../lib/utils'
52
52
  import useEditor from './../../../../../store/use-editor'
53
53
  import { useUploadStore } from '../../../../../store/use-upload'
54
+ import { MetricControl } from '../../../controls/metric-control'
54
55
  import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
55
56
  import { InlineRenameInput } from './inline-rename-input'
56
57
  import { focusTreeNode, TreeNode } from './tree-node'
@@ -873,6 +874,17 @@ const LevelItem = memo(function LevelItem({
873
874
  initial={{ height: 0, opacity: 0 }}
874
875
  transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
875
876
  >
877
+ <div className="relative border-border/50 border-b py-2 pr-3 pl-[60px]">
878
+ <div className="pointer-events-none absolute top-0 bottom-0 left-[45px] z-10 w-px bg-border/50" />
879
+ <MetricControl
880
+ label="Base elevation"
881
+ onChange={(value) => updateNode(level.id, { baseElevation: value })}
882
+ precision={2}
883
+ step={0.05}
884
+ unit="m"
885
+ value={Math.round((level.baseElevation ?? 0) * 100) / 100}
886
+ />
887
+ </div>
876
888
  <LevelReferences
877
889
  isLastLevel={isLast}
878
890
  levelId={level.id}
@@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'motion/react'
5
5
  import { forwardRef, memo, useEffect, useRef } from 'react'
6
6
  import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
7
7
  import useEditor from '../../../../../store/use-editor'
8
+ import { expandSessionSelectionForNode } from '../../../../../store/use-session-groups'
8
9
 
9
10
  export function handleTreeSelection(
10
11
  e: React.MouseEvent,
@@ -48,7 +49,13 @@ export function handleTreeSelection(
48
49
  }
49
50
  }
50
51
 
51
- setSelection({ selectedIds: [nodeId] })
52
+ if (e.altKey) {
53
+ setSelection({ selectedIds: [nodeId] })
54
+ return false
55
+ }
56
+
57
+ const expanded = expandSessionSelectionForNode(nodeId)
58
+ setSelection({ selectedIds: expanded && expanded.length > 1 ? expanded : [nodeId] })
52
59
  return false
53
60
  }
54
61
 
@@ -13,6 +13,12 @@ export type SidebarTab = {
13
13
  mobileIcon?: ReactNode
14
14
  /** Desktop icon shown in the vertical rail (v2 layout). */
15
15
  icon?: ReactNode
16
+ /**
17
+ * Rail entry that drives the stage instead of opening a sidebar panel:
18
+ * activating it hides the panel column (preserving its collapse state) and
19
+ * keeps the icon highlighted regardless of collapse.
20
+ */
21
+ noPanel?: boolean
16
22
  }
17
23
 
18
24
  interface TabBarProps {
@@ -75,7 +81,7 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
75
81
  const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
76
82
 
77
83
  const renderTab = (tab: SidebarTab) => {
78
- const showActive = activeTab === tab.id && !collapsed
84
+ const showActive = activeTab === tab.id && (!collapsed || tab.noPanel === true)
79
85
  return (
80
86
  <Tooltip key={tab.id}>
81
87
  <TooltipTrigger asChild>
@@ -0,0 +1,50 @@
1
+ 'use client'
2
+
3
+ import type React from 'react'
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
5
+
6
+ export type FloorplanCompassButtonProps = {
7
+ northRotationDeg: number
8
+ onAlignNorth: () => void
9
+ needleRef?: React.RefObject<SVGSVGElement | null>
10
+ }
11
+
12
+ export function FloorplanCompassButton({
13
+ northRotationDeg,
14
+ onAlignNorth,
15
+ needleRef,
16
+ }: FloorplanCompassButtonProps) {
17
+ return (
18
+ <Tooltip>
19
+ <TooltipTrigger asChild>
20
+ <button
21
+ aria-label="Align view to north"
22
+ className="group pointer-events-auto absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
23
+ onClick={(event) => {
24
+ event.preventDefault()
25
+ event.stopPropagation()
26
+ onAlignNorth()
27
+ }}
28
+ onPointerDown={(event) => {
29
+ event.stopPropagation()
30
+ }}
31
+ type="button"
32
+ >
33
+ <span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
34
+ <svg
35
+ aria-hidden="true"
36
+ className="h-6 w-6"
37
+ ref={needleRef}
38
+ style={{ transform: `rotate(${northRotationDeg}deg)` }}
39
+ viewBox="0 0 48 48"
40
+ >
41
+ <path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
42
+ <path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
43
+ </svg>
44
+ </span>
45
+ </button>
46
+ </TooltipTrigger>
47
+ <TooltipContent side="right">Align view to north</TooltipContent>
48
+ </Tooltip>
49
+ )
50
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { FloorplanGeometry } from '@pascal-app/core'
3
+ import {
4
+ clientToFloorplanPoint,
5
+ getFloorplanBounds,
6
+ padFloorplanBounds,
7
+ panFloorplanViewBox,
8
+ scaleFloorplanViewBox,
9
+ scaleFloorplanViewBoxBetweenClients,
10
+ } from './floorplan-preview-geometry'
11
+
12
+ describe('floorplan preview bounds', () => {
13
+ test('composes nested translation and rotation', () => {
14
+ const geometry: FloorplanGeometry = {
15
+ kind: 'group',
16
+ transform: { translate: [10, 5], rotate: Math.PI / 2 },
17
+ children: [
18
+ {
19
+ kind: 'rect',
20
+ x: 0,
21
+ y: 0,
22
+ width: 4,
23
+ height: 2,
24
+ },
25
+ ],
26
+ }
27
+
28
+ expect(getFloorplanBounds([geometry])).toEqual({
29
+ minX: 8,
30
+ minY: 5,
31
+ maxX: 10,
32
+ maxY: 9,
33
+ })
34
+ })
35
+
36
+ test('includes image rotation and dimension baselines', () => {
37
+ const geometries: FloorplanGeometry[] = [
38
+ {
39
+ kind: 'image',
40
+ url: '/symbol.png',
41
+ center: [2, 3],
42
+ width: 4,
43
+ height: 2,
44
+ rotation: Math.PI / 2,
45
+ },
46
+ {
47
+ kind: 'dimension',
48
+ start: [-3, -2],
49
+ end: [3, -2],
50
+ dimensionStart: [-4, -1],
51
+ dimensionEnd: [4, -1],
52
+ offsetNormal: [0, -1],
53
+ offsetDistance: 1,
54
+ extensionOvershoot: 0.2,
55
+ text: '8 m',
56
+ },
57
+ ]
58
+
59
+ expect(getFloorplanBounds(geometries)).toEqual({
60
+ minX: -4,
61
+ minY: -2,
62
+ maxX: 4,
63
+ maxY: 5,
64
+ })
65
+ })
66
+
67
+ test('pads narrow plans by a usable minimum', () => {
68
+ expect(padFloorplanBounds({ minX: 0, minY: 0, maxX: 0, maxY: 0 })).toEqual({
69
+ minX: -0.75,
70
+ minY: -0.75,
71
+ maxX: 0.75,
72
+ maxY: 0.75,
73
+ })
74
+ })
75
+
76
+ test('maps clients through letterboxed SVG content', () => {
77
+ const viewBox = { x: 0, y: 0, width: 10, height: 10 }
78
+ const viewport = { left: 0, top: 0, width: 1000, height: 500 }
79
+
80
+ expect(clientToFloorplanPoint(viewBox, viewport, 250, 0)).toEqual([0, 0])
81
+ expect(clientToFloorplanPoint(viewBox, viewport, 750, 500)).toEqual([10, 10])
82
+ expect(panFloorplanViewBox(viewBox, viewport, [500, 250], [550, 250])).toEqual({
83
+ x: -1,
84
+ y: 0,
85
+ width: 10,
86
+ height: 10,
87
+ })
88
+ })
89
+
90
+ test('keeps the plan point beneath a moving pinch midpoint', () => {
91
+ const viewBox = { x: 0, y: 0, width: 10, height: 10 }
92
+ const viewport = { left: 0, top: 0, width: 1000, height: 500 }
93
+ const next = scaleFloorplanViewBoxBetweenClients(viewBox, 0.5, viewport, [500, 250], [600, 250])
94
+
95
+ expect(next).toEqual({ x: 1.5, y: 2.5, width: 5, height: 5 })
96
+ expect(clientToFloorplanPoint(next, viewport, 600, 250)).toEqual([5, 5])
97
+ })
98
+
99
+ test('clamps zoom without changing the view box aspect ratio', () => {
100
+ expect(scaleFloorplanViewBox({ x: 0, y: 0, width: 100, height: 1 }, 0.01)).toEqual({
101
+ x: 37.5,
102
+ y: 0.375,
103
+ width: 25,
104
+ height: 0.25,
105
+ })
106
+ })
107
+ })