@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.
- package/.turbo/turbo-build.log +19 -16
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +444 -0
- package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-B_OBqIyD.js → exporters-xbXqEDlO.js} +2547 -1958
- package/dist/assets/{geometry.worker-xHHy-9DV.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ids-2WdONLlu.js +2033 -0
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-BXeEKqJG.css +1 -0
- package/dist/assets/{index-BKq-M3Mk.js → index-D8Epw-e7.js} +51781 -32599
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-SHXiQwFW.js → native-bridge-DKmx1z95.js} +2 -2
- package/dist/assets/{sandbox-jez21HtV.js → sandbox-tccwm5Bo.js} +1402 -1329
- package/dist/assets/{server-client-ncOQVNso.js → server-client-LoWPK1N2.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-DyfBSB8z.js → wasm-bridge-BsJGgPMs.js} +1 -1
- package/dist/index.html +8 -7
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/package.json +13 -7
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/MainToolbar.tsx +23 -2
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- package/src/components/viewer/Viewport.tsx +18 -1
- package/src/components/viewer/ViewportContainer.tsx +78 -9
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- package/src/components/viewer/tools/AddElementOverlay.tsx +43 -2
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/generated/mcp-catalog.json +82 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +67 -3
- package/src/services/file-dialog.ts +4 -2
- package/src/store/index.ts +10 -1
- package/src/store/slices/pointCloudSlice.ts +102 -0
- package/src/store/types.ts +7 -0
- package/vite.config.ts +7 -0
- package/dist/assets/basketViewActivator-Cm1QEk_R.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
- 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
|
+
}
|