@ifc-lite/viewer 1.15.0 → 1.16.0

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.
@@ -3,13 +3,12 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  import { useState, useEffect, useCallback, useMemo } from 'react';
6
- import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, Zap, Wrench, Plus } from 'lucide-react';
6
+ import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, ChevronRight, Zap, Wrench, Plus, Package } from 'lucide-react';
7
7
  import { Button } from '@/components/ui/button';
8
8
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
9
9
  import { KEYBOARD_SHORTCUTS } from '@/hooks/useKeyboardShortcuts';
10
10
 
11
11
  const GITHUB_URL = 'https://github.com/louistrue/ifc-lite';
12
- const INITIAL_RELEASE_COUNT = 5;
13
12
 
14
13
  interface InfoDialogProps {
15
14
  open: boolean;
@@ -35,85 +34,172 @@ const TYPE_CONFIG = {
35
34
  } as const;
36
35
 
37
36
  function AboutTab() {
37
+ const [showPackages, setShowPackages] = useState(false);
38
+ const packageVersions = __PACKAGE_VERSIONS__;
39
+
38
40
  return (
39
- <div className="space-y-4">
41
+ <div className="space-y-3">
40
42
  {/* Header */}
41
- <div className="text-center pb-4 border-b">
43
+ <div className="text-center pb-2 border-b">
42
44
  <h3 className="text-xl font-bold">ifc-lite</h3>
43
- <p className="text-sm text-muted-foreground mt-1">
44
- Version {__APP_VERSION__}
45
- </p>
46
45
  <p className="text-xs text-muted-foreground mt-0.5">
47
- Built {formatBuildDate(__BUILD_DATE__)}
48
- </p>
49
- </div>
50
-
51
- {/* Description */}
52
- <div className="space-y-2">
53
- <p className="text-sm">
54
- A high-performance IFC viewer for BIM models, built with WebGPU.
46
+ v{__APP_VERSION__} &middot; {formatBuildDate(__BUILD_DATE__)}
55
47
  </p>
56
48
  </div>
57
49
 
58
- {/* Features */}
59
- <div className="space-y-2">
60
- <h4 className="text-sm font-medium">Features</h4>
61
- <ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
62
- <li>WebGPU-accelerated 3D rendering</li>
63
- <li>IFC4 and IFC5/IFCX format support</li>
64
- <li>Multi-model federation</li>
65
- <li>Spatial hierarchy navigation</li>
66
- <li>Section planes and measurements</li>
67
- <li>Property inspection</li>
68
- </ul>
69
- </div>
70
-
71
50
  {/* Links */}
72
- <div className="pt-4 border-t space-y-2">
51
+ <div className="flex items-center justify-center gap-4 text-xs">
73
52
  <a
74
53
  href={GITHUB_URL}
75
54
  target="_blank"
76
55
  rel="noopener noreferrer"
77
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
56
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
78
57
  >
79
- <Github className="h-4 w-4" />
80
- <span>View on GitHub</span>
81
- <ExternalLink className="h-3 w-3" />
58
+ <Github className="h-3.5 w-3.5" />
59
+ GitHub
82
60
  </a>
83
61
  <a
84
62
  href={`${GITHUB_URL}/issues`}
85
63
  target="_blank"
86
64
  rel="noopener noreferrer"
87
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
65
+ className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
88
66
  >
89
- <span className="w-4 text-center">🐛</span>
90
- <span>Report an issue</span>
67
+ Report issue
91
68
  <ExternalLink className="h-3 w-3" />
92
69
  </a>
70
+ <span className="text-muted-foreground">MPL-2.0</span>
93
71
  </div>
94
72
 
95
- {/* License */}
96
- <div className="pt-4 border-t">
97
- <p className="text-xs text-muted-foreground text-center">
98
- Licensed under Mozilla Public License 2.0
99
- </p>
73
+ {/* Feature chips */}
74
+ <div className="flex flex-wrap gap-1 justify-center pt-2 border-t">
75
+ {[
76
+ 'WebGPU', 'IFC2x3', 'IFC4', 'IFC4X3', 'IFC5/IFCX',
77
+ 'Federation', 'Measurements', 'Sections',
78
+ 'Properties', 'Data tables', 'Lens rules', 'IDS',
79
+ '2D drawings', 'BCF', 'Scripting', 'AI assistant',
80
+ 'glTF export', 'CSV', 'Parquet',
81
+ ].map((tag) => (
82
+ <span
83
+ key={tag}
84
+ className="px-2 py-0.5 text-[11px] rounded-full bg-muted/60 text-muted-foreground"
85
+ >
86
+ {tag}
87
+ </span>
88
+ ))}
100
89
  </div>
90
+
91
+ {/* Package Versions */}
92
+ {packageVersions.length > 0 && (
93
+ <div className="pt-2 border-t">
94
+ <button
95
+ onClick={() => setShowPackages(!showPackages)}
96
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
97
+ >
98
+ {showPackages ? (
99
+ <ChevronDown className="h-3 w-3" />
100
+ ) : (
101
+ <ChevronRight className="h-3 w-3" />
102
+ )}
103
+ <Package className="h-3 w-3" />
104
+ {packageVersions.length} packages
105
+ </button>
106
+ {showPackages && (
107
+ <div className="rounded-md border bg-muted/30 p-2 mt-1.5 max-h-48 overflow-y-auto">
108
+ <div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
109
+ {packageVersions.map((pkg) => (
110
+ <div
111
+ key={pkg.name}
112
+ className="flex items-center justify-between text-xs py-0.5 px-1 min-w-0"
113
+ >
114
+ <span className="text-muted-foreground font-mono truncate mr-2">
115
+ {pkg.name.replace('@ifc-lite/', '')}
116
+ </span>
117
+ <span className="font-mono shrink-0 tabular-nums">{pkg.version}</span>
118
+ </div>
119
+ ))}
120
+ </div>
121
+ </div>
122
+ )}
123
+ </div>
124
+ )}
101
125
  </div>
102
126
  );
103
127
  }
104
128
 
129
+ function formatPkgName(name: string): string {
130
+ return name.replace('@ifc-lite/', '');
131
+ }
132
+
133
+ type TimelineEntry = {
134
+ version: string;
135
+ isViewerVersion: boolean;
136
+ entries: Array<{ pkg: string; highlights: typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'] }>;
137
+ };
138
+
139
+ const compareSemver = (a: string, b: string) => {
140
+ const pa = a.split('.').map(Number);
141
+ const pb = b.split('.').map(Number);
142
+ for (let i = 0; i < 3; i++) {
143
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
144
+ }
145
+ return 0;
146
+ };
147
+
148
+ /** Merge all per-package changelogs into a unified timeline grouped by version. */
149
+ function buildTimeline(
150
+ packageChangelogs: typeof __RELEASE_HISTORY__,
151
+ viewerVersion: string
152
+ ): TimelineEntry[] {
153
+ type Highlights = typeof __RELEASE_HISTORY__[0]['releases'][0]['highlights'];
154
+ const versionMap = new Map<string, Map<string, Highlights>>();
155
+
156
+ for (const pkg of packageChangelogs) {
157
+ for (const release of pkg.releases) {
158
+ if (!versionMap.has(release.version)) {
159
+ versionMap.set(release.version, new Map());
160
+ }
161
+ versionMap.get(release.version)!.set(pkg.name, release.highlights);
162
+ }
163
+ }
164
+
165
+ return Array.from(versionMap.entries())
166
+ .sort(([a], [b]) => compareSemver(a, b))
167
+ .map(([version, pkgMap]) => ({
168
+ version,
169
+ isViewerVersion: version === viewerVersion,
170
+ entries: Array.from(pkgMap.entries())
171
+ .sort(([a], [b]) => a.localeCompare(b))
172
+ .map(([pkg, highlights]) => ({ pkg, highlights })),
173
+ }));
174
+ }
175
+
105
176
  function WhatsNewTab() {
106
- const [showAll, setShowAll] = useState(false);
107
- const releases = __RELEASE_HISTORY__;
177
+ const packageChangelogs = __RELEASE_HISTORY__;
178
+ const viewerVersion = __APP_VERSION__;
179
+ const [expandedVersions, setExpandedVersions] = useState<Set<string>>(() => new Set());
108
180
 
109
- const visibleReleases = useMemo(
110
- () => (showAll ? releases : releases.slice(0, INITIAL_RELEASE_COUNT)),
111
- [releases, showAll]
181
+ const timeline = useMemo(
182
+ () => buildTimeline(packageChangelogs, viewerVersion),
183
+ [packageChangelogs, viewerVersion]
112
184
  );
113
185
 
114
- const hasMore = releases.length > INITIAL_RELEASE_COUNT;
186
+ // Auto-expand the first version with actual changes
187
+ useEffect(() => {
188
+ if (timeline.length > 0 && expandedVersions.size === 0) {
189
+ setExpandedVersions(new Set([timeline[0].version]));
190
+ }
191
+ }, [timeline]);
115
192
 
116
- if (releases.length === 0) {
193
+ const toggleVersion = useCallback((version: string) => {
194
+ setExpandedVersions((prev) => {
195
+ const next = new Set(prev);
196
+ if (next.has(version)) next.delete(version);
197
+ else next.add(version);
198
+ return next;
199
+ });
200
+ }, []);
201
+
202
+ if (timeline.length === 0) {
117
203
  return (
118
204
  <div className="text-center py-8 text-sm text-muted-foreground">
119
205
  No release history available.
@@ -122,43 +208,59 @@ function WhatsNewTab() {
122
208
  }
123
209
 
124
210
  return (
125
- <div className="space-y-4">
126
- {visibleReleases.map((release, i) => (
127
- <div key={release.version}>
128
- <div className="flex items-center gap-2 mb-1.5">
129
- <span className="text-sm font-semibold">v{release.version}</span>
130
- {i === 0 && (
131
- <span className="px-1.5 py-0.5 text-[10px] font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 rounded">
132
- latest
211
+ <div className="space-y-1">
212
+ {timeline.map((release) => {
213
+ const isExpanded = expandedVersions.has(release.version);
214
+ const totalHighlights = release.entries.reduce((s, e) => s + e.highlights.length, 0);
215
+ return (
216
+ <div key={release.version}>
217
+ <button
218
+ onClick={() => toggleVersion(release.version)}
219
+ className="flex items-center gap-2 w-full py-1.5 px-1 text-left hover:bg-muted/40 transition-colors rounded"
220
+ >
221
+ {isExpanded ? (
222
+ <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
223
+ ) : (
224
+ <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
225
+ )}
226
+ <span className="text-sm font-semibold">v{release.version}</span>
227
+ {release.isViewerVersion && (
228
+ <span className="px-1.5 py-0.5 text-[10px] font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 rounded">
229
+ viewer
230
+ </span>
231
+ )}
232
+ <span className="text-xs text-muted-foreground ml-auto">
233
+ {totalHighlights} change{totalHighlights !== 1 ? 's' : ''}
133
234
  </span>
235
+ </button>
236
+ {isExpanded && (
237
+ <div className="ml-5 pb-2 space-y-2">
238
+ {release.entries.map(({ pkg, highlights }) => (
239
+ <div key={pkg}>
240
+ <span className="text-xs font-medium font-mono text-muted-foreground">
241
+ {formatPkgName(pkg)}
242
+ </span>
243
+ <ul className="space-y-0.5 mt-0.5">
244
+ {highlights.map((h) => {
245
+ const { icon: Icon, className } = TYPE_CONFIG[h.type];
246
+ return (
247
+ <li
248
+ key={h.text}
249
+ className="flex items-start gap-1.5 text-sm text-muted-foreground"
250
+ >
251
+ <Icon className={`h-3 w-3 mt-0.5 shrink-0 ${className}`} />
252
+ <span>{h.text}</span>
253
+ </li>
254
+ );
255
+ })}
256
+ </ul>
257
+ </div>
258
+ ))}
259
+ </div>
134
260
  )}
135
261
  </div>
136
- <ul className="space-y-1 ml-0.5">
137
- {release.highlights.map((h) => {
138
- const { icon: Icon, className } = TYPE_CONFIG[h.type];
139
- return (
140
- <li key={h.text} className="flex items-start gap-2 text-sm text-muted-foreground">
141
- <Icon className={`h-3.5 w-3.5 mt-0.5 shrink-0 ${className}`} />
142
- <span>{h.text}</span>
143
- </li>
144
- );
145
- })}
146
- </ul>
147
- {i < visibleReleases.length - 1 && (
148
- <div className="border-b mt-3" />
149
- )}
150
- </div>
151
- ))}
152
-
153
- {hasMore && !showAll && (
154
- <button
155
- onClick={() => setShowAll(true)}
156
- className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mx-auto"
157
- >
158
- <ChevronDown className="h-3.5 w-3.5" />
159
- Show all {releases.length} releases
160
- </button>
161
- )}
262
+ );
263
+ })}
162
264
 
163
265
  {/* Legend */}
164
266
  <div className="pt-3 border-t flex items-center justify-center gap-4 text-[11px] text-muted-foreground">
@@ -4,13 +4,7 @@
4
4
 
5
5
  import {
6
6
  ChevronRight,
7
- Building2,
8
7
  Layers,
9
- MapPin,
10
- FolderKanban,
11
- Square,
12
- Box,
13
- DoorOpen,
14
8
  Eye,
15
9
  EyeOff,
16
10
  FileBox,
@@ -20,21 +14,21 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
20
14
  import { cn } from '@/lib/utils';
21
15
  import type { TreeNode } from './types';
22
16
  import { isSpatialContainer } from './types';
17
+ import { IFC_ICON_CODEPOINTS, IFC_ICON_DEFAULT } from './ifc-icons';
23
18
 
24
- const TYPE_ICONS: Record<string, React.ElementType> = {
19
+ /**
20
+ * Resolve the Material Symbols code point for a given IFC type string.
21
+ * Falls back to the generic product icon for unmapped classes.
22
+ */
23
+ function getIfcIconCodepoint(ifcType: string | undefined): string {
24
+ if (!ifcType) return IFC_ICON_DEFAULT;
25
+ return IFC_ICON_CODEPOINTS[ifcType] ?? IFC_ICON_DEFAULT;
26
+ }
27
+
28
+ /** Lucide fallback icons for non-IFC node types */
29
+ const NODE_TYPE_ICONS: Record<string, React.ElementType> = {
25
30
  'unified-storey': Layers,
26
31
  'model-header': FileBox,
27
- 'ifc-type': Building2,
28
- IfcProject: FolderKanban,
29
- IfcSite: MapPin,
30
- IfcBuilding: Building2,
31
- IfcBuildingStorey: Layers,
32
- IfcSpace: Box,
33
- IfcWall: Square,
34
- IfcWallStandardCase: Square,
35
- IfcDoor: DoorOpen,
36
- element: Box,
37
- default: Box,
38
32
  };
39
33
 
40
34
  export interface HierarchyNodeProps {
@@ -69,7 +63,9 @@ export function HierarchyNode({
69
63
  onModelHeaderClick,
70
64
  }: HierarchyNodeProps) {
71
65
  const resolvedType = node.ifcType || node.type;
72
- const Icon = TYPE_ICONS[resolvedType] || TYPE_ICONS[node.type] || TYPE_ICONS.default;
66
+ // Use Lucide icon for non-IFC structural nodes, Material Symbols for IFC classes
67
+ const LucideIcon = NODE_TYPE_ICONS[node.type];
68
+ const iconCodepoint = getIfcIconCodepoint(resolvedType);
73
69
 
74
70
  // Model header nodes (for visibility control and expansion)
75
71
  if (node.type === 'model-header' && node.id.startsWith('model-')) {
@@ -259,7 +255,17 @@ export function HierarchyNode({
259
255
  {/* Type Icon */}
260
256
  <Tooltip>
261
257
  <TooltipTrigger asChild>
262
- <Icon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
258
+ {LucideIcon ? (
259
+ <LucideIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
260
+ ) : (
261
+ <span
262
+ className="material-symbols-outlined shrink-0 leading-none text-zinc-500 dark:text-zinc-400"
263
+ style={{ fontSize: '14px' }}
264
+ aria-hidden="true"
265
+ >
266
+ {iconCodepoint}
267
+ </span>
268
+ )}
263
269
  </TooltipTrigger>
264
270
  <TooltipContent>
265
271
  <p className="text-xs">{resolvedType}</p>
@@ -0,0 +1,90 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * IFC class to Material Symbols icon code point mapping.
7
+ * Based on https://github.com/AECgeeks/ifc-icons (MIT license).
8
+ *
9
+ * Values are Unicode code points for the Material Symbols Outlined font.
10
+ */
11
+ export const IFC_ICON_CODEPOINTS: Record<string, string> = {
12
+ // Spatial / context
13
+ IfcContext: '\uf1c4',
14
+ IfcProject: '\uf1c4',
15
+ IfcProjectLibrary: '\uf1c4',
16
+ IfcSite: '\ue80b',
17
+ IfcBuilding: '\uea40',
18
+ IfcBuildingStorey: '\ue8fe',
19
+ IfcSpace: '\ueff4',
20
+
21
+ // Structural
22
+ IfcBeam: '\uf108',
23
+ IfcBeamStandardCase: '\uf108',
24
+ IfcColumn: '\ue233',
25
+ IfcColumnStandardCase: '\ue233',
26
+ IfcWall: '\ue3c0',
27
+ IfcWallStandardCase: '\ue3c0',
28
+ IfcWallElementedCase: '\ue3c0',
29
+ IfcSlab: '\ue229',
30
+ IfcSlabStandardCase: '\ue229',
31
+ IfcSlabElementedCase: '\ue229',
32
+ IfcRoof: '\uf201',
33
+ IfcFooting: '\uf200',
34
+ IfcPile: '\ue047',
35
+ IfcPlate: '\ue047',
36
+ IfcPlateStandardCase: '\ue047',
37
+ IfcMember: '\ue047',
38
+ IfcMemberStandardCase: '\ue047',
39
+
40
+ // Openings & access
41
+ IfcDoor: '\ueb4f',
42
+ IfcDoorStandardCase: '\ueb4f',
43
+ IfcWindow: '\uf088',
44
+ IfcWindowStandardCase: '\uf088',
45
+ IfcOpeningElement: '\ue3c6',
46
+ IfcOpeningStandardCase: '\ue3c6',
47
+ IfcCurtainWall: '\ue047',
48
+
49
+ // Vertical circulation
50
+ IfcStair: '\uf1a9',
51
+ IfcStairFlight: '\uf1a9',
52
+ IfcRamp: '\ue86b',
53
+ IfcRampFlight: '\ue86b',
54
+ IfcRailing: '\ue58f',
55
+
56
+ // Furnishing
57
+ IfcFurnishingElement: '\uea45',
58
+ IfcFurniture: '\uea45',
59
+ IfcSystemFurnitureElement: '\uea45',
60
+
61
+ // MEP terminals
62
+ IfcAirTerminal: '\uefd8',
63
+ IfcLamp: '\uf02a',
64
+ IfcLightFixture: '\uf02a',
65
+ IfcSanitaryTerminal: '\uea41',
66
+ IfcSpaceHeater: '\uf076',
67
+ IfcAudioVisualAppliance: '\ue333',
68
+ IfcSensor: '\ue51e',
69
+
70
+ // Assemblies & misc
71
+ IfcElementAssembly: '\ue9b0',
72
+ IfcTransportElement: '\uf1a0',
73
+ IfcGrid: '\uf015',
74
+ IfcPort: '\ue8c0',
75
+ IfcDistributionPort: '\ue8c0',
76
+ IfcAnnotation: '\ue3c9',
77
+
78
+ // Civil / geographic
79
+ IfcCivilElement: '\uea99',
80
+ IfcGeographicElement: '\uea99',
81
+ IfcLinearElement: '\uebaa',
82
+
83
+ // Proxy / generic fallback
84
+ IfcProduct: '\ue047',
85
+ IfcBuildingElementProxy: '\ue047',
86
+ IfcProxy: '\ue047',
87
+ };
88
+
89
+ /** Default code point for unmapped IFC classes (Material Symbols "widgets" / generic product) */
90
+ export const IFC_ICON_DEFAULT = '\ue047';
@@ -16,7 +16,7 @@ import {
16
16
  type IfcDataStore as CacheDataStore,
17
17
  type GeometryData,
18
18
  } from '@ifc-lite/cache';
19
- import { SpatialHierarchyBuilder, StepTokenizer, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
19
+ import { SpatialHierarchyBuilder, StepTokenizer, buildCompactEntityIndex, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
20
20
  import { buildSpatialIndex } from '@ifc-lite/spatial';
21
21
  import type { MeshData } from '@ifc-lite/geometry';
22
22
 
@@ -102,27 +102,27 @@ export function useIfcCache() {
102
102
 
103
103
  // Quick scan to rebuild entity index with byte offsets (needed for on-demand extraction)
104
104
  const tokenizer = new StepTokenizer(dataStore.source);
105
- const entityIndex = {
106
- byId: new Map<number, any>(),
107
- byType: new Map<string, number[]>(),
108
- };
105
+ const entityRefs: Array<{ expressId: number; type: string; byteOffset: number; byteLength: number; lineNumber: number }> = [];
106
+ const byType = new Map<string, number[]>();
109
107
 
110
108
  for (const ref of tokenizer.scanEntitiesFast()) {
111
- entityIndex.byId.set(ref.expressId, {
109
+ entityRefs.push({
112
110
  expressId: ref.expressId,
113
111
  type: ref.type,
114
112
  byteOffset: ref.offset,
115
113
  byteLength: ref.length,
116
114
  lineNumber: ref.line,
117
115
  });
118
- let typeList = entityIndex.byType.get(ref.type);
116
+ let typeList = byType.get(ref.type);
119
117
  if (!typeList) {
120
118
  typeList = [];
121
- entityIndex.byType.set(ref.type, typeList);
119
+ byType.set(ref.type, typeList);
122
120
  }
123
121
  typeList.push(ref.expressId);
124
122
  }
125
- dataStore.entityIndex = entityIndex;
123
+ // Use compact entity index (typed arrays) for lower memory usage
124
+ const compactByIdIndex = buildCompactEntityIndex(entityRefs);
125
+ dataStore.entityIndex = { byId: compactByIdIndex, byType };
126
126
 
127
127
  // Rebuild on-demand maps from relationships
128
128
  // Pass entityIndex which contains ALL entity types including IfcPropertySet/IfcElementQuantity
@@ -6,7 +6,7 @@
6
6
  * Global keyboard shortcuts for the viewer
7
7
  */
8
8
 
9
- import { useEffect, useCallback } from 'react';
9
+ import { useEffect, useCallback, useRef } from 'react';
10
10
  import { useViewerStore } from '@/store';
11
11
  import { resetVisibilityForHomeFromStore } from '@/store/homeView';
12
12
  import {
@@ -33,9 +33,14 @@ function getAllSelectedGlobalIds(): number[] {
33
33
  return [];
34
34
  }
35
35
 
36
+ /** Double-escape threshold in milliseconds */
37
+ const DOUBLE_ESCAPE_MS = 500;
38
+
36
39
  export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
37
40
  const { enabled = true } = options;
38
41
 
42
+ const lastEscapeRef = useRef<number>(0);
43
+
39
44
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
40
45
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
41
46
  const activeTool = useViewerStore((s) => s.activeTool);
@@ -181,9 +186,29 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
181
186
  }
182
187
  }
183
188
 
184
- // Selection - Escape clears selection and switches to select tool
189
+ // Escape: first press clears selection/tool, double-press closes all panels
185
190
  if (key === 'escape') {
186
191
  e.preventDefault();
192
+ const now = Date.now();
193
+ const timeSinceLastEscape = now - lastEscapeRef.current;
194
+ lastEscapeRef.current = now;
195
+
196
+ if (timeSinceLastEscape < DOUBLE_ESCAPE_MS) {
197
+ // Double-escape: close all panels, return to starting view
198
+ const state = useViewerStore.getState();
199
+ state.setBcfPanelVisible(false);
200
+ state.setIdsPanelVisible(false);
201
+ state.setLensPanelVisible(false);
202
+ state.setScriptPanelVisible(false);
203
+ state.setListPanelVisible(false);
204
+ state.setDrawing2DPanelVisible(false);
205
+ state.setOverridesPanelVisible(false);
206
+ state.setChatPanelVisible(false);
207
+ state.setSheetPanelVisible(false);
208
+ state.setLeftPanelCollapsed(false);
209
+ state.setRightPanelCollapsed(false);
210
+ }
211
+
187
212
  setSelectedEntityId(null);
188
213
  resetVisibilityForHomeFromStore();
189
214
  setActiveTool('select');
@@ -246,6 +271,7 @@ export const KEYBOARD_SHORTCUTS = [
246
271
  { key: '1-6', description: 'Preset views', category: 'Camera' },
247
272
  { key: 'T', description: 'Toggle theme', category: 'UI' },
248
273
  { key: 'Esc', description: 'Reset all (clear selection, basket, isolation)', category: 'Selection' },
274
+ { key: 'Esc Esc', description: 'Close all panels (return to starting view)', category: 'UI' },
249
275
  { key: 'Ctrl+K', description: 'Command palette', category: 'UI' },
250
276
  { key: '?', description: 'Show info panel', category: 'Help' },
251
277
  ] as const;