@ifc-lite/viewer 1.25.2 → 1.26.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.
- package/.turbo/turbo-build.log +30 -27
- package/CHANGELOG.md +81 -0
- package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
- package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
- package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
- package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
- package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
- package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
- package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
- package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
- package/dist/assets/index-B9Ug2EqU.css +1 -0
- package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
- package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
- package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
- package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
- package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
- package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
- package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
- package/dist/assets/raw-BQrAgxwT.js +1 -0
- package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
- package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
- package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
- package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +7 -6
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +14 -15
- package/src/components/viewer/MainToolbar.tsx +155 -175
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +49 -9
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/useGeometryStreaming.ts +21 -1
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +64 -42
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/useAlignmentLines3D.ts +164 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +5 -7
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +33 -25
- package/src/store/index.ts +29 -8
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +18 -8
- package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/raw-R2QfzPAR.js +0 -1
|
@@ -0,0 +1,308 @@
|
|
|
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
|
+
* localStorage persistence for clash detection settings + the user's rule preset
|
|
7
|
+
* set. Mirrors the lens slice's "built-ins + overrides + custom" model and the
|
|
8
|
+
* scripts module's quota-safe `SaveResult`:
|
|
9
|
+
*
|
|
10
|
+
* - Presets: the built-in `CLASH_RULE_PRESETS` are always present (projected to
|
|
11
|
+
* editable items with `enabled`/`builtin`); the user may toggle/edit them
|
|
12
|
+
* (stored as overrides) and add custom presets. Only customs + modified
|
|
13
|
+
* built-ins are persisted, so shipping a new built-in just works.
|
|
14
|
+
* - Settings: one flat JSON blob (mode/tolerance/clearance/clusterEpsilon/
|
|
15
|
+
* reportTouch/groupBy), every numeric clamped to a sane range on load.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
CLASH_RULE_PRESETS,
|
|
20
|
+
type ClashRulePreset,
|
|
21
|
+
type ClashMode,
|
|
22
|
+
type ClashSeverity,
|
|
23
|
+
} from '@ifc-lite/clash';
|
|
24
|
+
|
|
25
|
+
/** A built-in or user-defined clash rule preset, with editor/runtime flags. */
|
|
26
|
+
export type ClashPreset = ClashRulePreset & { enabled: boolean; builtin: boolean };
|
|
27
|
+
|
|
28
|
+
/** How the panel groups the flat clash list (display only). */
|
|
29
|
+
export type ClashSettingsGroupBy = 'severity' | 'rule' | 'typePair';
|
|
30
|
+
|
|
31
|
+
/** Global detection settings, persisted as one blob. */
|
|
32
|
+
export interface ClashGlobalSettings {
|
|
33
|
+
mode: ClashMode;
|
|
34
|
+
tolerance: number;
|
|
35
|
+
clearance: number;
|
|
36
|
+
clusterEpsilon: number;
|
|
37
|
+
reportTouch: boolean;
|
|
38
|
+
groupBy: ClashSettingsGroupBy;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SaveResult =
|
|
42
|
+
| { ok: true }
|
|
43
|
+
| { ok: false; reason: 'quota' | 'serialize' | 'too_many'; message: string };
|
|
44
|
+
|
|
45
|
+
const PRESETS_KEY = 'ifc-lite-clash-presets';
|
|
46
|
+
const SETTINGS_KEY = 'ifc-lite-clash-settings';
|
|
47
|
+
const SCHEMA_VERSION = 1;
|
|
48
|
+
|
|
49
|
+
const MAX_PRESETS = 200;
|
|
50
|
+
const MAX_NAME = 100;
|
|
51
|
+
|
|
52
|
+
/** [min, max] clamps applied to settings numerics on load and on commit. */
|
|
53
|
+
export const CLASH_BOUNDS = {
|
|
54
|
+
tolerance: [0, 1] as const,
|
|
55
|
+
clearance: [0, 5] as const,
|
|
56
|
+
clusterEpsilon: [0.01, 50] as const,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_CLASH_SETTINGS: ClashGlobalSettings = {
|
|
60
|
+
mode: 'hard',
|
|
61
|
+
tolerance: 0.002,
|
|
62
|
+
clearance: 0.05,
|
|
63
|
+
clusterEpsilon: 1.5,
|
|
64
|
+
reportTouch: false,
|
|
65
|
+
groupBy: 'severity',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const BUILTIN_PRESET_IDS = new Set(CLASH_RULE_PRESETS.map((p) => p.id));
|
|
69
|
+
const SEVERITIES: ClashSeverity[] = ['critical', 'major', 'minor', 'info'];
|
|
70
|
+
const GROUP_BYS: ClashSettingsGroupBy[] = ['severity', 'rule', 'typePair'];
|
|
71
|
+
|
|
72
|
+
export function clampToBounds(value: unknown, [min, max]: readonly [number, number], fallback: number): number {
|
|
73
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
74
|
+
if (!Number.isFinite(n)) return fallback;
|
|
75
|
+
return Math.min(max, Math.max(min, n));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Trim + length-cap a preset name; null if empty (invalid). */
|
|
79
|
+
export function validatePresetName(name: string): string | null {
|
|
80
|
+
const t = name.trim();
|
|
81
|
+
return t ? t.slice(0, MAX_NAME) : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Trim a selector; null if empty (invalid). An empty selector matches everything. */
|
|
85
|
+
export function validateSelector(selector: string): string | null {
|
|
86
|
+
const t = selector.trim();
|
|
87
|
+
return t ? t : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isValidStoredPreset(p: unknown): p is ClashPreset {
|
|
91
|
+
if (!p || typeof p !== 'object') return false;
|
|
92
|
+
const r = p as Record<string, unknown>;
|
|
93
|
+
return (
|
|
94
|
+
typeof r.id === 'string' && r.id.length > 0 &&
|
|
95
|
+
typeof r.name === 'string' && r.name.trim().length > 0 &&
|
|
96
|
+
typeof r.selectorA === 'string' && r.selectorA.trim().length > 0 &&
|
|
97
|
+
typeof r.selectorB === 'string' && r.selectorB.trim().length > 0 &&
|
|
98
|
+
typeof r.severity === 'string' && SEVERITIES.includes(r.severity as ClashSeverity)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read stored presets, accepting the versioned wrapper or a legacy bare array. */
|
|
103
|
+
function readStoredPresets(): ClashPreset[] {
|
|
104
|
+
try {
|
|
105
|
+
const raw = localStorage.getItem(PRESETS_KEY);
|
|
106
|
+
if (!raw) return [];
|
|
107
|
+
const parsed: unknown = JSON.parse(raw);
|
|
108
|
+
const list = Array.isArray(parsed)
|
|
109
|
+
? parsed
|
|
110
|
+
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { presets?: unknown }).presets)
|
|
111
|
+
? (parsed as { presets: unknown[] }).presets
|
|
112
|
+
: [];
|
|
113
|
+
return list
|
|
114
|
+
.filter(isValidStoredPreset)
|
|
115
|
+
.map((p) => ({
|
|
116
|
+
id: p.id,
|
|
117
|
+
name: p.name,
|
|
118
|
+
description: typeof p.description === 'string' ? p.description : '',
|
|
119
|
+
severity: p.severity,
|
|
120
|
+
selectorA: p.selectorA,
|
|
121
|
+
selectorB: p.selectorB,
|
|
122
|
+
enabled: p.enabled !== false,
|
|
123
|
+
builtin: BUILTIN_PRESET_IDS.has(p.id),
|
|
124
|
+
}));
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The pristine built-in preset set, no overrides/customs — the "reset" target. */
|
|
131
|
+
export function defaultPresets(): ClashPreset[] {
|
|
132
|
+
return CLASH_RULE_PRESETS.map((p) => ({ ...p, enabled: true, builtin: true }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The full preset list shown to the user: every built-in (with any saved
|
|
137
|
+
* override applied) followed by custom presets. Built-ins are always present
|
|
138
|
+
* even if storage is empty or dropped them.
|
|
139
|
+
*/
|
|
140
|
+
/**
|
|
141
|
+
* Resolve a stored (customs + modified-built-ins) list into the full preset
|
|
142
|
+
* list: every built-in (with any override applied), then customs. Built-ins are
|
|
143
|
+
* always present, so a list from an older app version still picks up new ones.
|
|
144
|
+
*/
|
|
145
|
+
export function mergeStoredPresets(stored: ClashPreset[]): ClashPreset[] {
|
|
146
|
+
const overrides = new Map(stored.filter((p) => p.builtin).map((p) => [p.id, p]));
|
|
147
|
+
const builtins: ClashPreset[] = CLASH_RULE_PRESETS.map(
|
|
148
|
+
(p) => overrides.get(p.id) ?? { ...p, enabled: true, builtin: true },
|
|
149
|
+
);
|
|
150
|
+
const custom = stored.filter((p) => !p.builtin);
|
|
151
|
+
return [...builtins, ...custom];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildInitialPresets(): ClashPreset[] {
|
|
155
|
+
return mergeStoredPresets(readStoredPresets());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function builtinDiffersFromDefault(p: ClashPreset): boolean {
|
|
159
|
+
const orig = CLASH_RULE_PRESETS.find((b) => b.id === p.id);
|
|
160
|
+
if (!orig) return true;
|
|
161
|
+
return (
|
|
162
|
+
!p.enabled ||
|
|
163
|
+
p.name !== orig.name ||
|
|
164
|
+
p.severity !== orig.severity ||
|
|
165
|
+
p.selectorA !== orig.selectorA ||
|
|
166
|
+
p.selectorB !== orig.selectorB ||
|
|
167
|
+
p.description !== orig.description
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The minimal stored shape: customs + only the built-ins that differ from default. */
|
|
172
|
+
export function presetsToStore(presets: ClashPreset[]): ClashPreset[] {
|
|
173
|
+
return [
|
|
174
|
+
...presets.filter((p) => !p.builtin),
|
|
175
|
+
...presets.filter((p) => p.builtin && builtinDiffersFromDefault(p)),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Persist only custom presets + modified built-ins (quota-safe). */
|
|
180
|
+
export function savePresets(presets: ClashPreset[]): SaveResult {
|
|
181
|
+
const custom = presets.filter((p) => !p.builtin);
|
|
182
|
+
if (custom.length > MAX_PRESETS) {
|
|
183
|
+
return { ok: false, reason: 'too_many', message: `Too many custom rules (max ${MAX_PRESETS}).` };
|
|
184
|
+
}
|
|
185
|
+
const toStore = presetsToStore(presets);
|
|
186
|
+
let payload: string;
|
|
187
|
+
try {
|
|
188
|
+
payload = JSON.stringify({ schemaVersion: SCHEMA_VERSION, presets: toStore });
|
|
189
|
+
} catch {
|
|
190
|
+
return { ok: false, reason: 'serialize', message: 'Could not serialize clash rules.' };
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
localStorage.setItem(PRESETS_KEY, payload);
|
|
194
|
+
return { ok: true };
|
|
195
|
+
} catch {
|
|
196
|
+
return { ok: false, reason: 'quota', message: 'Browser storage is full — clash rules were not saved.' };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Coerce arbitrary input into valid, bounds-clamped settings (defaults on junk). */
|
|
201
|
+
export function normalizeSettings(raw: unknown): ClashGlobalSettings {
|
|
202
|
+
const s = (raw && typeof raw === 'object' && 'settings' in raw
|
|
203
|
+
? (raw as { settings: unknown }).settings
|
|
204
|
+
: raw) as Partial<ClashGlobalSettings> | null;
|
|
205
|
+
if (!s || typeof s !== 'object') return { ...DEFAULT_CLASH_SETTINGS };
|
|
206
|
+
return {
|
|
207
|
+
mode: s.mode === 'clearance' ? 'clearance' : 'hard',
|
|
208
|
+
tolerance: clampToBounds(s.tolerance, CLASH_BOUNDS.tolerance, DEFAULT_CLASH_SETTINGS.tolerance),
|
|
209
|
+
clearance: clampToBounds(s.clearance, CLASH_BOUNDS.clearance, DEFAULT_CLASH_SETTINGS.clearance),
|
|
210
|
+
clusterEpsilon: clampToBounds(s.clusterEpsilon, CLASH_BOUNDS.clusterEpsilon, DEFAULT_CLASH_SETTINGS.clusterEpsilon),
|
|
211
|
+
reportTouch: s.reportTouch === true,
|
|
212
|
+
groupBy: GROUP_BYS.includes(s.groupBy as ClashSettingsGroupBy) ? (s.groupBy as ClashSettingsGroupBy) : 'severity',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function loadSettings(): ClashGlobalSettings {
|
|
217
|
+
try {
|
|
218
|
+
const raw = localStorage.getItem(SETTINGS_KEY);
|
|
219
|
+
if (!raw) return { ...DEFAULT_CLASH_SETTINGS };
|
|
220
|
+
return normalizeSettings(JSON.parse(raw));
|
|
221
|
+
} catch {
|
|
222
|
+
return { ...DEFAULT_CLASH_SETTINGS };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function saveSettings(settings: ClashGlobalSettings): SaveResult {
|
|
227
|
+
try {
|
|
228
|
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ schemaVersion: SCHEMA_VERSION, settings }));
|
|
229
|
+
return { ok: true };
|
|
230
|
+
} catch {
|
|
231
|
+
return { ok: false, reason: 'quota', message: 'Browser storage is full — clash settings were not saved.' };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Download the user's presets (customs + modified built-ins) as a JSON file. */
|
|
236
|
+
export function exportPresets(presets: ClashPreset[]): void {
|
|
237
|
+
const custom = presets.filter((p) => !p.builtin || builtinDiffersFromDefault(p));
|
|
238
|
+
const blob = new Blob([JSON.stringify({ schemaVersion: SCHEMA_VERSION, presets: custom }, null, 2)], {
|
|
239
|
+
type: 'application/json',
|
|
240
|
+
});
|
|
241
|
+
const url = URL.createObjectURL(blob);
|
|
242
|
+
const a = document.createElement('a');
|
|
243
|
+
a.href = url;
|
|
244
|
+
a.download = 'clash-rules.clash-presets.json';
|
|
245
|
+
a.click();
|
|
246
|
+
URL.revokeObjectURL(url);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Parse an exported file into custom presets (ids regenerated, `builtin` stripped). */
|
|
250
|
+
export async function importPresets(file: File): Promise<ClashPreset[]> {
|
|
251
|
+
const text = await file.text();
|
|
252
|
+
const parsed: unknown = JSON.parse(text);
|
|
253
|
+
const list = Array.isArray(parsed)
|
|
254
|
+
? parsed
|
|
255
|
+
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { presets?: unknown }).presets)
|
|
256
|
+
? (parsed as { presets: unknown[] }).presets
|
|
257
|
+
: [];
|
|
258
|
+
return list.filter(isValidStoredPreset).map((p) => ({
|
|
259
|
+
id: `custom-${crypto.randomUUID()}`,
|
|
260
|
+
name: p.name.slice(0, MAX_NAME),
|
|
261
|
+
description: typeof p.description === 'string' ? p.description : '',
|
|
262
|
+
severity: p.severity,
|
|
263
|
+
selectorA: p.selectorA,
|
|
264
|
+
selectorB: p.selectorB,
|
|
265
|
+
enabled: p.enabled !== false,
|
|
266
|
+
builtin: false,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Flavor integration ───────────────────────────────────────────────────────
|
|
271
|
+
// Clash config rides inside a flavor's generic `settings.clash` blob, so each
|
|
272
|
+
// flavor/profile carries its own rule-set + detection settings (and they travel
|
|
273
|
+
// with flavor export/import). Serialize stores the minimal shape (customs +
|
|
274
|
+
// modified built-ins + settings); deserialize rebuilds the full, validated state.
|
|
275
|
+
|
|
276
|
+
/** Plain-JSON snapshot of clash config stored in a flavor. */
|
|
277
|
+
export interface ClashFlavorConfig {
|
|
278
|
+
schemaVersion: number;
|
|
279
|
+
settings: ClashGlobalSettings;
|
|
280
|
+
/** Customs + modified built-ins only (built-ins are re-merged on restore). */
|
|
281
|
+
presets: ClashPreset[];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function serializeClashConfig(presets: ClashPreset[], settings: ClashGlobalSettings): ClashFlavorConfig {
|
|
285
|
+
return { schemaVersion: SCHEMA_VERSION, settings: { ...settings }, presets: presetsToStore(presets) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Rebuild clash state from a flavor blob: the full resolved preset list (defaults
|
|
290
|
+
* + the blob's overrides/customs) and bounds-clamped settings. Returns null when
|
|
291
|
+
* the blob is missing/garbage so the caller can skip the restore.
|
|
292
|
+
*/
|
|
293
|
+
export function deserializeClashConfig(blob: unknown): { presets: ClashPreset[]; settings: ClashGlobalSettings } | null {
|
|
294
|
+
if (!blob || typeof blob !== 'object') return null;
|
|
295
|
+
const b = blob as Partial<ClashFlavorConfig>;
|
|
296
|
+
const storedRaw = Array.isArray(b.presets) ? b.presets : [];
|
|
297
|
+
const stored = storedRaw.filter(isValidStoredPreset).map((p) => ({
|
|
298
|
+
id: p.id,
|
|
299
|
+
name: p.name,
|
|
300
|
+
description: typeof p.description === 'string' ? p.description : '',
|
|
301
|
+
severity: p.severity,
|
|
302
|
+
selectorA: p.selectorA,
|
|
303
|
+
selectorB: p.selectorB,
|
|
304
|
+
enabled: p.enabled !== false,
|
|
305
|
+
builtin: BUILTIN_PRESET_IDS.has(p.id),
|
|
306
|
+
}));
|
|
307
|
+
return { presets: mergeStoredPresets(stored), settings: normalizeSettings(b.settings) };
|
|
308
|
+
}
|
|
@@ -8,6 +8,7 @@ import assert from 'node:assert';
|
|
|
8
8
|
import {
|
|
9
9
|
detectScaleUnitMismatch,
|
|
10
10
|
getEffectiveHorizontalScale,
|
|
11
|
+
hasStandardGeoreferencing,
|
|
11
12
|
inferMapUnitScale,
|
|
12
13
|
mergeMapConversion,
|
|
13
14
|
mergeProjectedCRS,
|
|
@@ -237,4 +238,69 @@ describe('effective georeferencing', () => {
|
|
|
237
238
|
assert.strictEqual(m!.effectiveScale, 2);
|
|
238
239
|
});
|
|
239
240
|
});
|
|
241
|
+
|
|
242
|
+
describe('hasStandardGeoreferencing (federation alignment gate)', () => {
|
|
243
|
+
// Federation affine alignment (extractModelGeoref → buildGeorefAlignmentTransform)
|
|
244
|
+
// gates on this predicate. A site-location-only georef must NOT qualify: it is
|
|
245
|
+
// EPSG:4326 lat/long degrees + a raw, un-unit-scaled IfcSite RefElevation, which
|
|
246
|
+
// the projected-CRS transform misreads as metres and flings the second federated
|
|
247
|
+
// model kilometres away. These tests lock that invariant.
|
|
248
|
+
const mapConversion: MapConversion = {
|
|
249
|
+
id: 1,
|
|
250
|
+
sourceCRS: 0,
|
|
251
|
+
targetCRS: 0,
|
|
252
|
+
eastings: 100,
|
|
253
|
+
northings: 200,
|
|
254
|
+
orthogonalHeight: 5,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
it('rejects synthesised site-location georef even with a CRS name + map conversion', () => {
|
|
258
|
+
assert.strictEqual(
|
|
259
|
+
hasStandardGeoreferencing({
|
|
260
|
+
source: 'siteLocation',
|
|
261
|
+
projectedCRS: { id: 1, name: 'EPSG:4326' },
|
|
262
|
+
mapConversion,
|
|
263
|
+
}),
|
|
264
|
+
false,
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('accepts true IfcMapConversion + IfcProjectedCRS georef', () => {
|
|
269
|
+
assert.strictEqual(
|
|
270
|
+
hasStandardGeoreferencing({
|
|
271
|
+
source: 'mapConversion',
|
|
272
|
+
projectedCRS: { id: 1, name: 'EPSG:28992' },
|
|
273
|
+
mapConversion,
|
|
274
|
+
}),
|
|
275
|
+
true,
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('rejects georef missing a map conversion', () => {
|
|
280
|
+
assert.strictEqual(
|
|
281
|
+
hasStandardGeoreferencing({
|
|
282
|
+
source: 'mapConversion',
|
|
283
|
+
projectedCRS: { id: 1, name: 'EPSG:28992' },
|
|
284
|
+
mapConversion: undefined,
|
|
285
|
+
}),
|
|
286
|
+
false,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('rejects georef missing a projected CRS name', () => {
|
|
291
|
+
assert.strictEqual(
|
|
292
|
+
hasStandardGeoreferencing({
|
|
293
|
+
source: 'mapConversion',
|
|
294
|
+
projectedCRS: { id: 1, name: '' },
|
|
295
|
+
mapConversion,
|
|
296
|
+
}),
|
|
297
|
+
false,
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('rejects null / undefined', () => {
|
|
302
|
+
assert.strictEqual(hasStandardGeoreferencing(null), false);
|
|
303
|
+
assert.strictEqual(hasStandardGeoreferencing(undefined), false);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
240
306
|
});
|
|
@@ -436,6 +436,19 @@ export class ExtensionHostService {
|
|
|
436
436
|
} catch (err) {
|
|
437
437
|
console.warn('[ext-host] lens restore on switch failed:', err);
|
|
438
438
|
}
|
|
439
|
+
// Restore the flavor's clash config (rule-set + detection settings) from the
|
|
440
|
+
// opaque settings.clash blob, mirroring the lens roundtrip above. Missing /
|
|
441
|
+
// malformed blobs deserialize to null and are skipped (no-op).
|
|
442
|
+
try {
|
|
443
|
+
const { deserializeClashConfig } = await import('@/lib/clash/persistence');
|
|
444
|
+
const config = deserializeClashConfig((target.settings as Record<string, unknown> | undefined)?.clash);
|
|
445
|
+
if (config) {
|
|
446
|
+
const { useViewerStore } = await import('@/store');
|
|
447
|
+
useViewerStore.getState().applyClashFlavorConfig(config);
|
|
448
|
+
}
|
|
449
|
+
} catch (err) {
|
|
450
|
+
console.warn('[ext-host] clash restore on switch failed:', err);
|
|
451
|
+
}
|
|
439
452
|
this.emit();
|
|
440
453
|
}
|
|
441
454
|
|
package/src/store/constants.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Store constants - extracted magic numbers for maintainability
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { TypeVisibility } from './types.js';
|
|
10
|
+
|
|
9
11
|
// ============================================================================
|
|
10
12
|
// Camera Defaults
|
|
11
13
|
// ============================================================================
|
|
@@ -182,37 +184,43 @@ function readPersistedBool(key: string, fallback: boolean): boolean {
|
|
|
182
184
|
// on first load. IfcSite + IfcAnnotation + IfcGrid on — all three convey
|
|
183
185
|
// design intent users expect to see by default. (Issue #862 split grid
|
|
184
186
|
// into its own toggle so dense-grid models can hide grids without losing
|
|
185
|
-
// dimensions/labels.)
|
|
186
|
-
|
|
187
|
+
// dimensions/labels.) Exported so the "Reset" action in the visibility
|
|
188
|
+
// menu can restore these without re-deriving them.
|
|
189
|
+
export const TYPE_VISIBILITY_SEMANTIC_DEFAULTS: TypeVisibility = {
|
|
187
190
|
spaces: false,
|
|
188
191
|
openings: false,
|
|
189
192
|
site: true,
|
|
190
193
|
ifcAnnotations: true,
|
|
191
194
|
ifcGrid: true,
|
|
192
|
-
}
|
|
195
|
+
};
|
|
193
196
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the full type-visibility preference set from localStorage.
|
|
199
|
+
*
|
|
200
|
+
* Read fresh on EVERY call — not captured once at module load. The store
|
|
201
|
+
* applies this both at boot (slice init) and on every new-file load
|
|
202
|
+
* (`resetViewerState`). A module-level constant would snapshot localStorage
|
|
203
|
+
* at first import and then go stale after the first in-session toggle, so
|
|
204
|
+
* loading a second model would silently revert the user's choices (e.g.
|
|
205
|
+
* "Show Annotations" flipping back on). Reading live keeps every toggle
|
|
206
|
+
* sticky across reloads AND across model swaps within a session.
|
|
207
|
+
*/
|
|
208
|
+
export function getPersistedTypeVisibility(): TypeVisibility {
|
|
209
|
+
return {
|
|
210
|
+
spaces: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.spaces, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.spaces),
|
|
211
|
+
openings: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.openings),
|
|
212
|
+
site: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.site),
|
|
213
|
+
ifcAnnotations: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.ifcAnnotations),
|
|
214
|
+
// Issue #862. Migration: if the new grid key isn't set yet, fall back to
|
|
215
|
+
// the legacy combined `ifcAnnotations` preference so a user who turned
|
|
216
|
+
// the old "Annotations & Grids" toggle off keeps grids hidden after
|
|
217
|
+
// upgrade instead of grids silently reappearing (PR #868 review).
|
|
218
|
+
ifcGrid: readPersistedBool(
|
|
219
|
+
TYPE_VISIBILITY_STORAGE_KEYS.ifcGrid,
|
|
220
|
+
readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, TYPE_VISIBILITY_SEMANTIC_DEFAULTS.ifcGrid),
|
|
221
|
+
),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
216
224
|
|
|
217
225
|
// ============================================================================
|
|
218
226
|
// Data Defaults
|
package/src/store/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { createExtensionsSlice, type ExtensionsSlice } from './slices/extensions
|
|
|
33
33
|
import { createListSlice, type ListSlice } from './slices/listSlice.js';
|
|
34
34
|
import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
|
|
35
35
|
import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
|
|
36
|
+
import { createClashSlice, type ClashSlice } from './slices/clashSlice.js';
|
|
36
37
|
import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
|
|
37
38
|
import { createChatSlice, type ChatSlice } from './slices/chatSlice.js';
|
|
38
39
|
import { createCesiumSlice, type CesiumSlice } from './slices/cesiumSlice.js';
|
|
@@ -49,7 +50,7 @@ import { createPointCloudSlice, type PointCloudSlice, POINT_CLOUD_DEFAULTS } fro
|
|
|
49
50
|
import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
|
|
50
51
|
|
|
51
52
|
// Import constants for reset function
|
|
52
|
-
import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS,
|
|
53
|
+
import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, getPersistedTypeVisibility } from './constants.js';
|
|
53
54
|
|
|
54
55
|
// Re-export types for consumers
|
|
55
56
|
export type * from './types.js';
|
|
@@ -129,6 +130,7 @@ export type ViewerState = LoadingSlice &
|
|
|
129
130
|
ListSlice &
|
|
130
131
|
PinboardSlice &
|
|
131
132
|
LensSlice &
|
|
133
|
+
ClashSlice &
|
|
132
134
|
ScriptSlice &
|
|
133
135
|
ChatSlice &
|
|
134
136
|
CesiumSlice &
|
|
@@ -144,6 +146,16 @@ export type ViewerState = LoadingSlice &
|
|
|
144
146
|
PointCloudSlice &
|
|
145
147
|
ExtensionsSlice & {
|
|
146
148
|
resetViewerState: () => void;
|
|
149
|
+
/**
|
|
150
|
+
* Open one right-side analysis panel and close the others, so the chosen
|
|
151
|
+
* panel is always the topmost/active one. The right panel renders a single
|
|
152
|
+
* mutually-exclusive chain (lens → clash → ids → bcf → extensions), so
|
|
153
|
+
* leaving a sibling flag set would keep the higher-precedence panel on top
|
|
154
|
+
* (the cause of "I have to close clash before I see BCF"). Also un-collapses
|
|
155
|
+
* the right panel. Routed through by the toolbar, command palette, and the
|
|
156
|
+
* BCF overlay so every entry point behaves identically.
|
|
157
|
+
*/
|
|
158
|
+
openWorkspacePanel: (panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') => void;
|
|
147
159
|
};
|
|
148
160
|
|
|
149
161
|
/**
|
|
@@ -169,6 +181,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
|
|
|
169
181
|
...createListSlice(...args),
|
|
170
182
|
...createPinboardSlice(...args),
|
|
171
183
|
...createLensSlice(...args),
|
|
184
|
+
...createClashSlice(...args),
|
|
172
185
|
...createScriptSlice(...args),
|
|
173
186
|
...createChatSlice(...args),
|
|
174
187
|
...createCesiumSlice(...args),
|
|
@@ -203,13 +216,9 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
|
|
|
203
216
|
hiddenEntities: new Set(),
|
|
204
217
|
isolatedEntities: null,
|
|
205
218
|
classFilter: null,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
site: TYPE_VISIBILITY_DEFAULTS.SITE,
|
|
210
|
-
ifcAnnotations: TYPE_VISIBILITY_DEFAULTS.IFC_ANNOTATIONS,
|
|
211
|
-
ifcGrid: TYPE_VISIBILITY_DEFAULTS.IFC_GRID,
|
|
212
|
-
},
|
|
219
|
+
// Re-read persisted toggles on every file load so a new model never
|
|
220
|
+
// reverts the user's visibility choices (e.g. "Show Annotations").
|
|
221
|
+
typeVisibility: getPersistedTypeVisibility(),
|
|
213
222
|
|
|
214
223
|
// Visibility (multi-model)
|
|
215
224
|
hiddenEntitiesByModel: new Map(),
|
|
@@ -443,6 +452,18 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
|
|
|
443
452
|
pointCloudFixedColor: [...POINT_CLOUD_DEFAULTS.pointCloudFixedColor] as [number, number, number, number],
|
|
444
453
|
});
|
|
445
454
|
},
|
|
455
|
+
|
|
456
|
+
openWorkspacePanel: (panel) => {
|
|
457
|
+
const [set] = args;
|
|
458
|
+
set({
|
|
459
|
+
bcfPanelVisible: panel === 'bcf',
|
|
460
|
+
idsPanelVisible: panel === 'ids',
|
|
461
|
+
lensPanelVisible: panel === 'lens',
|
|
462
|
+
clashPanelVisible: panel === 'clash',
|
|
463
|
+
extensionsPanelVisible: panel === 'extensions',
|
|
464
|
+
rightPanelCollapsed: false,
|
|
465
|
+
});
|
|
466
|
+
},
|
|
446
467
|
}));
|
|
447
468
|
|
|
448
469
|
const STORE_SINGLETON_KEY = '__ifc_lite_viewer_store__';
|