@ifc-lite/viewer 1.18.0 → 1.19.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 (61) hide show
  1. package/.turbo/turbo-build.log +19 -16
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +444 -0
  4. package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  7. package/dist/assets/{exporters-B_OBqIyD.js → exporters-xbXqEDlO.js} +2547 -1958
  8. package/dist/assets/{geometry.worker-xHHy-9DV.js → geometry.worker-DQEZB2rB.js} +1 -1
  9. package/dist/assets/ids-2WdONLlu.js +2033 -0
  10. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  11. package/dist/assets/index-BXeEKqJG.css +1 -0
  12. package/dist/assets/{index-BKq-M3Mk.js → index-D8Epw-e7.js} +51781 -32599
  13. package/dist/assets/index-XwKzDuw6.js +22 -0
  14. package/dist/assets/{native-bridge-SHXiQwFW.js → native-bridge-DKmx1z95.js} +2 -2
  15. package/dist/assets/{sandbox-jez21HtV.js → sandbox-tccwm5Bo.js} +1402 -1329
  16. package/dist/assets/{server-client-ncOQVNso.js → server-client-LoWPK1N2.js} +1 -1
  17. package/dist/assets/three-CDRZThFA.js +4057 -0
  18. package/dist/assets/{wasm-bridge-DyfBSB8z.js → wasm-bridge-BsJGgPMs.js} +1 -1
  19. package/dist/index.html +8 -7
  20. package/dist/samples/building-architecture.ifc +453 -0
  21. package/dist/samples/hello-wall.ifc +1054 -0
  22. package/dist/samples/infra-bridge.ifc +962 -0
  23. package/package.json +13 -7
  24. package/public/samples/building-architecture.ifc +453 -0
  25. package/public/samples/hello-wall.ifc +1054 -0
  26. package/public/samples/infra-bridge.ifc +962 -0
  27. package/src/App.tsx +37 -3
  28. package/src/components/mcp/HeroScene.tsx +876 -0
  29. package/src/components/mcp/McpLanding.tsx +1318 -0
  30. package/src/components/mcp/McpPlayground.tsx +524 -0
  31. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  32. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  33. package/src/components/mcp/README.md +171 -0
  34. package/src/components/mcp/data.ts +659 -0
  35. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  36. package/src/components/mcp/playground-files.ts +107 -0
  37. package/src/components/mcp/playground-uploads.ts +122 -0
  38. package/src/components/mcp/types.ts +65 -0
  39. package/src/components/mcp/use-mcp-page.ts +109 -0
  40. package/src/components/viewer/MainToolbar.tsx +23 -2
  41. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  42. package/src/components/viewer/Viewport.tsx +18 -1
  43. package/src/components/viewer/ViewportContainer.tsx +78 -9
  44. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  45. package/src/components/viewer/tools/AddElementOverlay.tsx +43 -2
  46. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  47. package/src/components/viewer/usePointCloudSync.ts +98 -0
  48. package/src/generated/mcp-catalog.json +82 -0
  49. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  50. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  51. package/src/hooks/useIfcFederation.ts +72 -3
  52. package/src/hooks/useIfcLoader.ts +67 -3
  53. package/src/services/file-dialog.ts +4 -2
  54. package/src/store/index.ts +10 -1
  55. package/src/store/slices/pointCloudSlice.ts +102 -0
  56. package/src/store/types.ts +7 -0
  57. package/vite.config.ts +7 -0
  58. package/dist/assets/basketViewActivator-Cm1QEk_R.js +0 -1
  59. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  60. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  61. package/dist/assets/index-COnQRuqY.css +0 -1
@@ -0,0 +1,107 @@
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
+ * playground-files.ts — virtual file store for the playground.
7
+ *
8
+ * Tools that "write a file" (bcf_export, model_save, export_ifc / csv /
9
+ * json) DON'T trigger a browser download — that would be a surprising
10
+ * privacy issue and against the user's explicit "never auto-download"
11
+ * rule. Instead they push the artifact into this store, which a
12
+ * Downloads panel in the playground sidebar lists with a per-row
13
+ * "Download" button. The actual `Blob` → `<a download>` click only
14
+ * happens when the user presses that button.
15
+ */
16
+ import { useEffect, useState } from 'react';
17
+
18
+ export interface PlaygroundFile {
19
+ /** Stable id used by tools to refer back to a written artifact. */
20
+ id: string;
21
+ /** Suggested filename used when the user clicks Download. */
22
+ filename: string;
23
+ /** MIME type for the download Blob. */
24
+ mimeType: string;
25
+ /** Bytes — read once, cheap. */
26
+ size: number;
27
+ /** The data. */
28
+ blob: Blob;
29
+ /** ms since epoch. */
30
+ createdAt: number;
31
+ /** Tool that produced it (`bcf_export`, `model_save`, …). */
32
+ source: string;
33
+ /** Free-form line shown under the filename in the UI. */
34
+ description?: string;
35
+ }
36
+
37
+ class FileStore {
38
+ private files: PlaygroundFile[] = [];
39
+ private listeners = new Set<() => void>();
40
+ private nextId = 1;
41
+
42
+ add(input: Omit<PlaygroundFile, 'id' | 'createdAt'>): PlaygroundFile {
43
+ const file: PlaygroundFile = {
44
+ ...input,
45
+ id: `pg-file-${this.nextId++}`,
46
+ createdAt: Date.now(),
47
+ };
48
+ this.files = [file, ...this.files];
49
+ this.notify();
50
+ return file;
51
+ }
52
+
53
+ list(): PlaygroundFile[] {
54
+ return this.files;
55
+ }
56
+
57
+ remove(id: string): void {
58
+ this.files = this.files.filter((f) => f.id !== id);
59
+ this.notify();
60
+ }
61
+
62
+ clear(): void {
63
+ this.files = [];
64
+ this.notify();
65
+ }
66
+
67
+ /** User-triggered. Synthesises an <a download> click — never called by
68
+ * tool code, only by the explicit Download button. */
69
+ download(id: string): void {
70
+ const file = this.files.find((f) => f.id === id);
71
+ if (!file) return;
72
+ const url = URL.createObjectURL(file.blob);
73
+ const a = document.createElement('a');
74
+ a.href = url;
75
+ a.download = file.filename;
76
+ a.style.display = 'none';
77
+ document.body.appendChild(a);
78
+ a.click();
79
+ a.remove();
80
+ // Revoke after a tick so the browser actually fetched the blob.
81
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
82
+ }
83
+
84
+ subscribe(listener: () => void): () => void {
85
+ this.listeners.add(listener);
86
+ return () => this.listeners.delete(listener);
87
+ }
88
+
89
+ private notify(): void {
90
+ for (const l of this.listeners) l();
91
+ }
92
+ }
93
+
94
+ export const playgroundFiles = new FileStore();
95
+
96
+ /** React hook for components that want to render the file list reactively. */
97
+ export function usePlaygroundFiles(): PlaygroundFile[] {
98
+ const [files, setFiles] = useState<PlaygroundFile[]>(() => playgroundFiles.list());
99
+ useEffect(() => playgroundFiles.subscribe(() => setFiles(playgroundFiles.list())), []);
100
+ return files;
101
+ }
102
+
103
+ export function formatBytes(bytes: number): string {
104
+ if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
105
+ if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB';
106
+ return bytes + ' B';
107
+ }
@@ -0,0 +1,122 @@
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
+ * playground-uploads.ts — virtual file system for INPUTS the user attaches
7
+ * to a chat turn (IDS specs, side IFC files for diff, BCF imports later).
8
+ *
9
+ * Mirrors the playground-files.ts shape but for the other direction: the
10
+ * user drops a `.ids` (or any text file) onto the chat textarea, we cache
11
+ * it here, and tools that take a `*_path` argument (ids_validate,
12
+ * ids_explain) resolve the path through this store BEFORE asking the agent
13
+ * to inline the XML. That lifts the "the playground can't read disk" wart
14
+ * and makes the chat experience continuous.
15
+ */
16
+
17
+ import { useEffect, useState } from 'react';
18
+
19
+ export interface UploadedFile {
20
+ /** The original filename, used as the lookup key (paths are normalised
21
+ * to the basename so the agent can pass `./foo.ids` or just `foo.ids`). */
22
+ name: string;
23
+ /** MIME type as the browser saw it. May be empty for `.ids`. */
24
+ mimeType: string;
25
+ /** Bytes — for sizing the chip and bounding what we accept. */
26
+ size: number;
27
+ /** Text content if the file is text/* — this is the path we use for
28
+ * ids_validate. Binaries store an empty string here and put bytes in
29
+ * `bytes` (future use; v1 only handles text). */
30
+ text: string;
31
+ /** Wall-clock when the user attached it. */
32
+ uploadedAt: number;
33
+ }
34
+
35
+ class UploadStore {
36
+ private uploads: UploadedFile[] = [];
37
+ private listeners = new Set<() => void>();
38
+
39
+ /** Read the file as text and stash it. Returns the entry.
40
+ *
41
+ * We only decode known-text formats — `.bcfzip` and other binaries
42
+ * are zip archives whose .text() decode would chew through tens of
43
+ * megabytes on the main thread for no user benefit (the playground
44
+ * has no read path for binary attachments yet). */
45
+ async add(file: File): Promise<UploadedFile> {
46
+ const name = file.name.split(/[\\/]/).pop() ?? file.name;
47
+ const mimeType = file.type || guessMimeType(name);
48
+ const text = isTextLike(name, mimeType) ? await file.text() : '';
49
+ const entry: UploadedFile = {
50
+ name,
51
+ mimeType,
52
+ size: file.size,
53
+ text,
54
+ uploadedAt: Date.now(),
55
+ };
56
+ // De-dup by basename — re-attaching with the same name overwrites.
57
+ this.uploads = [entry, ...this.uploads.filter((u) => u.name !== name)];
58
+ this.notify();
59
+ return entry;
60
+ }
61
+
62
+ /** Resolve a path-ish string to an upload. Tolerates absolute paths,
63
+ * ./relative, and bare filenames. */
64
+ resolve(pathOrName: string): UploadedFile | null {
65
+ if (!pathOrName) return null;
66
+ const base = pathOrName.split(/[\\/]/).pop() ?? pathOrName;
67
+ return this.uploads.find((u) => u.name === base) ?? null;
68
+ }
69
+
70
+ list(): UploadedFile[] {
71
+ return this.uploads;
72
+ }
73
+
74
+ remove(name: string): void {
75
+ this.uploads = this.uploads.filter((u) => u.name !== name);
76
+ this.notify();
77
+ }
78
+
79
+ clear(): void {
80
+ this.uploads = [];
81
+ this.notify();
82
+ }
83
+
84
+ subscribe(listener: () => void): () => void {
85
+ this.listeners.add(listener);
86
+ return () => this.listeners.delete(listener);
87
+ }
88
+
89
+ private notify(): void {
90
+ for (const l of this.listeners) l();
91
+ }
92
+ }
93
+
94
+ export const playgroundUploads = new UploadStore();
95
+
96
+ export function usePlaygroundUploads(): UploadedFile[] {
97
+ const [uploads, setUploads] = useState<UploadedFile[]>(() => playgroundUploads.list());
98
+ useEffect(() => playgroundUploads.subscribe(() => setUploads(playgroundUploads.list())), []);
99
+ return uploads;
100
+ }
101
+
102
+ /** Whether we should decode this attachment as text. STEP files (.ifc) and
103
+ * IDS specs are text under the hood; .bcfzip / arbitrary octet-stream
104
+ * attachments are zip archives we only ever surface as references. */
105
+ function isTextLike(name: string, mimeType: string): boolean {
106
+ if (mimeType.startsWith('text/')) return true;
107
+ if (mimeType === 'application/json' || mimeType === 'application/xml' || mimeType === 'application/xhtml+xml') return true;
108
+ const ext = name.toLowerCase().split('.').pop();
109
+ return ext === 'ifc' || ext === 'ids' || ext === 'csv' || ext === 'tsv' || ext === 'xml' || ext === 'json' || ext === 'txt' || ext === 'md';
110
+ }
111
+
112
+ function guessMimeType(name: string): string {
113
+ const ext = name.toLowerCase().split('.').pop() ?? '';
114
+ switch (ext) {
115
+ case 'ids': return 'application/xml';
116
+ case 'xml': return 'application/xml';
117
+ case 'json': return 'application/json';
118
+ case 'csv': return 'text/csv';
119
+ case 'txt': return 'text/plain';
120
+ default: return 'application/octet-stream';
121
+ }
122
+ }
@@ -0,0 +1,65 @@
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
+ * Shared types for the /mcp landing page variants.
7
+ *
8
+ * The catalog shape mirrors what `node packages/mcp/dist/cli.js --dump-tools`
9
+ * is expected to emit. Until that script lands, the landing pages can fall
10
+ * back to `MOCK_CATALOG` from ./data.ts so we can iterate on visuals.
11
+ */
12
+
13
+ export type ToolScope = 'read' | 'mutate' | 'export';
14
+
15
+ export type ToolCategory =
16
+ | 'Discovery'
17
+ | 'Query'
18
+ | 'Geometry'
19
+ | 'Validation'
20
+ | 'Mutation'
21
+ | 'BCF'
22
+ | 'bSDD'
23
+ | 'Diff'
24
+ | 'Export'
25
+ | 'Viewer';
26
+
27
+ export interface CatalogTool {
28
+ name: string;
29
+ description: string;
30
+ scope: ToolScope;
31
+ category: ToolCategory;
32
+ inputSchema: unknown;
33
+ }
34
+
35
+ export interface McpCatalog {
36
+ generatedAt?: string;
37
+ version?: string;
38
+ tools: CatalogTool[];
39
+ }
40
+
41
+ export type McpClientId = 'claude-desktop' | 'cursor' | 'windsurf' | 'vscode' | 'goose';
42
+
43
+ export interface McpClient {
44
+ id: McpClientId;
45
+ name: string;
46
+ /** Short blurb shown under the button title. */
47
+ blurb: string;
48
+ /** Path to a logo image inside /public, or null if we render a glyph. */
49
+ logo?: string;
50
+ /** Optional deep-link URL scheme prefix (cursor://, windsurf://, vscode:). */
51
+ deepLinkPrefix?: string;
52
+ /** Where the user pastes the JSON snippet. */
53
+ configHint: string;
54
+ }
55
+
56
+ export interface McpRecipe {
57
+ id: string;
58
+ title: string;
59
+ /** The actual prompt the user copies. */
60
+ prompt: string;
61
+ /** Comma-separated tool names this recipe likely fans out to. */
62
+ uses: string[];
63
+ /** Visual category for grouping/coloring. */
64
+ family: 'audit' | 'visualize' | 'validate' | 'author' | 'compare' | 'discover';
65
+ }
@@ -0,0 +1,109 @@
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
+ * Tiny utilities shared by every landing-page variant:
7
+ *
8
+ * • useFonts(href) — injects a Google Fonts <link> only while a /mcp page
9
+ * is mounted, so the global app stays unbothered.
10
+ * • useCopyToClipboard() — returns [copy, isJustCopied] for the install
11
+ * snippets and recipe cards.
12
+ * • useDocumentMeta(title, themeColor)
13
+ * — keeps <title> / theme-color in sync per variant.
14
+ *
15
+ * None of these touch React Suspense / global state — they’re plain
16
+ * useEffect plumbing so the variants can be lifted out of the chooser
17
+ * without surprises.
18
+ */
19
+
20
+ import { useEffect, useState } from 'react';
21
+
22
+ /** Inject a stylesheet <link> while this hook is mounted. Idempotent. */
23
+ export function useFonts(...hrefs: string[]): void {
24
+ useEffect(() => {
25
+ const tags: HTMLLinkElement[] = [];
26
+ for (const href of hrefs) {
27
+ const existing = document.head.querySelector(`link[data-mcp-font="${href}"]`);
28
+ if (existing) {
29
+ // Already injected by another variant — refcount via a data attribute.
30
+ const refs = Number(existing.getAttribute('data-refs') ?? '1') + 1;
31
+ existing.setAttribute('data-refs', String(refs));
32
+ tags.push(existing as HTMLLinkElement);
33
+ continue;
34
+ }
35
+ const link = document.createElement('link');
36
+ link.rel = 'stylesheet';
37
+ link.href = href;
38
+ link.setAttribute('data-mcp-font', href);
39
+ link.setAttribute('data-refs', '1');
40
+ document.head.appendChild(link);
41
+ tags.push(link);
42
+ }
43
+ return () => {
44
+ for (const tag of tags) {
45
+ const refs = Number(tag.getAttribute('data-refs') ?? '1') - 1;
46
+ if (refs <= 0) tag.parentNode?.removeChild(tag);
47
+ else tag.setAttribute('data-refs', String(refs));
48
+ }
49
+ };
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ }, [hrefs.join('|')]);
52
+ }
53
+
54
+ /** Browser-safe clipboard with a 1.4s "just copied" indicator. */
55
+ export function useCopyToClipboard(): {
56
+ copy: (text: string, key?: string) => Promise<void>;
57
+ copiedKey: string | null;
58
+ } {
59
+ const [copiedKey, setCopiedKey] = useState<string | null>(null);
60
+ return {
61
+ copiedKey,
62
+ copy: async (text: string, key?: string) => {
63
+ try {
64
+ await navigator.clipboard.writeText(text);
65
+ setCopiedKey(key ?? text);
66
+ setTimeout(() => setCopiedKey((curr) => (curr === (key ?? text) ? null : curr)), 1400);
67
+ } catch {
68
+ // Older browsers — fall through silently. The install dialogs always
69
+ // show the snippet anyway so the user can manually select+copy.
70
+ }
71
+ },
72
+ };
73
+ }
74
+
75
+ /** Set <title> + theme-color while mounted; restore on unmount. */
76
+ export function useDocumentMeta(title: string, themeColor?: string): void {
77
+ useEffect(() => {
78
+ const prevTitle = document.title;
79
+ document.title = title;
80
+ let prevTheme: string | null = null;
81
+ let prevThemeExisted = false;
82
+ let themeMeta: HTMLMetaElement | null = null;
83
+ if (themeColor) {
84
+ themeMeta = document.querySelector('meta[name="theme-color"]');
85
+ if (themeMeta) {
86
+ // Track existence separately so a meta tag without `content`
87
+ // doesn't permanently keep our temporary color after unmount.
88
+ prevThemeExisted = themeMeta.hasAttribute('content');
89
+ prevTheme = themeMeta.getAttribute('content');
90
+ themeMeta.setAttribute('content', themeColor);
91
+ }
92
+ }
93
+ return () => {
94
+ document.title = prevTitle;
95
+ if (themeMeta) {
96
+ if (prevThemeExisted && prevTheme !== null) themeMeta.setAttribute('content', prevTheme);
97
+ else themeMeta.removeAttribute('content');
98
+ }
99
+ };
100
+ }, [title, themeColor]);
101
+ }
102
+
103
+ /** Smooth-scroll to an in-page anchor; updates the URL hash. */
104
+ export function scrollToAnchor(id: string): void {
105
+ const el = document.getElementById(id);
106
+ if (!el) return;
107
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
108
+ history.replaceState(null, '', `#${id}`);
109
+ }
@@ -24,6 +24,7 @@ import {
24
24
  ArrowRight,
25
25
  Box,
26
26
  HelpCircle,
27
+ Sparkles,
27
28
  Loader2,
28
29
  Camera,
29
30
  Info,
@@ -425,6 +426,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
425
426
  // Filter to supported files (IFC, IFCX, GLB)
426
427
  const supportedFiles = Array.from(files).filter(
427
428
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
429
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
428
430
  );
429
431
 
430
432
  if (supportedFiles.length === 0) return;
@@ -465,6 +467,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
465
467
  // Filter to supported files (IFC, IFCX, GLB)
466
468
  const supportedFiles = Array.from(files).filter(
467
469
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
470
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
468
471
  );
469
472
 
470
473
  if (supportedFiles.length === 0) return;
@@ -779,7 +782,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
779
782
  id="file-input-open"
780
783
  ref={fileInputRef}
781
784
  type="file"
782
- accept=".ifc,.ifcx,.glb"
785
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
783
786
  multiple
784
787
  onChange={handleFileSelect}
785
788
  className="hidden"
@@ -787,7 +790,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
787
790
  <input
788
791
  ref={addModelInputRef}
789
792
  type="file"
790
- accept=".ifc,.ifcx,.glb"
793
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
791
794
  multiple
792
795
  onChange={handleAddModelSelect}
793
796
  className="hidden"
@@ -1331,6 +1334,24 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1331
1334
 
1332
1335
  {/* Right Side Actions */}
1333
1336
  <div className="flex items-center gap-2 ml-2 pl-2 border-l border-zinc-200 dark:border-zinc-700/60">
1337
+ {/* /mcp cross-link — lives in the meta cluster (Settings / Theme /
1338
+ Help) so it shares space with shell-level navigation rather
1339
+ than competing with the modeling tools to its left. */}
1340
+ <Tooltip>
1341
+ <TooltipTrigger asChild>
1342
+ <Button
1343
+ variant="ghost"
1344
+ size="icon"
1345
+ className="rounded-full"
1346
+ onClick={() => navigateToPath('/mcp')}
1347
+ aria-label="Open ifc-lite MCP"
1348
+ >
1349
+ <Sparkles className="!h-[20px] !w-[20px]" />
1350
+ </Button>
1351
+ </TooltipTrigger>
1352
+ <TooltipContent>Drive ifc-lite from any LLM (MCP)</TooltipContent>
1353
+ </Tooltip>
1354
+
1334
1355
  {desktopShell ? (
1335
1356
  <Tooltip>
1336
1357
  <TooltipTrigger asChild>
@@ -0,0 +1,174 @@
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
+ * Compact panel that exposes point cloud rendering controls (color mode,
7
+ * size mode, point size, EDL). Renders only when point cloud assets are
8
+ * loaded — sits over the canvas without affecting layout for IFC-only
9
+ * models.
10
+ */
11
+
12
+ import { useViewerStore } from '@/store';
13
+ import type { PointColorModeUi, PointSizeModeUi } from '@/store/slices/pointCloudSlice';
14
+ import { cn } from '@/lib/utils';
15
+
16
+ const COLOR_MODES: Array<{ value: PointColorModeUi; label: string; hint: string }> = [
17
+ { value: 'rgb', label: 'RGB', hint: 'Per-point colour from the source' },
18
+ { value: 'classification', label: 'Classification', hint: 'ASPRS class palette (ground, vegetation, building...)' },
19
+ { value: 'intensity', label: 'Intensity', hint: 'Greyscale ramp from per-point intensity' },
20
+ { value: 'height', label: 'Height', hint: 'Cool-warm ramp by Y-up world height' },
21
+ { value: 'fixed', label: 'Solid', hint: 'Single colour override' },
22
+ ];
23
+
24
+ const SIZE_MODES: Array<{ value: PointSizeModeUi; label: string; hint: string }> = [
25
+ { value: 'fixed-px', label: 'Fixed', hint: 'Always render at the slider value (in pixels)' },
26
+ { value: 'attenuated', label: 'Auto', hint: 'Adaptive (closer = bigger), clamped to the slider as max' },
27
+ { value: 'adaptive-world', label: 'World', hint: 'Pure world-space radius — splat covers N mm in source space' },
28
+ ];
29
+
30
+ export interface PointCloudPanelProps {
31
+ /** Number of currently-loaded point cloud assets — panel hides when 0. */
32
+ assetCount: number;
33
+ }
34
+
35
+ export function PointCloudPanel({ assetCount }: PointCloudPanelProps) {
36
+ const colorMode = useViewerStore((s) => s.pointCloudColorMode);
37
+ const setColorMode = useViewerStore((s) => s.setPointCloudColorMode);
38
+ const sizeMode = useViewerStore((s) => s.pointCloudSizeMode);
39
+ const setSizeMode = useViewerStore((s) => s.setPointCloudSizeMode);
40
+ const pointSize = useViewerStore((s) => s.pointCloudPointSize);
41
+ const setPointSize = useViewerStore((s) => s.setPointCloudPointSize);
42
+ const worldRadius = useViewerStore((s) => s.pointCloudWorldRadius);
43
+ const setWorldRadius = useViewerStore((s) => s.setPointCloudWorldRadius);
44
+ const edlEnabled = useViewerStore((s) => s.pointCloudEdlEnabled);
45
+ const setEdlEnabled = useViewerStore((s) => s.setPointCloudEdlEnabled);
46
+ const edlStrength = useViewerStore((s) => s.pointCloudEdlStrength);
47
+ const setEdlStrength = useViewerStore((s) => s.setPointCloudEdlStrength);
48
+
49
+ if (assetCount <= 0) return null;
50
+
51
+ return (
52
+ <div className="absolute bottom-4 left-4 z-10 pointer-events-auto bg-background/90 backdrop-blur-sm rounded-lg border shadow-lg p-2 flex flex-col gap-2 min-w-[200px]">
53
+ <div className="flex items-center justify-between gap-2">
54
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
55
+ Point Cloud
56
+ </span>
57
+ <span className="text-[10px] text-muted-foreground">
58
+ {assetCount} asset{assetCount === 1 ? '' : 's'}
59
+ </span>
60
+ </div>
61
+
62
+ {/* Color mode */}
63
+ <div className="flex flex-col gap-0.5">
64
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">Colour</span>
65
+ {COLOR_MODES.map((mode) => {
66
+ const active = colorMode === mode.value;
67
+ return (
68
+ <button
69
+ key={mode.value}
70
+ aria-pressed={active}
71
+ onClick={() => setColorMode(mode.value)}
72
+ title={mode.hint}
73
+ className={cn(
74
+ 'flex items-center gap-2 px-2 py-1 rounded text-xs transition-colors text-left',
75
+ active
76
+ ? 'bg-teal-600 text-white'
77
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
78
+ )}
79
+ >
80
+ {mode.label}
81
+ </button>
82
+ );
83
+ })}
84
+ </div>
85
+
86
+ {/* Size mode */}
87
+ <div className="flex flex-col gap-0.5">
88
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">Size</span>
89
+ <div className="grid grid-cols-3 gap-0.5">
90
+ {SIZE_MODES.map((mode) => {
91
+ const active = sizeMode === mode.value;
92
+ return (
93
+ <button
94
+ key={mode.value}
95
+ aria-pressed={active}
96
+ onClick={() => setSizeMode(mode.value)}
97
+ title={mode.hint}
98
+ className={cn(
99
+ 'px-1.5 py-1 rounded text-[11px] transition-colors',
100
+ active
101
+ ? 'bg-teal-600 text-white'
102
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
103
+ )}
104
+ >
105
+ {mode.label}
106
+ </button>
107
+ );
108
+ })}
109
+ </div>
110
+ <label className="flex items-center gap-2 mt-1">
111
+ <span className="text-[10px] text-muted-foreground w-8 shrink-0">{pointSize.toFixed(0)}px</span>
112
+ <input
113
+ type="range"
114
+ min={1}
115
+ max={20}
116
+ step={1}
117
+ value={pointSize}
118
+ onChange={(e) => setPointSize(Number(e.target.value))}
119
+ className="flex-1 h-1 accent-teal-600 cursor-pointer"
120
+ title="Splat size in pixels (or upper cap in Auto mode)"
121
+ />
122
+ </label>
123
+ {sizeMode !== 'fixed-px' && (
124
+ <label className="flex items-center gap-2">
125
+ <span className="text-[10px] text-muted-foreground w-8 shrink-0">
126
+ {(worldRadius * 1000).toFixed(0)}mm
127
+ </span>
128
+ <input
129
+ type="range"
130
+ min={1}
131
+ max={100}
132
+ step={1}
133
+ value={Math.round(worldRadius * 1000)}
134
+ onChange={(e) => setWorldRadius(Number(e.target.value) / 1000)}
135
+ className="flex-1 h-1 accent-teal-600 cursor-pointer"
136
+ title="World-space splat radius in millimetres"
137
+ />
138
+ </label>
139
+ )}
140
+ </div>
141
+
142
+ {/* EDL */}
143
+ <div className="flex flex-col gap-0.5">
144
+ <label className="flex items-center justify-between gap-2 cursor-pointer">
145
+ <span className="text-[9px] uppercase text-muted-foreground tracking-wider">EDL</span>
146
+ <input
147
+ type="checkbox"
148
+ checked={edlEnabled}
149
+ onChange={(e) => setEdlEnabled(e.target.checked)}
150
+ className="accent-teal-600"
151
+ title="Eye-Dome Lighting — adds depth perception via screen-space depth gradient"
152
+ />
153
+ </label>
154
+ {edlEnabled && (
155
+ <label className="flex items-center gap-2">
156
+ <span className="text-[10px] text-muted-foreground w-8 shrink-0">
157
+ {edlStrength.toFixed(1)}
158
+ </span>
159
+ <input
160
+ type="range"
161
+ min={0}
162
+ max={3}
163
+ step={0.1}
164
+ value={edlStrength}
165
+ onChange={(e) => setEdlStrength(Number(e.target.value))}
166
+ className="flex-1 h-1 accent-teal-600 cursor-pointer"
167
+ title="EDL strength multiplier"
168
+ />
169
+ </label>
170
+ )}
171
+ </div>
172
+ </div>
173
+ );
174
+ }