@ifc-lite/viewer 1.14.2 → 1.14.4
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/CHANGELOG.md +35 -0
- package/dist/assets/{Arrow.dom-CSgnLhN4.js → Arrow.dom-_vGzMMKs.js} +1 -1
- package/dist/assets/basketViewActivator-BZcoCL3V.js +1 -0
- package/dist/assets/{browser-qSKWrKQW.js → browser-Czmf34bo.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
- package/dist/assets/index-CMQ_Dgkr.css +1 -0
- package/dist/assets/index-D7nEDctQ.js +229 -0
- package/dist/assets/{index-4Y4XaV8N.js → index-DX-Qf5fA.js} +72669 -61673
- package/dist/assets/{native-bridge-CSFDsEkg.js → native-bridge-DAOWftxE.js} +1 -1
- package/dist/assets/{wasm-bridge-Zf90ysEm.js → wasm-bridge-D7jYpn8a.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +21 -20
- package/src/App.tsx +17 -1
- package/src/components/viewer/BasketPresentationDock.tsx +8 -4
- package/src/components/viewer/ChatPanel.tsx +1402 -0
- package/src/components/viewer/CodeEditor.tsx +70 -4
- package/src/components/viewer/CommandPalette.tsx +1 -0
- package/src/components/viewer/HierarchyPanel.tsx +28 -13
- package/src/components/viewer/MainToolbar.tsx +113 -95
- package/src/components/viewer/ScriptPanel.tsx +351 -184
- package/src/components/viewer/UpgradePage.tsx +69 -0
- package/src/components/viewer/Viewport.tsx +23 -0
- package/src/components/viewer/chat/ChatMessage.tsx +144 -0
- package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
- package/src/components/viewer/chat/ModelSelector.tsx +102 -0
- package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
- package/src/components/viewer/chat/renderTextContent.ts +19 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
- package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
- package/src/components/viewer/hierarchy/types.ts +6 -1
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
- package/src/hooks/useIfcCache.ts +1 -2
- package/src/hooks/useSandbox.ts +122 -6
- package/src/index.css +10 -0
- package/src/lib/attachments.ts +46 -0
- package/src/lib/llm/ClerkChatSync.tsx +74 -0
- package/src/lib/llm/clerk-auth.ts +62 -0
- package/src/lib/llm/code-extractor.ts +50 -0
- package/src/lib/llm/context-builder.test.ts +18 -0
- package/src/lib/llm/context-builder.ts +305 -0
- package/src/lib/llm/free-models.test.ts +118 -0
- package/src/lib/llm/message-capabilities.test.ts +131 -0
- package/src/lib/llm/message-capabilities.ts +94 -0
- package/src/lib/llm/models.ts +197 -0
- package/src/lib/llm/repair-loop.test.ts +91 -0
- package/src/lib/llm/repair-loop.ts +76 -0
- package/src/lib/llm/script-diagnostics.ts +445 -0
- package/src/lib/llm/script-edit-ops.test.ts +399 -0
- package/src/lib/llm/script-edit-ops.ts +954 -0
- package/src/lib/llm/script-preflight.test.ts +513 -0
- package/src/lib/llm/script-preflight.ts +990 -0
- package/src/lib/llm/script-preservation.test.ts +128 -0
- package/src/lib/llm/script-preservation.ts +152 -0
- package/src/lib/llm/stream-client.test.ts +97 -0
- package/src/lib/llm/stream-client.ts +410 -0
- package/src/lib/llm/system-prompt.test.ts +181 -0
- package/src/lib/llm/system-prompt.ts +665 -0
- package/src/lib/llm/types.ts +150 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
- package/src/lib/scripts/templates/create-building.ts +12 -12
- package/src/main.tsx +10 -1
- package/src/sdk/adapters/export-adapter.test.ts +24 -0
- package/src/sdk/adapters/export-adapter.ts +40 -16
- package/src/sdk/adapters/files-adapter.ts +39 -0
- package/src/sdk/adapters/model-compat.ts +1 -1
- package/src/sdk/adapters/mutate-adapter.ts +20 -6
- package/src/sdk/adapters/mutation-view.ts +112 -0
- package/src/sdk/adapters/query-adapter.ts +100 -4
- package/src/sdk/local-backend.ts +4 -0
- package/src/store/index.ts +15 -1
- package/src/store/slices/chatSlice.test.ts +325 -0
- package/src/store/slices/chatSlice.ts +468 -0
- package/src/store/slices/scriptSlice.test.ts +75 -0
- package/src/store/slices/scriptSlice.ts +256 -9
- package/src/vite-env.d.ts +10 -0
- package/vite.config.ts +21 -2
- package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
- package/dist/assets/index-ByrFvN5A.css +0 -1
- package/dist/assets/index-CN7qDq7G.js +0 -216
|
@@ -0,0 +1,990 @@
|
|
|
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
|
+
import { NAMESPACE_SCHEMAS } from '@ifc-lite/sandbox/schema';
|
|
6
|
+
import {
|
|
7
|
+
createPreflightDiagnostic,
|
|
8
|
+
formatDiagnosticsForDisplay,
|
|
9
|
+
type PreflightScriptDiagnostic,
|
|
10
|
+
} from './script-diagnostics.js';
|
|
11
|
+
|
|
12
|
+
interface MethodRule {
|
|
13
|
+
required: string[];
|
|
14
|
+
anyOf?: string[][];
|
|
15
|
+
positiveKeys?: string[];
|
|
16
|
+
pointArity?: Record<string, number>;
|
|
17
|
+
axisPair?: [string, string];
|
|
18
|
+
forbidKeys?: Array<{ key: string; message: string }>;
|
|
19
|
+
custom?: (body: string) => string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createMethodRulesFromSchema(): Record<string, MethodRule> {
|
|
23
|
+
const createNamespace = NAMESPACE_SCHEMAS.find((schema) => schema.name === 'create');
|
|
24
|
+
if (!createNamespace) return {};
|
|
25
|
+
|
|
26
|
+
const rules: Record<string, MethodRule> = {};
|
|
27
|
+
for (const method of createNamespace.methods) {
|
|
28
|
+
const semantics = method.llmSemantics;
|
|
29
|
+
if (!semantics?.requiredKeys?.length && !semantics?.anyOfKeys?.length && !semantics?.forbiddenKeys?.length) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let custom: ((body: string) => string[]) | undefined;
|
|
34
|
+
switch (semantics.customValidationId) {
|
|
35
|
+
case 'slab-shape':
|
|
36
|
+
custom = validateSlabShape;
|
|
37
|
+
break;
|
|
38
|
+
case 'roof-shape':
|
|
39
|
+
if (method.name === 'addIfcRoof' || method.name === 'addIfcGableRoof') {
|
|
40
|
+
const roofMethod = method.name;
|
|
41
|
+
custom = (body) => validateRoofShape(roofMethod, body);
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
case 'generic-element':
|
|
45
|
+
custom = validateGenericElementShape;
|
|
46
|
+
break;
|
|
47
|
+
case 'axis-element':
|
|
48
|
+
custom = validateAxisElementShape;
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
custom = undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
rules[method.name] = {
|
|
55
|
+
required: semantics.requiredKeys ?? [],
|
|
56
|
+
anyOf: semantics.anyOfKeys,
|
|
57
|
+
positiveKeys: semantics.positiveKeys,
|
|
58
|
+
pointArity: semantics.pointArity,
|
|
59
|
+
axisPair: semantics.axisPair,
|
|
60
|
+
forbidKeys: semantics.forbiddenKeys,
|
|
61
|
+
custom,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return rules;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const METHOD_RULES: Record<string, MethodRule> = createMethodRulesFromSchema();
|
|
68
|
+
|
|
69
|
+
const SUSPICIOUS_BARE_IDENTIFIERS = new Set([
|
|
70
|
+
'Position', 'Placement', 'Start', 'End', 'Width', 'Depth', 'Height', 'Thickness', 'Elevation', 'IfcType',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function nearestMethodName(method: string, options: string[]): string | null {
|
|
74
|
+
const lower = method.toLowerCase();
|
|
75
|
+
const hit = options.find((m) => m.toLowerCase() === lower);
|
|
76
|
+
if (hit) return hit;
|
|
77
|
+
const close = options.find((m) => m.toLowerCase().includes(lower) || lower.includes(m.toLowerCase()));
|
|
78
|
+
return close ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function scanToMatching(source: string, start: number, open: string, close: string): number {
|
|
82
|
+
let depth = 0;
|
|
83
|
+
let quote: '"' | '\'' | '`' | null = null;
|
|
84
|
+
for (let i = start; i < source.length; i++) {
|
|
85
|
+
const ch = source[i];
|
|
86
|
+
if (quote) {
|
|
87
|
+
if (ch === '\\') {
|
|
88
|
+
i++;
|
|
89
|
+
} else if (ch === quote) {
|
|
90
|
+
quote = null;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '"' || ch === '\'' || ch === '`') {
|
|
95
|
+
quote = ch;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === open) depth++;
|
|
99
|
+
else if (ch === close) {
|
|
100
|
+
depth--;
|
|
101
|
+
if (depth === 0) return i;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function splitTopLevelItems(text: string): string[] {
|
|
108
|
+
const items: string[] = [];
|
|
109
|
+
let depthParen = 0;
|
|
110
|
+
let depthBracket = 0;
|
|
111
|
+
let depthBrace = 0;
|
|
112
|
+
let quote: '"' | '\'' | '`' | null = null;
|
|
113
|
+
let current = '';
|
|
114
|
+
for (let i = 0; i < text.length; i++) {
|
|
115
|
+
const ch = text[i];
|
|
116
|
+
if (quote) {
|
|
117
|
+
current += ch;
|
|
118
|
+
if (ch === '\\') {
|
|
119
|
+
current += text[i + 1] ?? '';
|
|
120
|
+
i++;
|
|
121
|
+
} else if (ch === quote) {
|
|
122
|
+
quote = null;
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ch === '"' || ch === '\'' || ch === '`') {
|
|
127
|
+
quote = ch;
|
|
128
|
+
current += ch;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (ch === '(') depthParen++;
|
|
132
|
+
else if (ch === ')') depthParen--;
|
|
133
|
+
else if (ch === '[') depthBracket++;
|
|
134
|
+
else if (ch === ']') depthBracket--;
|
|
135
|
+
else if (ch === '{') depthBrace++;
|
|
136
|
+
else if (ch === '}') depthBrace--;
|
|
137
|
+
|
|
138
|
+
if (ch === ',' && depthParen === 0 && depthBracket === 0 && depthBrace === 0) {
|
|
139
|
+
if (current.trim()) items.push(current.trim());
|
|
140
|
+
current = '';
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
current += ch;
|
|
144
|
+
}
|
|
145
|
+
if (current.trim()) items.push(current.trim());
|
|
146
|
+
return items;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasKey(body: string, key: string): boolean {
|
|
150
|
+
return new RegExp(String.raw`\b${key}\s*:`, 'm').test(body);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function findPropertyValue(body: string, key: string): string | null {
|
|
154
|
+
const match = new RegExp(String.raw`\b${key}\s*:\s*`, 'm').exec(body);
|
|
155
|
+
if (!match || match.index === undefined) return null;
|
|
156
|
+
const start = match.index + match[0].length;
|
|
157
|
+
const trimmed = body.slice(start).trimStart();
|
|
158
|
+
if (!trimmed) return null;
|
|
159
|
+
const offset = body.slice(start).length - trimmed.length;
|
|
160
|
+
const absolute = start + offset;
|
|
161
|
+
const first = body[absolute];
|
|
162
|
+
if (first === '[') {
|
|
163
|
+
const end = scanToMatching(body, absolute, '[', ']');
|
|
164
|
+
return end >= 0 ? body.slice(absolute, end + 1) : null;
|
|
165
|
+
}
|
|
166
|
+
if (first === '{') {
|
|
167
|
+
const end = scanToMatching(body, absolute, '{', '}');
|
|
168
|
+
return end >= 0 ? body.slice(absolute, end + 1) : null;
|
|
169
|
+
}
|
|
170
|
+
const rest = body.slice(absolute);
|
|
171
|
+
const top = splitTopLevelItems(rest);
|
|
172
|
+
return top[0] ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getArrayLiteralArity(body: string, key: string): number | null {
|
|
176
|
+
const value = findPropertyValue(body, key);
|
|
177
|
+
if (!value || !value.startsWith('[')) return null;
|
|
178
|
+
return splitTopLevelItems(value.slice(1, -1)).length;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getArrayLiteralItems(body: string, key: string): string[] | null {
|
|
182
|
+
const value = findPropertyValue(body, key);
|
|
183
|
+
if (!value || !value.startsWith('[')) return null;
|
|
184
|
+
return splitTopLevelItems(value.slice(1, -1));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseNumericPointLiteral(body: string, key: string): number[] | null {
|
|
188
|
+
const value = findPropertyValue(body, key);
|
|
189
|
+
if (!value || !value.startsWith('[')) return null;
|
|
190
|
+
const parts = splitTopLevelItems(value.slice(1, -1));
|
|
191
|
+
if (parts.length === 0) return null;
|
|
192
|
+
const nums = parts.map((part) => Number(part.trim()));
|
|
193
|
+
if (nums.some((n) => Number.isNaN(n))) return null;
|
|
194
|
+
return nums;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getObjectBodiesForMethod(code: string, methodName: string): string[] {
|
|
198
|
+
const marker = `bim.create.${methodName}(`;
|
|
199
|
+
const bodies: string[] = [];
|
|
200
|
+
let start = 0;
|
|
201
|
+
while (true) {
|
|
202
|
+
const idx = code.indexOf(marker, start);
|
|
203
|
+
if (idx < 0) break;
|
|
204
|
+
const openParen = idx + marker.length - 1;
|
|
205
|
+
const closeParen = scanToMatching(code, openParen, '(', ')');
|
|
206
|
+
if (closeParen < 0) break;
|
|
207
|
+
const callBody = code.slice(openParen + 1, closeParen);
|
|
208
|
+
let lastObject: string | null = null;
|
|
209
|
+
for (let i = 0; i < callBody.length; i++) {
|
|
210
|
+
if (callBody[i] !== '{') continue;
|
|
211
|
+
const closeBrace = scanToMatching(callBody, i, '{', '}');
|
|
212
|
+
if (closeBrace < 0) break;
|
|
213
|
+
lastObject = callBody.slice(i + 1, closeBrace);
|
|
214
|
+
i = closeBrace;
|
|
215
|
+
}
|
|
216
|
+
if (lastObject) bodies.push(lastObject);
|
|
217
|
+
start = closeParen + 1;
|
|
218
|
+
}
|
|
219
|
+
return bodies;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface MethodCallMatch {
|
|
223
|
+
methodName: string;
|
|
224
|
+
range: { from: number; to: number };
|
|
225
|
+
snippet: string;
|
|
226
|
+
line: number;
|
|
227
|
+
column: number;
|
|
228
|
+
unterminated: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getMethodCalls(code: string, methodName: string): MethodCallMatch[] {
|
|
232
|
+
const marker = `bim.create.${methodName}(`;
|
|
233
|
+
const matches: MethodCallMatch[] = [];
|
|
234
|
+
let start = 0;
|
|
235
|
+
|
|
236
|
+
while (true) {
|
|
237
|
+
const idx = code.indexOf(marker, start);
|
|
238
|
+
if (idx < 0) break;
|
|
239
|
+
const openParen = idx + marker.length - 1;
|
|
240
|
+
const closeParen = scanToMatching(code, openParen, '(', ')');
|
|
241
|
+
const unterminated = closeParen < 0;
|
|
242
|
+
const end = unterminated ? findFallbackCallEnd(code, idx + marker.length) : closeParen + 1;
|
|
243
|
+
const { line, column } = getLineAndColumn(code, idx);
|
|
244
|
+
|
|
245
|
+
matches.push({
|
|
246
|
+
methodName,
|
|
247
|
+
range: { from: idx, to: end },
|
|
248
|
+
snippet: code.slice(idx, end).trimEnd(),
|
|
249
|
+
line,
|
|
250
|
+
column,
|
|
251
|
+
unterminated,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
start = Math.max(end, idx + marker.length);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return matches;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function findFallbackCallEnd(code: string, start: number): number {
|
|
261
|
+
const candidates = [
|
|
262
|
+
code.indexOf('\n//', start),
|
|
263
|
+
code.indexOf('\nconst ', start),
|
|
264
|
+
code.indexOf('\nlet ', start),
|
|
265
|
+
code.indexOf('\nvar ', start),
|
|
266
|
+
code.indexOf('\nbim.create.', start),
|
|
267
|
+
code.indexOf('\n\n', start),
|
|
268
|
+
].filter((value) => value >= 0);
|
|
269
|
+
|
|
270
|
+
return candidates.length > 0 ? Math.min(...candidates) : code.length;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getLineAndColumn(code: string, offset: number): { line: number; column: number } {
|
|
274
|
+
let line = 1;
|
|
275
|
+
let lastLineStart = 0;
|
|
276
|
+
for (let i = 0; i < offset; i++) {
|
|
277
|
+
if (code[i] === '\n') {
|
|
278
|
+
line++;
|
|
279
|
+
lastLineStart = i + 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { line, column: offset - lastLineStart + 1 };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getLineSnippet(code: string, offset: number): string {
|
|
286
|
+
const lineStart = code.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
|
|
287
|
+
const nextBreak = code.indexOf('\n', offset);
|
|
288
|
+
const lineEnd = nextBreak >= 0 ? nextBreak : code.length;
|
|
289
|
+
return code.slice(lineStart, lineEnd).trimEnd();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isIdentifierStart(char: string): boolean {
|
|
293
|
+
return /[A-Za-z_$]/.test(char);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isIdentifierPart(char: string): boolean {
|
|
297
|
+
return /[A-Za-z0-9_$]/.test(char);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function isIdentifierBoundary(code: string, index: number): boolean {
|
|
301
|
+
if (index < 0 || index >= code.length) return true;
|
|
302
|
+
return !isIdentifierPart(code[index]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function skipStringLiteral(code: string, start: number): number {
|
|
306
|
+
const quote = code[start];
|
|
307
|
+
let index = start + 1;
|
|
308
|
+
while (index < code.length) {
|
|
309
|
+
const char = code[index];
|
|
310
|
+
if (char === '\\') {
|
|
311
|
+
index += 2;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (quote === '`' && char === '$' && code[index + 1] === '{') {
|
|
315
|
+
const expressionEnd = scanToMatching(code, index + 1, '{', '}');
|
|
316
|
+
if (expressionEnd < 0) return code.length;
|
|
317
|
+
index = expressionEnd + 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (char === quote) return index + 1;
|
|
321
|
+
index++;
|
|
322
|
+
}
|
|
323
|
+
return code.length;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function skipTrivia(code: string, start: number): number {
|
|
327
|
+
let index = start;
|
|
328
|
+
while (index < code.length) {
|
|
329
|
+
const char = code[index];
|
|
330
|
+
const next = code[index + 1];
|
|
331
|
+
if (/\s/.test(char)) {
|
|
332
|
+
index++;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (char === '/' && next === '/') {
|
|
336
|
+
index += 2;
|
|
337
|
+
while (index < code.length && code[index] !== '\n') index++;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (char === '/' && next === '*') {
|
|
341
|
+
index += 2;
|
|
342
|
+
while (index < code.length && !(code[index] === '*' && code[index + 1] === '/')) index++;
|
|
343
|
+
index = Math.min(code.length, index + 2);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
return index;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function readIdentifier(code: string, start: number): { value: string; end: number } | null {
|
|
352
|
+
const index = skipTrivia(code, start);
|
|
353
|
+
const char = code[index];
|
|
354
|
+
if (!char || !isIdentifierStart(char)) return null;
|
|
355
|
+
let end = index + 1;
|
|
356
|
+
while (end < code.length && isIdentifierPart(code[end])) end++;
|
|
357
|
+
return { value: code.slice(index, end), end };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function scanExpressionUntil(code: string, start: number, terminators: Set<string>): number {
|
|
361
|
+
let index = start;
|
|
362
|
+
let parenDepth = 0;
|
|
363
|
+
let bracketDepth = 0;
|
|
364
|
+
let braceDepth = 0;
|
|
365
|
+
|
|
366
|
+
while (index < code.length) {
|
|
367
|
+
index = skipTrivia(code, index);
|
|
368
|
+
if (index >= code.length) break;
|
|
369
|
+
const char = code[index];
|
|
370
|
+
|
|
371
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
372
|
+
index = skipStringLiteral(code, index);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (char === '(') parenDepth++;
|
|
377
|
+
else if (char === ')') {
|
|
378
|
+
if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) return index;
|
|
379
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
380
|
+
} else if (char === '[') bracketDepth++;
|
|
381
|
+
else if (char === ']') {
|
|
382
|
+
if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) return index;
|
|
383
|
+
bracketDepth = Math.max(0, bracketDepth - 1);
|
|
384
|
+
} else if (char === '{') braceDepth++;
|
|
385
|
+
else if (char === '}') {
|
|
386
|
+
if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) return index;
|
|
387
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
388
|
+
} else if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && terminators.has(char)) {
|
|
389
|
+
return index;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
index++;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return index;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function collectBindingsFromPattern(pattern: string, target: Set<string>): void {
|
|
399
|
+
const code = pattern.trim();
|
|
400
|
+
if (!code) return;
|
|
401
|
+
|
|
402
|
+
function parsePattern(start: number, terminators: Set<string>): number {
|
|
403
|
+
let index = skipTrivia(code, start);
|
|
404
|
+
if (index >= code.length) return index;
|
|
405
|
+
|
|
406
|
+
if (code.startsWith('...', index)) {
|
|
407
|
+
return parsePattern(index + 3, terminators);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const char = code[index];
|
|
411
|
+
if (char === '[') {
|
|
412
|
+
index++;
|
|
413
|
+
while (index < code.length) {
|
|
414
|
+
index = skipTrivia(code, index);
|
|
415
|
+
if (index >= code.length || code[index] === ']') return index + 1;
|
|
416
|
+
if (code[index] === ',') {
|
|
417
|
+
index++;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
index = parsePattern(index, new Set([',', ']']));
|
|
421
|
+
index = skipTrivia(code, index);
|
|
422
|
+
if (code[index] === '=') {
|
|
423
|
+
index = scanExpressionUntil(code, index + 1, new Set([',', ']']));
|
|
424
|
+
}
|
|
425
|
+
index = skipTrivia(code, index);
|
|
426
|
+
if (code[index] === ',') index++;
|
|
427
|
+
}
|
|
428
|
+
return index;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (char === '{') {
|
|
432
|
+
index++;
|
|
433
|
+
while (index < code.length) {
|
|
434
|
+
index = skipTrivia(code, index);
|
|
435
|
+
if (index >= code.length || code[index] === '}') return index + 1;
|
|
436
|
+
if (code[index] === ',') {
|
|
437
|
+
index++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (code.startsWith('...', index)) {
|
|
441
|
+
index = parsePattern(index + 3, new Set([',', '}']));
|
|
442
|
+
} else {
|
|
443
|
+
if (code[index] === '[') {
|
|
444
|
+
index = scanToMatching(code, index, '[', ']');
|
|
445
|
+
index = index < 0 ? code.length : index + 1;
|
|
446
|
+
} else if (code[index] === '"' || code[index] === '\'' || code[index] === '`') {
|
|
447
|
+
index = skipStringLiteral(code, index);
|
|
448
|
+
} else {
|
|
449
|
+
const key = readIdentifier(code, index);
|
|
450
|
+
if (key) {
|
|
451
|
+
index = key.end;
|
|
452
|
+
const afterKey = skipTrivia(code, index);
|
|
453
|
+
if (code[afterKey] === ':') {
|
|
454
|
+
index = parsePattern(afterKey + 1, new Set([',', '}', '=']));
|
|
455
|
+
} else {
|
|
456
|
+
target.add(key.value);
|
|
457
|
+
index = afterKey;
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
index++;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
index = skipTrivia(code, index);
|
|
466
|
+
if (code[index] === '=') {
|
|
467
|
+
index = scanExpressionUntil(code, index + 1, new Set([',', '}']));
|
|
468
|
+
}
|
|
469
|
+
index = skipTrivia(code, index);
|
|
470
|
+
if (code[index] === ',') index++;
|
|
471
|
+
}
|
|
472
|
+
return index;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const identifier = readIdentifier(code, index);
|
|
476
|
+
if (identifier) {
|
|
477
|
+
target.add(identifier.value);
|
|
478
|
+
return identifier.end;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return scanExpressionUntil(code, index, terminators);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
parsePattern(0, new Set());
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function collectDeclaredIdentifiers(code: string): Set<string> {
|
|
488
|
+
const declared = new Set<string>();
|
|
489
|
+
|
|
490
|
+
const collectCommaSeparatedPatterns = (body: string) => {
|
|
491
|
+
splitTopLevelItems(body).forEach((item) => {
|
|
492
|
+
const trimmed = item.trim();
|
|
493
|
+
if (!trimmed) return;
|
|
494
|
+
const equalsIndex = scanExpressionUntil(trimmed, 0, new Set(['=']));
|
|
495
|
+
const pattern = equalsIndex < trimmed.length ? trimmed.slice(0, equalsIndex) : trimmed;
|
|
496
|
+
collectBindingsFromPattern(pattern, declared);
|
|
497
|
+
});
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const collectFunctionLikeBindings = (expression: string) => {
|
|
501
|
+
const trimmed = expression.trim();
|
|
502
|
+
if (!trimmed) return;
|
|
503
|
+
|
|
504
|
+
if (trimmed.startsWith('function') && isIdentifierBoundary(trimmed, 'function'.length)) {
|
|
505
|
+
let cursor = skipTrivia(trimmed, 'function'.length);
|
|
506
|
+
const name = readIdentifier(trimmed, cursor);
|
|
507
|
+
if (name) {
|
|
508
|
+
cursor = name.end;
|
|
509
|
+
}
|
|
510
|
+
cursor = skipTrivia(trimmed, cursor);
|
|
511
|
+
if (trimmed[cursor] === '(') {
|
|
512
|
+
const close = scanToMatching(trimmed, cursor, '(', ')');
|
|
513
|
+
if (close >= 0) {
|
|
514
|
+
collectCommaSeparatedPatterns(trimmed.slice(cursor + 1, close));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (trimmed[0] === '(') {
|
|
521
|
+
const close = scanToMatching(trimmed, 0, '(', ')');
|
|
522
|
+
const afterClose = close >= 0 ? skipTrivia(trimmed, close + 1) : 0;
|
|
523
|
+
if (close >= 0 && trimmed.startsWith('=>', afterClose)) {
|
|
524
|
+
collectCommaSeparatedPatterns(trimmed.slice(1, close));
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const param = readIdentifier(trimmed, 0);
|
|
530
|
+
const afterParam = param ? skipTrivia(trimmed, param.end) : 0;
|
|
531
|
+
if (param && trimmed.startsWith('=>', afterParam)) {
|
|
532
|
+
declared.add(param.value);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const collectVariableDeclaration = (start: number): number => {
|
|
537
|
+
let index = skipTrivia(code, start);
|
|
538
|
+
|
|
539
|
+
while (index < code.length) {
|
|
540
|
+
const declaratorEnd = scanExpressionUntil(code, index, new Set([',', ';', ')']));
|
|
541
|
+
const declarator = code.slice(index, declaratorEnd);
|
|
542
|
+
const ofOrInMatch = declarator.match(/^(.*?)(?=\s+\b(?:of|in)\b)/s);
|
|
543
|
+
const bindingSegment = ofOrInMatch ? ofOrInMatch[1] : declarator;
|
|
544
|
+
const equalsIndex = scanExpressionUntil(bindingSegment, 0, new Set(['=']));
|
|
545
|
+
const pattern = equalsIndex < bindingSegment.length ? bindingSegment.slice(0, equalsIndex) : bindingSegment;
|
|
546
|
+
collectBindingsFromPattern(pattern, declared);
|
|
547
|
+
if (equalsIndex < bindingSegment.length) {
|
|
548
|
+
collectFunctionLikeBindings(bindingSegment.slice(equalsIndex + 1));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
index = skipTrivia(code, declaratorEnd);
|
|
552
|
+
if (code[index] !== ',') return declaratorEnd;
|
|
553
|
+
index++;
|
|
554
|
+
index = skipTrivia(code, index);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return index;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
for (let index = 0; index < code.length;) {
|
|
561
|
+
index = skipTrivia(code, index);
|
|
562
|
+
if (index >= code.length) break;
|
|
563
|
+
|
|
564
|
+
const char = code[index];
|
|
565
|
+
if (char === '"' || char === '\'' || char === '`') {
|
|
566
|
+
index = skipStringLiteral(code, index);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const variableKeyword = ['const', 'let', 'var'].find((keyword) =>
|
|
571
|
+
code.startsWith(keyword, index)
|
|
572
|
+
&& isIdentifierBoundary(code, index - 1)
|
|
573
|
+
&& isIdentifierBoundary(code, index + keyword.length),
|
|
574
|
+
);
|
|
575
|
+
if (variableKeyword) {
|
|
576
|
+
index = collectVariableDeclaration(index + variableKeyword.length);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (code.startsWith('function', index)
|
|
581
|
+
&& isIdentifierBoundary(code, index - 1)
|
|
582
|
+
&& isIdentifierBoundary(code, index + 'function'.length)) {
|
|
583
|
+
let cursor = skipTrivia(code, index + 'function'.length);
|
|
584
|
+
const name = readIdentifier(code, cursor);
|
|
585
|
+
if (name) {
|
|
586
|
+
declared.add(name.value);
|
|
587
|
+
cursor = name.end;
|
|
588
|
+
}
|
|
589
|
+
cursor = skipTrivia(code, cursor);
|
|
590
|
+
if (code[cursor] === '(') {
|
|
591
|
+
const close = scanToMatching(code, cursor, '(', ')');
|
|
592
|
+
if (close >= 0) {
|
|
593
|
+
collectCommaSeparatedPatterns(code.slice(cursor + 1, close));
|
|
594
|
+
index = close + 1;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (code.startsWith('catch', index)
|
|
601
|
+
&& isIdentifierBoundary(code, index - 1)
|
|
602
|
+
&& isIdentifierBoundary(code, index + 'catch'.length)) {
|
|
603
|
+
let cursor = skipTrivia(code, index + 'catch'.length);
|
|
604
|
+
if (code[cursor] === '(') {
|
|
605
|
+
const close = scanToMatching(code, cursor, '(', ')');
|
|
606
|
+
if (close >= 0) {
|
|
607
|
+
collectBindingsFromPattern(code.slice(cursor + 1, close), declared);
|
|
608
|
+
index = close + 1;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
index++;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return declared;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function validateKnownBimMethods(code: string): string[] {
|
|
621
|
+
const errors: string[] = [];
|
|
622
|
+
const byNamespace = new Map<string, Set<string>>();
|
|
623
|
+
for (const schema of NAMESPACE_SCHEMAS) {
|
|
624
|
+
byNamespace.set(schema.name, new Set(schema.methods.map((m) => m.name)));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const regex = /\bbim\.(\w+)\.(\w+)\s*\(/g;
|
|
628
|
+
let match: RegExpExecArray | null;
|
|
629
|
+
while ((match = regex.exec(code)) !== null) {
|
|
630
|
+
const namespace = match[1];
|
|
631
|
+
const method = match[2];
|
|
632
|
+
const knownMethods = byNamespace.get(namespace);
|
|
633
|
+
if (!knownMethods) {
|
|
634
|
+
errors.push(`Unknown namespace \`bim.${namespace}\`.`);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (!knownMethods.has(method)) {
|
|
638
|
+
const suggestion = nearestMethodName(method, Array.from(knownMethods));
|
|
639
|
+
errors.push(
|
|
640
|
+
suggestion
|
|
641
|
+
? `Unknown method \`bim.${namespace}.${method}()\`. Did you mean \`bim.${namespace}.${suggestion}()\`?`
|
|
642
|
+
: `Unknown method \`bim.${namespace}.${method}()\`.`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return errors;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function validatePositiveLiterals(body: string, methodName: string, keys: string[]): string[] {
|
|
650
|
+
const errors: string[] = [];
|
|
651
|
+
for (const key of keys) {
|
|
652
|
+
const value = findPropertyValue(body, key);
|
|
653
|
+
if (!value) continue;
|
|
654
|
+
const n = Number(value.trim());
|
|
655
|
+
if (!Number.isNaN(n) && n <= 0) {
|
|
656
|
+
errors.push(`\`bim.create.${methodName}(...)\` requires \`${key}\` to be > 0.`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return errors;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function validatePointArities(body: string, methodName: string, pointArity: Record<string, number>): string[] {
|
|
663
|
+
const errors: string[] = [];
|
|
664
|
+
for (const [key, arity] of Object.entries(pointArity)) {
|
|
665
|
+
const actual = getArrayLiteralArity(body, key);
|
|
666
|
+
if (actual !== null && actual !== arity) {
|
|
667
|
+
errors.push(`\`bim.create.${methodName}(...)\` expects \`${key}\` to be a ${arity}D point array.`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return errors;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function validateAxisPair(body: string, methodName: string, startKey: string, endKey: string): string[] {
|
|
674
|
+
const start = parseNumericPointLiteral(body, startKey);
|
|
675
|
+
const end = parseNumericPointLiteral(body, endKey);
|
|
676
|
+
if (!start || !end) return [];
|
|
677
|
+
if (start.length === end.length && start.every((value, index) => value === end[index])) {
|
|
678
|
+
return [`\`bim.create.${methodName}(...)\` requires \`${startKey}\` and \`${endKey}\` to define a non-zero axis.`];
|
|
679
|
+
}
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function validateAlternativeShapes(body: string, methodName: string, anyOf: string[][]): string[] {
|
|
684
|
+
if (anyOf.some((group) => group.every((key) => hasKey(body, key)))) {
|
|
685
|
+
return [];
|
|
686
|
+
}
|
|
687
|
+
return [
|
|
688
|
+
`\`bim.create.${methodName}(...)\` requires one of: ${anyOf.map((group) => group.map((key) => `\`${key}\``).join(' + ')).join(' OR ')}.`,
|
|
689
|
+
];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function validateForbiddenKeys(body: string, forbidKeys: Array<{ key: string; message: string }>): string[] {
|
|
693
|
+
return forbidKeys.filter(({ key }) => hasKey(body, key)).map(({ message }) => message);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function validateSlabShape(body: string): string[] {
|
|
697
|
+
const errors: string[] = [];
|
|
698
|
+
const profileValue = findPropertyValue(body, 'Profile');
|
|
699
|
+
if (profileValue?.startsWith('{')) {
|
|
700
|
+
errors.push('`bim.create.addIfcSlab(...)` expects `Profile` to be a 2D point array, not a generic profile object.');
|
|
701
|
+
}
|
|
702
|
+
return errors;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function validateRoofShape(methodName: 'addIfcRoof' | 'addIfcGableRoof', body: string): string[] {
|
|
706
|
+
const errors: string[] = [];
|
|
707
|
+
const slopeValue = findPropertyValue(body, 'Slope');
|
|
708
|
+
if (slopeValue) {
|
|
709
|
+
const slope = Number(slopeValue.trim());
|
|
710
|
+
if (!Number.isNaN(slope) && slope >= Math.PI / 2) {
|
|
711
|
+
errors.push(
|
|
712
|
+
`\`bim.create.${methodName}(...)\` expects \`Slope\` in radians between 0 and π/2. If you meant degrees, convert them first (for example \`15 * Math.PI / 180\`).`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const overhangValue = findPropertyValue(body, 'Overhang');
|
|
718
|
+
if (overhangValue) {
|
|
719
|
+
const overhang = Number(overhangValue.trim());
|
|
720
|
+
if (!Number.isNaN(overhang) && overhang < 0) {
|
|
721
|
+
errors.push(`\`bim.create.${methodName}(...)\` requires \`Overhang\` to be >= 0.`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (methodName === 'addIfcRoof') {
|
|
726
|
+
const nameValue = findPropertyValue(body, 'Name');
|
|
727
|
+
if (nameValue && /gable/i.test(nameValue)) {
|
|
728
|
+
errors.push('`bim.create.addIfcRoof(...)` is a flat/mono-pitch roof helper. Use `bim.create.addIfcGableRoof(...)` for standard dual-pitch or gable roofs.');
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return errors;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function validateGenericElementShape(body: string): string[] {
|
|
736
|
+
const errors: string[] = [];
|
|
737
|
+
if (hasKey(body, 'Type')) {
|
|
738
|
+
errors.push('`bim.create.addElement(...)` uses `IfcType`, not `Type`.');
|
|
739
|
+
}
|
|
740
|
+
if (hasKey(body, 'Position')) {
|
|
741
|
+
errors.push('`bim.create.addElement(...)` uses `Placement: { Location: [...] }`, not `Position`.');
|
|
742
|
+
}
|
|
743
|
+
if (hasKey(body, 'Height')) {
|
|
744
|
+
errors.push('`bim.create.addElement(...)` uses `Depth`, not `Height`.');
|
|
745
|
+
}
|
|
746
|
+
if (hasKey(body, 'ExtrusionHeight')) {
|
|
747
|
+
errors.push('`bim.create.addElement(...)` uses `Depth`, not `ExtrusionHeight`.');
|
|
748
|
+
}
|
|
749
|
+
const placementValue = findPropertyValue(body, 'Placement');
|
|
750
|
+
if (placementValue?.startsWith('{') && !/\bLocation\s*:/.test(placementValue)) {
|
|
751
|
+
errors.push('`bim.create.addElement(...)` requires `Placement.Location`.');
|
|
752
|
+
}
|
|
753
|
+
const profileValue = findPropertyValue(body, 'Profile');
|
|
754
|
+
if (profileValue?.startsWith('{')) {
|
|
755
|
+
if (!/\bProfileType\s*:/.test(profileValue)) {
|
|
756
|
+
errors.push('`bim.create.addElement(...)` requires a valid IFC-style `Profile` object with `ProfileType`.');
|
|
757
|
+
}
|
|
758
|
+
if (/\bkind\s*:|\bxDim\s*:|\byDim\s*:/.test(profileValue)) {
|
|
759
|
+
errors.push('`bim.create.addElement(...)` profile keys must use IFC casing such as `XDim`, `YDim`, `Radius`, `OuterCurve`, and `ProfileType`.');
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return errors;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function validateAxisElementShape(body: string): string[] {
|
|
766
|
+
const errors: string[] = [];
|
|
767
|
+
if (hasKey(body, 'Type')) {
|
|
768
|
+
errors.push('`bim.create.addAxisElement(...)` uses `IfcType`, not `Type`.');
|
|
769
|
+
}
|
|
770
|
+
const profileValue = findPropertyValue(body, 'Profile');
|
|
771
|
+
if (profileValue?.startsWith('{') && !/\bProfileType\s*:/.test(profileValue)) {
|
|
772
|
+
errors.push('`bim.create.addAxisElement(...)` requires a valid IFC-style `Profile` object with `ProfileType`.');
|
|
773
|
+
}
|
|
774
|
+
return errors;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function validateCreateContracts(code: string): string[] {
|
|
778
|
+
const errors: string[] = [];
|
|
779
|
+
for (const [methodName, rule] of Object.entries(METHOD_RULES)) {
|
|
780
|
+
const objectBodies = getObjectBodiesForMethod(code, methodName);
|
|
781
|
+
for (const body of objectBodies) {
|
|
782
|
+
const missing = rule.required.filter((key) => !hasKey(body, key));
|
|
783
|
+
if (missing.length > 0) {
|
|
784
|
+
errors.push(`\`bim.create.${methodName}(...)\` is missing required key(s): ${missing.map((key) => `\`${key}\``).join(', ')}.`);
|
|
785
|
+
}
|
|
786
|
+
if (rule.anyOf) errors.push(...validateAlternativeShapes(body, methodName, rule.anyOf));
|
|
787
|
+
if (rule.positiveKeys) errors.push(...validatePositiveLiterals(body, methodName, rule.positiveKeys));
|
|
788
|
+
if (rule.pointArity) errors.push(...validatePointArities(body, methodName, rule.pointArity));
|
|
789
|
+
if (rule.axisPair) errors.push(...validateAxisPair(body, methodName, rule.axisPair[0], rule.axisPair[1]));
|
|
790
|
+
if (rule.forbidKeys) errors.push(...validateForbiddenKeys(body, rule.forbidKeys));
|
|
791
|
+
if (rule.custom) errors.push(...rule.custom(body));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return errors;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function validateBareIdentifierTraps(code: string): string[] {
|
|
798
|
+
const errors: string[] = [];
|
|
799
|
+
for (const methodName of Object.keys(METHOD_RULES)) {
|
|
800
|
+
for (const objectBody of getObjectBodiesForMethod(code, methodName)) {
|
|
801
|
+
const valueRegex = /:\s*([A-Za-z_]\w*)\s*(?=,|$)/g;
|
|
802
|
+
let valueMatch: RegExpExecArray | null;
|
|
803
|
+
while ((valueMatch = valueRegex.exec(objectBody)) !== null) {
|
|
804
|
+
const ident = valueMatch[1];
|
|
805
|
+
if (SUSPICIOUS_BARE_IDENTIFIERS.has(ident)) {
|
|
806
|
+
errors.push(
|
|
807
|
+
`Suspicious bare identifier value \`${ident}\` in BIM parameter object. Use a literal/array (e.g. \`${ident}: [..]\` or \`${ident}: 1\`) or declare the variable explicitly.`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return errors;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function validateWallHostedOpeningDiagnostics(code: string): PreflightScriptDiagnostic[] {
|
|
817
|
+
const hasWallCalls = code.includes('bim.create.addIfcWall(');
|
|
818
|
+
if (!hasWallCalls) return [];
|
|
819
|
+
|
|
820
|
+
const hasWallOpenings = /\bOpenings\s*:/.test(code);
|
|
821
|
+
if (hasWallOpenings) return [];
|
|
822
|
+
|
|
823
|
+
const diagnostics: PreflightScriptDiagnostic[] = [];
|
|
824
|
+
const methodMessages = {
|
|
825
|
+
addIfcWindow: 'Suspicious pattern: `bim.create.addIfcWindow(...)` is being used alongside walls, but no wall `Openings` are defined. `addIfcWindow(...)` creates a world-aligned standalone window and will not auto-align or host into a wall. For wall-hosted inserts, use `bim.create.addIfcWallWindow(...)` or `Openings` on `bim.create.addIfcWall(...)`.',
|
|
826
|
+
addIfcDoor: 'Suspicious pattern: `bim.create.addIfcDoor(...)` is being used alongside walls, but no wall `Openings` are defined. `addIfcDoor(...)` creates a world-aligned standalone door and will not auto-align or host into a wall. For wall-hosted inserts, use `bim.create.addIfcWallDoor(...)` or `Openings` on `bim.create.addIfcWall(...)`.',
|
|
827
|
+
} satisfies Record<'addIfcWindow' | 'addIfcDoor', string>;
|
|
828
|
+
|
|
829
|
+
for (const methodName of Object.keys(methodMessages) as Array<'addIfcWindow' | 'addIfcDoor'>) {
|
|
830
|
+
for (const match of getMethodCalls(code, methodName)) {
|
|
831
|
+
diagnostics.push(createPreflightDiagnostic(
|
|
832
|
+
'wall_hosted_opening_pattern',
|
|
833
|
+
methodMessages[methodName],
|
|
834
|
+
'error',
|
|
835
|
+
{
|
|
836
|
+
methodName,
|
|
837
|
+
symbol: 'Openings',
|
|
838
|
+
failureKind: 'standalone_opening',
|
|
839
|
+
range: match.range,
|
|
840
|
+
line: match.line,
|
|
841
|
+
column: match.column,
|
|
842
|
+
snippet: match.snippet,
|
|
843
|
+
fixHint: `Replace this ${methodName === 'addIfcDoor' ? 'door' : 'window'} call with a wall-hosted insert or add it through the host wall's \`Openings\` payload.`,
|
|
844
|
+
unterminated: match.unterminated,
|
|
845
|
+
},
|
|
846
|
+
));
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return diagnostics;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function validateMetadataQueryPatterns(code: string): string[] {
|
|
854
|
+
const errors: string[] = [];
|
|
855
|
+
|
|
856
|
+
if (/bim\.query\.property\(\s*[^,]+,\s*["'`]Pset_MaterialCommon["'`]/.test(code) || /bim\.query\.property\(\s*[^,]+,\s*["'`]Material["'`]\s*,\s*["'`]Name["'`]/.test(code)) {
|
|
857
|
+
errors.push('Suspicious material lookup: materials are usually not stored as ordinary property-set values. Prefer `bim.query.materials(entity)` over querying `Pset_MaterialCommon` or `Material.Name` as a property set.');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return errors;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function looksLikeMultiStoreyScript(code: string): boolean {
|
|
864
|
+
const hasStoreyLoop = /for\s*\([^)]*;\s*[^;]*\b(storeyCount|levels?|floors?)\b/i.test(code) || /for\s*\(\s*let\s+\w+\s*=\s*0\s*;[^)]*<\s*\w+Count/i.test(code);
|
|
865
|
+
const hasStoreyCreation = code.includes('bim.create.addIfcBuildingStorey(');
|
|
866
|
+
return hasStoreyLoop && hasStoreyCreation;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function mentionsElevationSignal(value: string): boolean {
|
|
870
|
+
return /\b(elevation|storeyElevation|levelElevation|baseZ|levelZ|storeyZ|z)\b/.test(value);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function validateWorldPlacementPatterns(code: string): PreflightScriptDiagnostic[] {
|
|
874
|
+
if (!looksLikeMultiStoreyScript(code)) return [];
|
|
875
|
+
|
|
876
|
+
const diagnostics: PreflightScriptDiagnostic[] = [];
|
|
877
|
+
const checks: Array<{ methodName: 'addIfcCurtainWall' | 'addIfcMember' | 'addIfcPlate'; keys: string[] }> = [
|
|
878
|
+
{ methodName: 'addIfcCurtainWall', keys: ['Start', 'End'] },
|
|
879
|
+
{ methodName: 'addIfcMember', keys: ['Start', 'End'] },
|
|
880
|
+
{ methodName: 'addIfcPlate', keys: ['Position'] },
|
|
881
|
+
];
|
|
882
|
+
|
|
883
|
+
for (const { methodName, keys } of checks) {
|
|
884
|
+
for (const match of getMethodCalls(code, methodName)) {
|
|
885
|
+
const body = getObjectBodiesForMethod(match.snippet, methodName)[0];
|
|
886
|
+
if (!body) continue;
|
|
887
|
+
const zValues = keys
|
|
888
|
+
.map((key) => {
|
|
889
|
+
const items = getArrayLiteralItems(body, key);
|
|
890
|
+
return items && items.length >= 3 ? items[2].trim() : null;
|
|
891
|
+
})
|
|
892
|
+
.filter((value): value is string => Boolean(value));
|
|
893
|
+
|
|
894
|
+
if (zValues.length === 0) continue;
|
|
895
|
+
const allGrounded = zValues.every((value) => value === '0' || value === '0.0');
|
|
896
|
+
const anyElevationAware = zValues.some((value) => mentionsElevationSignal(value));
|
|
897
|
+
if (allGrounded && !anyElevationAware) {
|
|
898
|
+
diagnostics.push(createPreflightDiagnostic(
|
|
899
|
+
'world_placement_elevation',
|
|
900
|
+
`Suspicious multi-level placement: \`bim.create.${methodName}(...)\` appears inside a repeated storey-level script but uses fixed ground-level Z coordinates. This method is world-placement based, so its Z coordinates should usually include the current level elevation.`,
|
|
901
|
+
'error',
|
|
902
|
+
{
|
|
903
|
+
methodName,
|
|
904
|
+
failureKind: 'missing_level_elevation',
|
|
905
|
+
range: match.range,
|
|
906
|
+
line: match.line,
|
|
907
|
+
column: match.column,
|
|
908
|
+
snippet: match.snippet,
|
|
909
|
+
fixHint: 'Include the current level/storey elevation in the Z coordinates for this world-placement call.',
|
|
910
|
+
},
|
|
911
|
+
));
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return diagnostics;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function validateDetachedSnippetScope(code: string): PreflightScriptDiagnostic[] {
|
|
920
|
+
const diagnostics: PreflightScriptDiagnostic[] = [];
|
|
921
|
+
const declared = collectDeclaredIdentifiers(code);
|
|
922
|
+
|
|
923
|
+
const maybePushIdentifierDiagnostic = (identifier: string, message: string) => {
|
|
924
|
+
const match = new RegExp(String.raw`\b${identifier}\b`).exec(code);
|
|
925
|
+
const offset = match?.index ?? 0;
|
|
926
|
+
const { line, column } = getLineAndColumn(code, offset);
|
|
927
|
+
diagnostics.push(createPreflightDiagnostic(
|
|
928
|
+
'detached_snippet_scope',
|
|
929
|
+
message,
|
|
930
|
+
'error',
|
|
931
|
+
{
|
|
932
|
+
symbol: identifier,
|
|
933
|
+
failureKind: 'missing_context_binding',
|
|
934
|
+
range: { from: offset, to: offset + identifier.length },
|
|
935
|
+
line,
|
|
936
|
+
column,
|
|
937
|
+
snippet: getLineSnippet(code, offset),
|
|
938
|
+
fixHint: 'Patch the existing full script or restore the missing surrounding declarations instead of returning an isolated fragment.',
|
|
939
|
+
},
|
|
940
|
+
));
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
if (/\bbim\.create\.[A-Za-z]+\(\s*h\s*,/.test(code) && !declared.has('h') && !/bim\.create\.project\(/.test(code)) {
|
|
944
|
+
maybePushIdentifierDiagnostic('h', 'Detached snippet risk: BIM create calls reference `h`, but no project handle is declared in this script. Preserve the surrounding full script or recreate the project/context explicitly.');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (/\bbim\.create\.[A-Za-z]+\(\s*h\s*,\s*storey\b/.test(code) && !declared.has('storey') && !/addIfcBuildingStorey\(/.test(code)) {
|
|
948
|
+
maybePushIdentifierDiagnostic('storey', 'Detached snippet risk: BIM create calls reference `storey`, but no storey handle is declared in this script. Preserve the surrounding loop/context instead of returning a standalone fragment.');
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
for (const identifier of ['width', 'depth', 'i', 'z']) {
|
|
952
|
+
if (new RegExp(String.raw`\b${identifier}\b`).test(code) && !declared.has(identifier)) {
|
|
953
|
+
maybePushIdentifierDiagnostic(identifier, `Detached snippet risk: script references \`${identifier}\` without declaring it locally. If this is a fix for an existing script, patch the full script in place instead of returning an isolated fragment.`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
return diagnostics;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function validateScriptPreflightDetailed(code: string): PreflightScriptDiagnostic[] {
|
|
961
|
+
return [
|
|
962
|
+
...validateKnownBimMethods(code).map((message) => createPreflightDiagnostic(
|
|
963
|
+
message.startsWith('Unknown namespace') ? 'unknown_namespace' : 'unknown_method',
|
|
964
|
+
message,
|
|
965
|
+
'error',
|
|
966
|
+
buildDiagnosticData(message),
|
|
967
|
+
)),
|
|
968
|
+
...validateCreateContracts(code).map((message) => createPreflightDiagnostic('create_contract', message, 'error', buildDiagnosticData(message))),
|
|
969
|
+
...validateBareIdentifierTraps(code).map((message) => createPreflightDiagnostic('bare_identifier', message, 'error', buildDiagnosticData(message))),
|
|
970
|
+
...validateWallHostedOpeningDiagnostics(code),
|
|
971
|
+
...validateMetadataQueryPatterns(code).map((message) => createPreflightDiagnostic('metadata_query_pattern', message, 'error', buildDiagnosticData(message))),
|
|
972
|
+
...validateWorldPlacementPatterns(code),
|
|
973
|
+
...validateDetachedSnippetScope(code),
|
|
974
|
+
];
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export function validateScriptPreflight(code: string): string[] {
|
|
978
|
+
return formatDiagnosticsForDisplay(validateScriptPreflightDetailed(code));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function buildDiagnosticData(message: string): Record<string, unknown> | undefined {
|
|
982
|
+
const data: Record<string, unknown> = {};
|
|
983
|
+
const methodMatch = /`bim\.\w+\.([A-Za-z0-9_]+)\([^`]*`/.exec(message);
|
|
984
|
+
const symbolMatch = /`([A-Za-z_]\w*)`/.exec(message);
|
|
985
|
+
|
|
986
|
+
if (methodMatch) data.methodName = methodMatch[1];
|
|
987
|
+
if (symbolMatch) data.symbol = symbolMatch[1];
|
|
988
|
+
|
|
989
|
+
return Object.keys(data).length > 0 ? data : undefined;
|
|
990
|
+
}
|