@ifc-lite/cli 0.3.0 → 0.5.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/dist/commands/ask.d.ts +2 -0
- package/dist/commands/ask.d.ts.map +1 -0
- package/dist/commands/ask.js +403 -0
- package/dist/commands/ask.js.map +1 -0
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +115 -27
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +175 -11
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/info.d.ts.map +1 -1
- package/dist/commands/info.js +24 -7
- package/dist/commands/info.js.map +1 -1
- package/dist/commands/mutate.d.ts +2 -0
- package/dist/commands/mutate.d.ts.map +1 -0
- package/dist/commands/mutate.js +203 -0
- package/dist/commands/mutate.js.map +1 -0
- package/dist/commands/query.d.ts.map +1 -1
- package/dist/commands/query.js +548 -52
- package/dist/commands/query.js.map +1 -1
- package/dist/commands/stats.d.ts +2 -0
- package/dist/commands/stats.d.ts.map +1 -0
- package/dist/commands/stats.js +269 -0
- package/dist/commands/stats.js.map +1 -0
- package/dist/index.js +35 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/dist/commands/query.js
CHANGED
|
@@ -58,6 +58,24 @@ const STANDARD_QTO_MAP = {
|
|
|
58
58
|
Qto_FootingBaseQuantities: ['Length', 'Width', 'Height', 'CrossSectionArea', 'OuterSurfaceArea', 'GrossVolume', 'NetVolume', 'GrossWeight', 'NetWeight'],
|
|
59
59
|
},
|
|
60
60
|
};
|
|
61
|
+
/** Valid built-in grouping keys */
|
|
62
|
+
const VALID_GROUP_BY_KEYS = ['type', 'storey', 'material'];
|
|
63
|
+
/**
|
|
64
|
+
* B9/F6: Auto-prefix Ifc for --type if user omits it.
|
|
65
|
+
* Returns the corrected type string, or the original if already prefixed.
|
|
66
|
+
*/
|
|
67
|
+
function normalizeTypeName(typeStr) {
|
|
68
|
+
return typeStr.split(',').map(t => {
|
|
69
|
+
const trimmed = t.trim();
|
|
70
|
+
if (trimmed.startsWith('Ifc') || trimmed.startsWith('IFC') || trimmed.startsWith('ifc')) {
|
|
71
|
+
return trimmed;
|
|
72
|
+
}
|
|
73
|
+
// Auto-prefix with Ifc
|
|
74
|
+
const prefixed = 'Ifc' + trimmed;
|
|
75
|
+
process.stderr.write(`Note: Auto-corrected type "${trimmed}" → "${prefixed}"\n`);
|
|
76
|
+
return prefixed;
|
|
77
|
+
}).join(',');
|
|
78
|
+
}
|
|
61
79
|
/**
|
|
62
80
|
* Parse a --where filter string into psetName, propName, operator, value.
|
|
63
81
|
* Supported formats:
|
|
@@ -90,31 +108,100 @@ function parseWhereFilter(filter) {
|
|
|
90
108
|
// No operator found — exists check
|
|
91
109
|
return { psetName, propName: rest, operator: 'exists' };
|
|
92
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* B3/F1: Apply --where filter to entities, searching both property sets AND quantity sets.
|
|
113
|
+
* Falls back to quantity sets when a property set match is not found.
|
|
114
|
+
*/
|
|
115
|
+
function applyWhereFilter(entities, parsed, bim) {
|
|
116
|
+
return entities.filter(e => {
|
|
117
|
+
// First try property sets
|
|
118
|
+
const props = bim.properties(e.ref);
|
|
119
|
+
const pset = props.find((p) => p.name === parsed.psetName);
|
|
120
|
+
if (pset) {
|
|
121
|
+
const prop = pset.properties.find((p) => p.name === parsed.propName);
|
|
122
|
+
if (prop) {
|
|
123
|
+
if (parsed.operator === 'exists')
|
|
124
|
+
return true;
|
|
125
|
+
return compareValues(prop.value, parsed.operator, parsed.value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// B3: Also search quantity sets
|
|
129
|
+
const qsets = bim.quantities(e.ref);
|
|
130
|
+
const qset = qsets.find((q) => q.name === parsed.psetName);
|
|
131
|
+
if (qset) {
|
|
132
|
+
const qty = qset.quantities.find((q) => q.name === parsed.propName);
|
|
133
|
+
if (qty) {
|
|
134
|
+
if (parsed.operator === 'exists')
|
|
135
|
+
return true;
|
|
136
|
+
return compareValues(qty.value, parsed.operator, parsed.value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function compareValues(actual, operator, expected) {
|
|
143
|
+
if (expected === undefined)
|
|
144
|
+
return actual != null;
|
|
145
|
+
const normActual = normalizeBooleanValue(actual);
|
|
146
|
+
const normExpected = normalizeBooleanValue(expected);
|
|
147
|
+
switch (operator) {
|
|
148
|
+
case '=': return String(normActual) === String(normExpected);
|
|
149
|
+
case '!=': return String(normActual) !== String(normExpected);
|
|
150
|
+
case '>': return Number(normActual) > Number(normExpected);
|
|
151
|
+
case '<': return Number(normActual) < Number(normExpected);
|
|
152
|
+
case '>=': return Number(normActual) >= Number(normExpected);
|
|
153
|
+
case '<=': return Number(normActual) <= Number(normExpected);
|
|
154
|
+
case 'contains': return String(normActual).toLowerCase().includes(String(normExpected).toLowerCase());
|
|
155
|
+
default: return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function normalizeBooleanValue(value) {
|
|
159
|
+
if (value === true || value === '.T.' || value === 'true' || value === 'TRUE')
|
|
160
|
+
return 'true';
|
|
161
|
+
if (value === false || value === '.F.' || value === 'false' || value === 'FALSE')
|
|
162
|
+
return 'false';
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Helper: get a quantity value for an entity by name (searching all qsets).
|
|
167
|
+
*/
|
|
168
|
+
function getQuantityValue(bim, ref, quantityName) {
|
|
169
|
+
const qsets = bim.quantities(ref);
|
|
170
|
+
for (const qset of qsets) {
|
|
171
|
+
for (const q of qset.quantities) {
|
|
172
|
+
if (q.name === quantityName)
|
|
173
|
+
return Number(q.value) || 0;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
93
178
|
export async function queryCommand(args) {
|
|
94
179
|
const filePath = args.find(a => !a.startsWith('-'));
|
|
95
180
|
if (!filePath)
|
|
96
181
|
fatal('Usage: ifc-lite query <file.ifc> --type IfcWall [--props] [--limit N]');
|
|
97
|
-
|
|
182
|
+
let type = getFlag(args, '--type');
|
|
98
183
|
const limit = getFlag(args, '--limit');
|
|
99
184
|
const offset = getFlag(args, '--offset');
|
|
100
185
|
const propFilter = getFlag(args, '--where');
|
|
101
|
-
const showProps = hasFlag(args, '--props');
|
|
102
|
-
const showQuantities = hasFlag(args, '--quantities');
|
|
103
|
-
const showMaterials = hasFlag(args, '--materials');
|
|
104
|
-
const showClassifications = hasFlag(args, '--classifications');
|
|
105
|
-
const showAttributes = hasFlag(args, '--attributes');
|
|
106
|
-
const showRelationships = hasFlag(args, '--relationships');
|
|
107
|
-
const showTypeProps = hasFlag(args, '--type-props');
|
|
108
|
-
const showDocuments = hasFlag(args, '--documents');
|
|
109
|
-
const showAll = hasFlag(args, '--all');
|
|
110
186
|
const jsonOutput = hasFlag(args, '--json');
|
|
111
187
|
const countOnly = hasFlag(args, '--count');
|
|
112
188
|
const spatial = hasFlag(args, '--spatial');
|
|
113
189
|
const sumQuantity = getFlag(args, '--sum');
|
|
190
|
+
const avgQuantity = getFlag(args, '--avg');
|
|
191
|
+
const minQuantity = getFlag(args, '--min');
|
|
192
|
+
const maxQuantity = getFlag(args, '--max');
|
|
193
|
+
const sortBy = getFlag(args, '--sort');
|
|
194
|
+
const descSort = hasFlag(args, '--desc');
|
|
114
195
|
const storeyFilter = getFlag(args, '--storey');
|
|
115
196
|
const quantityNames = hasFlag(args, '--quantity-names');
|
|
197
|
+
const propertyNames = hasFlag(args, '--property-names');
|
|
198
|
+
const uniqueProp = getFlag(args, '--unique');
|
|
116
199
|
const groupBy = getFlag(args, '--group-by');
|
|
117
200
|
const spatialSummary = hasFlag(args, '--summary');
|
|
201
|
+
// B9/F6: Auto-prefix Ifc for --type
|
|
202
|
+
if (type) {
|
|
203
|
+
type = normalizeTypeName(type);
|
|
204
|
+
}
|
|
118
205
|
const { bim } = await createHeadlessContext(filePath);
|
|
119
206
|
// --quantity-names: list available quantities per entity type
|
|
120
207
|
if (quantityNames) {
|
|
@@ -195,6 +282,117 @@ export async function queryCommand(args) {
|
|
|
195
282
|
}
|
|
196
283
|
return;
|
|
197
284
|
}
|
|
285
|
+
// --property-names: list available properties per entity type
|
|
286
|
+
if (propertyNames) {
|
|
287
|
+
const targetType = type;
|
|
288
|
+
if (!targetType)
|
|
289
|
+
fatal('--property-names requires --type (e.g., --type IfcWall --property-names)');
|
|
290
|
+
const entities = bim.query().byType(...targetType.split(',')).limit(50).toArray();
|
|
291
|
+
const psetMap = {};
|
|
292
|
+
for (const e of entities) {
|
|
293
|
+
const psets = bim.properties(e.ref);
|
|
294
|
+
for (const pset of psets) {
|
|
295
|
+
if (!psetMap[pset.name])
|
|
296
|
+
psetMap[pset.name] = new Map();
|
|
297
|
+
const pmap = psetMap[pset.name];
|
|
298
|
+
for (const p of pset.properties) {
|
|
299
|
+
const existing = pmap.get(p.name);
|
|
300
|
+
const strVal = p.value != null ? String(p.value) : '';
|
|
301
|
+
if (existing) {
|
|
302
|
+
existing.count++;
|
|
303
|
+
if (existing.sampleValues.length < 3 && strVal && !existing.sampleValues.includes(strVal)) {
|
|
304
|
+
existing.sampleValues.push(strVal);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
pmap.set(p.name, { count: 1, sampleValues: strVal ? [strVal] : [] });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (jsonOutput) {
|
|
314
|
+
const result = {};
|
|
315
|
+
for (const [psetName, pmap] of Object.entries(psetMap)) {
|
|
316
|
+
result[psetName] = {};
|
|
317
|
+
for (const [propName, info] of pmap) {
|
|
318
|
+
result[psetName][propName] = {
|
|
319
|
+
foundIn: `${info.count}/${entities.length} entities`,
|
|
320
|
+
sampleValues: info.sampleValues,
|
|
321
|
+
filterPath: `${psetName}.${propName}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
printJson({ availableProperties: result, note: 'Use --where PsetName.PropName=Value to filter.' });
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
process.stdout.write(`\nProperties available for ${targetType} (sampled ${entities.length} entities):\n\n`);
|
|
329
|
+
for (const [psetName, pmap] of Object.entries(psetMap)) {
|
|
330
|
+
process.stdout.write(` ${psetName}:\n`);
|
|
331
|
+
for (const [propName, info] of pmap) {
|
|
332
|
+
const samples = info.sampleValues.length > 0 ? ` samples: [${info.sampleValues.map(v => `"${v}"`).join(', ')}]` : '';
|
|
333
|
+
process.stdout.write(` ${propName} (${info.count}/${entities.length} entities)${samples}\n`);
|
|
334
|
+
}
|
|
335
|
+
process.stdout.write('\n');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// B6/F8: --unique: distinct values for a property path, material, or storey
|
|
341
|
+
if (uniqueProp) {
|
|
342
|
+
const targetType = type;
|
|
343
|
+
if (!targetType)
|
|
344
|
+
fatal('--unique requires --type (e.g., --type IfcWall --unique material)');
|
|
345
|
+
const entities = bim.query().byType(...targetType.split(',')).toArray();
|
|
346
|
+
const valueCounts = new Map();
|
|
347
|
+
if (uniqueProp === 'material') {
|
|
348
|
+
// B6: Support --unique material
|
|
349
|
+
for (const e of entities) {
|
|
350
|
+
const mat = bim.materials(e.ref);
|
|
351
|
+
const val = mat?.materials?.[0] ?? mat?.name ?? '(no material)';
|
|
352
|
+
valueCounts.set(val, (valueCounts.get(val) ?? 0) + 1);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (uniqueProp === 'storey') {
|
|
356
|
+
for (const e of entities) {
|
|
357
|
+
const storey = bim.storey(e.ref);
|
|
358
|
+
const val = storey?.name ?? '(no storey)';
|
|
359
|
+
valueCounts.set(val, (valueCounts.get(val) ?? 0) + 1);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else if (uniqueProp === 'type') {
|
|
363
|
+
for (const e of entities) {
|
|
364
|
+
valueCounts.set(e.type, (valueCounts.get(e.type) ?? 0) + 1);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
const dotIdx = uniqueProp.indexOf('.');
|
|
369
|
+
if (dotIdx <= 0)
|
|
370
|
+
fatal(`Invalid --unique path: "${uniqueProp}". Expected: PsetName.PropName, or one of: material, storey, type`);
|
|
371
|
+
const psetName = uniqueProp.slice(0, dotIdx);
|
|
372
|
+
const propName = uniqueProp.slice(dotIdx + 1);
|
|
373
|
+
for (const e of entities) {
|
|
374
|
+
const psets = bim.properties(e.ref);
|
|
375
|
+
const pset = psets.find((p) => p.name === psetName);
|
|
376
|
+
const prop = pset?.properties?.find((p) => p.name === propName);
|
|
377
|
+
const val = prop?.value != null ? String(prop.value) : '(no value)';
|
|
378
|
+
valueCounts.set(val, (valueCounts.get(val) ?? 0) + 1);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (jsonOutput) {
|
|
382
|
+
const result = {};
|
|
383
|
+
for (const [val, count] of valueCounts)
|
|
384
|
+
result[val] = count;
|
|
385
|
+
printJson({ property: uniqueProp, distinctValues: result, totalEntities: entities.length });
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
const sorted = [...valueCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
389
|
+
for (const [val, count] of sorted) {
|
|
390
|
+
process.stdout.write(`${val} (${count})\n`);
|
|
391
|
+
}
|
|
392
|
+
process.stderr.write(`\n${sorted.length} distinct values across ${entities.length} entities\n`);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
198
396
|
// Spatial tree mode
|
|
199
397
|
if (spatial) {
|
|
200
398
|
const storeys = bim.storeys();
|
|
@@ -202,7 +400,7 @@ export async function queryCommand(args) {
|
|
|
202
400
|
if (storeys.length > 0) {
|
|
203
401
|
for (const storey of storeys) {
|
|
204
402
|
const contained = bim.contains(storey.ref);
|
|
205
|
-
tree[storey.name || `Storey #${storey.ref.expressId}`] = contained.map(e => ({
|
|
403
|
+
tree[storey.name || `Storey #${storey.ref.expressId}`] = contained.map((e) => ({
|
|
206
404
|
type: e.type,
|
|
207
405
|
name: e.name,
|
|
208
406
|
globalId: e.globalId,
|
|
@@ -214,7 +412,7 @@ export async function queryCommand(args) {
|
|
|
214
412
|
const buildings = bim.query().byType('IfcBuilding').toArray();
|
|
215
413
|
for (const building of buildings) {
|
|
216
414
|
const contained = bim.contains(building.ref);
|
|
217
|
-
tree[building.name || `Building #${building.ref.expressId}`] = contained.map(e => ({
|
|
415
|
+
tree[building.name || `Building #${building.ref.expressId}`] = contained.map((e) => ({
|
|
218
416
|
type: e.type,
|
|
219
417
|
name: e.name,
|
|
220
418
|
globalId: e.globalId,
|
|
@@ -262,75 +460,157 @@ export async function queryCommand(args) {
|
|
|
262
460
|
// --storey filter: restrict to entities in a specific storey
|
|
263
461
|
if (storeyFilter) {
|
|
264
462
|
const storeys = bim.storeys();
|
|
265
|
-
const matchedStorey = storeys.find(s => s.name === storeyFilter ||
|
|
463
|
+
const matchedStorey = storeys.find((s) => s.name === storeyFilter ||
|
|
266
464
|
s.name.toLowerCase().includes(storeyFilter.toLowerCase()) ||
|
|
267
465
|
String(s.ref.expressId) === storeyFilter);
|
|
268
466
|
if (!matchedStorey) {
|
|
269
|
-
const names = storeys.map(s => s.name).filter(Boolean).join(', ');
|
|
467
|
+
const names = storeys.map((s) => s.name).filter(Boolean).join(', ');
|
|
270
468
|
fatal(`Storey "${storeyFilter}" not found. Available: ${names || '(none)'}`);
|
|
271
469
|
}
|
|
272
470
|
const contained = bim.contains(matchedStorey.ref);
|
|
273
|
-
const storeyIds = new Set(contained.map(e => e.ref.expressId));
|
|
471
|
+
const storeyIds = new Set(contained.map((e) => e.ref.expressId));
|
|
274
472
|
// Post-filter: only keep entities that are in this storey
|
|
275
473
|
const baseEntities = q.toArray();
|
|
276
|
-
|
|
277
|
-
// Apply --where filter to storey-filtered entities
|
|
474
|
+
let storeyEntities = baseEntities.filter((e) => storeyIds.has(e.ref.expressId));
|
|
475
|
+
// B3: Apply --where filter to storey-filtered entities (with quantity support)
|
|
278
476
|
if (propFilter) {
|
|
279
477
|
const parsed = parseWhereFilter(propFilter);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const prop = pset.properties.find((p) => p.name === parsed.propName);
|
|
287
|
-
if (!prop)
|
|
288
|
-
return false;
|
|
289
|
-
if (parsed.operator === 'exists')
|
|
290
|
-
return true;
|
|
291
|
-
return String(prop.value) === String(parsed.value);
|
|
292
|
-
});
|
|
293
|
-
if (sumQuantity) {
|
|
294
|
-
outputSum(finalEntities, sumQuantity, bim, jsonOutput);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
if (countOnly) {
|
|
298
|
-
outputCount(finalEntities.length, jsonOutput);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
outputEntities(finalEntities, args, bim, jsonOutput);
|
|
478
|
+
storeyEntities = applyWhereFilter(storeyEntities, parsed, bim);
|
|
479
|
+
}
|
|
480
|
+
const sAggQty = sumQuantity ?? avgQuantity ?? minQuantity ?? maxQuantity;
|
|
481
|
+
const sAggMode = sumQuantity ? 'sum' : avgQuantity ? 'avg' : minQuantity ? 'min' : maxQuantity ? 'max' : undefined;
|
|
482
|
+
if (groupBy && sAggQty) {
|
|
483
|
+
outputGroupBy(storeyEntities, groupBy, sAggQty, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined, sAggMode);
|
|
302
484
|
return;
|
|
303
485
|
}
|
|
304
486
|
if (sumQuantity) {
|
|
305
487
|
outputSum(storeyEntities, sumQuantity, bim, jsonOutput);
|
|
306
488
|
return;
|
|
307
489
|
}
|
|
490
|
+
if (avgQuantity) {
|
|
491
|
+
outputAggregation(storeyEntities, avgQuantity, 'avg', bim, jsonOutput);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (minQuantity) {
|
|
495
|
+
outputAggregation(storeyEntities, minQuantity, 'min', bim, jsonOutput);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (maxQuantity) {
|
|
499
|
+
outputAggregation(storeyEntities, maxQuantity, 'max', bim, jsonOutput);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (groupBy) {
|
|
503
|
+
outputGroupBy(storeyEntities, groupBy, undefined, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
308
506
|
if (countOnly) {
|
|
309
507
|
outputCount(storeyEntities.length, jsonOutput);
|
|
310
508
|
return;
|
|
311
509
|
}
|
|
510
|
+
if (sortBy) {
|
|
511
|
+
storeyEntities = sortEntities(storeyEntities, sortBy, descSort, bim);
|
|
512
|
+
}
|
|
312
513
|
outputEntities(storeyEntities, args, bim, jsonOutput);
|
|
313
514
|
return;
|
|
314
515
|
}
|
|
315
|
-
// --where filter
|
|
516
|
+
// --where filter: search both property sets and quantity sets (B3)
|
|
316
517
|
if (propFilter) {
|
|
317
518
|
const parsed = parseWhereFilter(propFilter);
|
|
318
|
-
|
|
519
|
+
// We need to do manual filtering to support quantity sets
|
|
520
|
+
let entities = q.toArray();
|
|
521
|
+
entities = applyWhereFilter(entities, parsed, bim);
|
|
522
|
+
const whereAggQty = sumQuantity ?? avgQuantity ?? minQuantity ?? maxQuantity;
|
|
523
|
+
const whereAggMode = sumQuantity ? 'sum' : avgQuantity ? 'avg' : minQuantity ? 'min' : maxQuantity ? 'max' : undefined;
|
|
524
|
+
// When grouping, don't slice entities — pass limit as groupLimit instead
|
|
525
|
+
if (groupBy && whereAggQty) {
|
|
526
|
+
outputGroupBy(entities, groupBy, whereAggQty, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined, whereAggMode);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (groupBy) {
|
|
530
|
+
outputGroupBy(entities, groupBy, undefined, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// Aggregations operate on the full filtered set (no offset/limit)
|
|
534
|
+
if (sumQuantity) {
|
|
535
|
+
outputSum(entities, sumQuantity, bim, jsonOutput);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (avgQuantity) {
|
|
539
|
+
outputAggregation(entities, avgQuantity, 'avg', bim, jsonOutput);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (minQuantity) {
|
|
543
|
+
outputAggregation(entities, minQuantity, 'min', bim, jsonOutput);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (maxQuantity) {
|
|
547
|
+
outputAggregation(entities, maxQuantity, 'max', bim, jsonOutput);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// Apply offset/limit only for non-aggregation, non-group paths
|
|
551
|
+
if (offset)
|
|
552
|
+
entities = entities.slice(parseInt(offset, 10));
|
|
553
|
+
if (limit)
|
|
554
|
+
entities = entities.slice(0, parseInt(limit, 10));
|
|
555
|
+
if (countOnly) {
|
|
556
|
+
outputCount(entities.length, jsonOutput);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (sortBy) {
|
|
560
|
+
entities = sortEntities(entities, sortBy, descSort, bim);
|
|
561
|
+
}
|
|
562
|
+
outputEntities(entities, args, bim, jsonOutput);
|
|
563
|
+
return;
|
|
319
564
|
}
|
|
320
|
-
if (limit)
|
|
565
|
+
if (limit && !groupBy)
|
|
321
566
|
q = q.limit(parseInt(limit, 10));
|
|
322
567
|
if (offset)
|
|
323
568
|
q = q.offset(parseInt(offset, 10));
|
|
569
|
+
// B11: Validate --group-by key
|
|
570
|
+
if (groupBy) {
|
|
571
|
+
if (!VALID_GROUP_BY_KEYS.includes(groupBy) && !groupBy.includes('.')) {
|
|
572
|
+
fatal(`Unknown grouping "${groupBy}". Valid options: ${VALID_GROUP_BY_KEYS.join(', ')}, or PsetName.PropName`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Detect aggregation quantity and mode for --group-by combos
|
|
576
|
+
const aggQuantity = sumQuantity ?? avgQuantity ?? minQuantity ?? maxQuantity;
|
|
577
|
+
const aggMode = sumQuantity ? 'sum' : avgQuantity ? 'avg' : minQuantity ? 'min' : maxQuantity ? 'max' : undefined;
|
|
578
|
+
// --group-by + aggregation combo: aggregate per group
|
|
579
|
+
if (groupBy && aggQuantity) {
|
|
580
|
+
const entities = q.toArray();
|
|
581
|
+
// B12: pass limit to outputGroupBy to limit groups, not entities
|
|
582
|
+
outputGroupBy(entities, groupBy, aggQuantity, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined, aggMode);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
324
585
|
// --sum mode: aggregate a quantity across matched entities
|
|
325
586
|
if (sumQuantity) {
|
|
326
587
|
const entities = q.toArray();
|
|
327
588
|
outputSum(entities, sumQuantity, bim, jsonOutput);
|
|
328
589
|
return;
|
|
329
590
|
}
|
|
591
|
+
// B7/F2: --avg mode
|
|
592
|
+
if (avgQuantity) {
|
|
593
|
+
const entities = q.toArray();
|
|
594
|
+
outputAggregation(entities, avgQuantity, 'avg', bim, jsonOutput);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// B7/F2: --min mode
|
|
598
|
+
if (minQuantity) {
|
|
599
|
+
const entities = q.toArray();
|
|
600
|
+
outputAggregation(entities, minQuantity, 'min', bim, jsonOutput);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// B7/F2: --max mode
|
|
604
|
+
if (maxQuantity) {
|
|
605
|
+
const entities = q.toArray();
|
|
606
|
+
outputAggregation(entities, maxQuantity, 'max', bim, jsonOutput);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
330
609
|
// --group-by mode: pivot table grouped by a property or 'type'/'material'
|
|
331
610
|
if (groupBy) {
|
|
332
611
|
const entities = q.toArray();
|
|
333
|
-
outputGroupBy
|
|
612
|
+
// B12: pass limit to outputGroupBy to limit groups, not entities
|
|
613
|
+
outputGroupBy(entities, groupBy, undefined, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined);
|
|
334
614
|
return;
|
|
335
615
|
}
|
|
336
616
|
if (countOnly) {
|
|
@@ -338,7 +618,11 @@ export async function queryCommand(args) {
|
|
|
338
618
|
outputCount(count, jsonOutput);
|
|
339
619
|
return;
|
|
340
620
|
}
|
|
341
|
-
|
|
621
|
+
let entities = q.toArray();
|
|
622
|
+
// F7: --sort by quantity
|
|
623
|
+
if (sortBy) {
|
|
624
|
+
entities = sortEntities(entities, sortBy, descSort, bim);
|
|
625
|
+
}
|
|
342
626
|
outputEntities(entities, args, bim, jsonOutput);
|
|
343
627
|
}
|
|
344
628
|
function outputCount(count, jsonOutput) {
|
|
@@ -376,6 +660,33 @@ function outputSum(entities, quantityName, bim, jsonOutput) {
|
|
|
376
660
|
}
|
|
377
661
|
}
|
|
378
662
|
}
|
|
663
|
+
// B10: Better error when quantity not found
|
|
664
|
+
if (matched === 0 && entities.length > 0) {
|
|
665
|
+
const availableNames = new Set();
|
|
666
|
+
for (const [key] of allQuantityNames) {
|
|
667
|
+
availableNames.add(key.split('.').pop());
|
|
668
|
+
}
|
|
669
|
+
if (jsonOutput) {
|
|
670
|
+
printJson({
|
|
671
|
+
quantity: quantityName,
|
|
672
|
+
total: 0,
|
|
673
|
+
matchedEntities: 0,
|
|
674
|
+
totalEntities: entities.length,
|
|
675
|
+
error: `Quantity "${quantityName}" not found in any of the ${entities.length} entities.`,
|
|
676
|
+
availableQuantities: [...availableNames],
|
|
677
|
+
hint: 'Use --quantity-names --type <Type> to see all available quantities with details.',
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
process.stdout.write(`0\n`);
|
|
682
|
+
process.stderr.write(`Quantity "${quantityName}" not found in any of the ${entities.length} entities.\n`);
|
|
683
|
+
if (availableNames.size > 0) {
|
|
684
|
+
process.stderr.write(`Available quantities: ${[...availableNames].join(', ')}\n`);
|
|
685
|
+
}
|
|
686
|
+
process.stderr.write(`Use --quantity-names --type <Type> to see all available quantities.\n`);
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
379
690
|
// Check for ambiguous area/volume quantities and warn
|
|
380
691
|
const similarNames = [...allQuantityNames.entries()]
|
|
381
692
|
.filter(([key]) => {
|
|
@@ -421,16 +732,146 @@ function outputSum(entities, quantityName, bim, jsonOutput) {
|
|
|
421
732
|
}
|
|
422
733
|
}
|
|
423
734
|
}
|
|
424
|
-
|
|
735
|
+
/**
|
|
736
|
+
* B7/F2: --avg, --min, --max aggregation functions.
|
|
737
|
+
*/
|
|
738
|
+
function outputAggregation(entities, quantityName, mode, bim, jsonOutput) {
|
|
739
|
+
let total = 0;
|
|
740
|
+
let matched = 0;
|
|
741
|
+
let minVal = Infinity;
|
|
742
|
+
let maxVal = -Infinity;
|
|
743
|
+
let minEntity = null;
|
|
744
|
+
let maxEntity = null;
|
|
745
|
+
for (const e of entities) {
|
|
746
|
+
const val = getQuantityValue(bim, e.ref, quantityName);
|
|
747
|
+
if (val !== null) {
|
|
748
|
+
total += val;
|
|
749
|
+
matched++;
|
|
750
|
+
if (val < minVal) {
|
|
751
|
+
minVal = val;
|
|
752
|
+
minEntity = e;
|
|
753
|
+
}
|
|
754
|
+
if (val > maxVal) {
|
|
755
|
+
maxVal = val;
|
|
756
|
+
maxEntity = e;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// B10: quantity not found
|
|
761
|
+
if (matched === 0) {
|
|
762
|
+
if (jsonOutput) {
|
|
763
|
+
printJson({ quantity: quantityName, error: `Quantity "${quantityName}" not found.`, hint: 'Use --quantity-names --type <Type> to see available quantities.' });
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
process.stderr.write(`Quantity "${quantityName}" not found in any of the ${entities.length} entities.\n`);
|
|
767
|
+
process.stderr.write(`Use --quantity-names --type <Type> to see all available quantities.\n`);
|
|
768
|
+
}
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const avg = total / matched;
|
|
772
|
+
const label = mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
773
|
+
if (jsonOutput) {
|
|
774
|
+
const result = { quantity: quantityName, matchedEntities: matched, totalEntities: entities.length };
|
|
775
|
+
if (mode === 'avg') {
|
|
776
|
+
result.average = avg;
|
|
777
|
+
}
|
|
778
|
+
else if (mode === 'min') {
|
|
779
|
+
result.min = minVal;
|
|
780
|
+
if (minEntity)
|
|
781
|
+
result.entity = { Name: minEntity.name, Type: minEntity.type, GlobalId: minEntity.globalId };
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
result.max = maxVal;
|
|
785
|
+
if (maxEntity)
|
|
786
|
+
result.entity = { Name: maxEntity.name, Type: maxEntity.type, GlobalId: maxEntity.globalId };
|
|
787
|
+
}
|
|
788
|
+
printJson(result);
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
if (mode === 'avg') {
|
|
792
|
+
process.stdout.write(`${avg}\n`);
|
|
793
|
+
process.stderr.write(`${label} ${quantityName}: ${avg.toFixed(4)} (${matched} entities)\n`);
|
|
794
|
+
}
|
|
795
|
+
else if (mode === 'min') {
|
|
796
|
+
process.stdout.write(`${minVal}\n`);
|
|
797
|
+
process.stderr.write(`${label} ${quantityName}: ${minVal} (${minEntity?.name ?? 'unknown'})\n`);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
process.stdout.write(`${maxVal}\n`);
|
|
801
|
+
process.stderr.write(`${label} ${quantityName}: ${maxVal} (${maxEntity?.name ?? 'unknown'})\n`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* F7: Sort entities by quantity, attribute, or property value.
|
|
807
|
+
* Supports: quantity names, entity attributes (name/type/globalId), PsetName.PropName
|
|
808
|
+
*/
|
|
809
|
+
function sortEntities(entities, sortBy, descending, bim) {
|
|
810
|
+
const ATTR_KEYS = ['name', 'type', 'globalId', 'globalid', 'description', 'objectType', 'objecttype'];
|
|
811
|
+
const isAttr = ATTR_KEYS.includes(sortBy) || ATTR_KEYS.includes(sortBy.toLowerCase());
|
|
812
|
+
const isDotted = sortBy.includes('.');
|
|
813
|
+
return entities.slice().sort((a, b) => {
|
|
814
|
+
let valA;
|
|
815
|
+
let valB;
|
|
816
|
+
if (isAttr) {
|
|
817
|
+
// Sort by entity attribute (alphabetical)
|
|
818
|
+
const key = sortBy.toLowerCase() === 'globalid' ? 'globalId'
|
|
819
|
+
: sortBy.toLowerCase() === 'objecttype' ? 'objectType'
|
|
820
|
+
: sortBy.toLowerCase();
|
|
821
|
+
valA = a[key] ?? '';
|
|
822
|
+
valB = b[key] ?? '';
|
|
823
|
+
const cmp = String(valA).localeCompare(String(valB));
|
|
824
|
+
return descending ? -cmp : cmp;
|
|
825
|
+
}
|
|
826
|
+
else if (isDotted) {
|
|
827
|
+
// Sort by PsetName.PropName
|
|
828
|
+
const [psetName, propName] = sortBy.split('.', 2);
|
|
829
|
+
const getVal = (e) => {
|
|
830
|
+
const props = bim.properties(e.ref);
|
|
831
|
+
const pset = props.find((p) => p.name === psetName);
|
|
832
|
+
const prop = pset?.properties?.find((p) => p.name === propName);
|
|
833
|
+
if (prop?.value != null)
|
|
834
|
+
return prop.value;
|
|
835
|
+
// Also check quantity sets
|
|
836
|
+
const qsets = bim.quantities(e.ref);
|
|
837
|
+
const qset = qsets.find((q) => q.name === psetName);
|
|
838
|
+
const qty = qset?.quantities?.find((q) => q.name === propName);
|
|
839
|
+
return qty?.value ?? null;
|
|
840
|
+
};
|
|
841
|
+
valA = getVal(a);
|
|
842
|
+
valB = getVal(b);
|
|
843
|
+
if (typeof valA === 'number' && typeof valB === 'number') {
|
|
844
|
+
return descending ? valB - valA : valA - valB;
|
|
845
|
+
}
|
|
846
|
+
const cmp = String(valA ?? '').localeCompare(String(valB ?? ''));
|
|
847
|
+
return descending ? -cmp : cmp;
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
// Sort by quantity name (numeric)
|
|
851
|
+
valA = getQuantityValue(bim, a.ref, sortBy) ?? 0;
|
|
852
|
+
valB = getQuantityValue(bim, b.ref, sortBy) ?? 0;
|
|
853
|
+
return descending ? valB - valA : valA - valB;
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
function outputGroupBy(entities, groupByKey, sumQuantity, bim, jsonOutput, groupLimit, aggMode) {
|
|
858
|
+
// B11: Validate group-by key
|
|
859
|
+
if (!VALID_GROUP_BY_KEYS.includes(groupByKey) && !groupByKey.includes('.')) {
|
|
860
|
+
fatal(`Unknown grouping "${groupByKey}". Valid options: ${VALID_GROUP_BY_KEYS.join(', ')}, or PsetName.PropName`);
|
|
861
|
+
}
|
|
425
862
|
const groups = new Map();
|
|
426
863
|
for (const e of entities) {
|
|
427
864
|
let groupValue;
|
|
428
865
|
if (groupByKey === 'type') {
|
|
429
866
|
groupValue = e.type;
|
|
430
867
|
}
|
|
868
|
+
else if (groupByKey === 'storey') {
|
|
869
|
+
const storey = bim.storey(e.ref);
|
|
870
|
+
groupValue = storey?.name ?? '(no storey)';
|
|
871
|
+
}
|
|
431
872
|
else if (groupByKey === 'material') {
|
|
432
873
|
const mat = bim.materials(e.ref);
|
|
433
|
-
groupValue = mat?.materials?.[0]
|
|
874
|
+
groupValue = mat?.materials?.[0] ?? mat?.name ?? '(no material)';
|
|
434
875
|
}
|
|
435
876
|
else if (groupByKey.includes('.')) {
|
|
436
877
|
// PsetName.PropName
|
|
@@ -451,20 +892,75 @@ function outputGroupBy(entities, groupByKey, _sumQuantity, bim, jsonOutput) {
|
|
|
451
892
|
groups.set(groupValue, [e]);
|
|
452
893
|
}
|
|
453
894
|
}
|
|
895
|
+
// Compute per-group aggregation if a quantity is specified alongside --group-by
|
|
896
|
+
const mode = aggMode ?? 'sum';
|
|
897
|
+
const groupAgg = new Map();
|
|
898
|
+
if (sumQuantity) {
|
|
899
|
+
for (const [key, groupEntities] of groups) {
|
|
900
|
+
let sum = 0;
|
|
901
|
+
let count = 0;
|
|
902
|
+
let minVal = Infinity;
|
|
903
|
+
let maxVal = -Infinity;
|
|
904
|
+
for (const e of groupEntities) {
|
|
905
|
+
const val = getQuantityValue(bim, e.ref, sumQuantity);
|
|
906
|
+
if (val !== null) {
|
|
907
|
+
sum += val;
|
|
908
|
+
count++;
|
|
909
|
+
if (val < minVal)
|
|
910
|
+
minVal = val;
|
|
911
|
+
if (val > maxVal)
|
|
912
|
+
maxVal = val;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (mode === 'avg')
|
|
916
|
+
groupAgg.set(key, count > 0 ? sum / count : 0);
|
|
917
|
+
else if (mode === 'min')
|
|
918
|
+
groupAgg.set(key, count > 0 ? minVal : 0);
|
|
919
|
+
else if (mode === 'max')
|
|
920
|
+
groupAgg.set(key, count > 0 ? maxVal : 0);
|
|
921
|
+
else
|
|
922
|
+
groupAgg.set(key, sum);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const modeLabel = mode === 'sum' ? 'sum' : mode;
|
|
454
926
|
if (jsonOutput) {
|
|
455
927
|
const result = {};
|
|
456
|
-
|
|
457
|
-
|
|
928
|
+
let entries = [...groups.entries()];
|
|
929
|
+
// B12: --limit limits groups, not entities
|
|
930
|
+
if (groupLimit)
|
|
931
|
+
entries = entries.slice(0, groupLimit);
|
|
932
|
+
for (const [key, groupEntities] of entries) {
|
|
933
|
+
const entry = { count: groupEntities.length };
|
|
934
|
+
if (sumQuantity)
|
|
935
|
+
entry[sumQuantity] = groupAgg.get(key) ?? 0;
|
|
936
|
+
if (sumQuantity && mode !== 'sum')
|
|
937
|
+
entry.aggregation = mode;
|
|
938
|
+
result[key] = entry;
|
|
458
939
|
}
|
|
459
940
|
printJson(result);
|
|
460
941
|
}
|
|
461
942
|
else {
|
|
462
|
-
|
|
463
|
-
|
|
943
|
+
let sorted = [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
944
|
+
// B12: --limit limits groups, not entities
|
|
945
|
+
if (groupLimit)
|
|
946
|
+
sorted = sorted.slice(0, groupLimit);
|
|
947
|
+
process.stdout.write(`\nGrouped by ${groupByKey}${sumQuantity ? ` (${modeLabel}: ${sumQuantity})` : ''}:\n\n`);
|
|
464
948
|
for (const [key, groupEntities] of sorted) {
|
|
465
|
-
|
|
949
|
+
if (sumQuantity) {
|
|
950
|
+
const agg = groupAgg.get(key) ?? 0;
|
|
951
|
+
process.stdout.write(` ${key}: ${groupEntities.length} elements, ${sumQuantity} ${modeLabel}: ${mode === 'avg' ? agg.toFixed(4) : agg}\n`);
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
process.stdout.write(` ${key}: ${groupEntities.length}\n`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (sumQuantity) {
|
|
958
|
+
const grandTotal = [...groupAgg.values()].reduce((a, b) => a + b, 0);
|
|
959
|
+
process.stdout.write(`\n Total: ${entities.length} entities in ${groups.size} groups\n\n`);
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
process.stdout.write(`\n Total: ${entities.length} entities in ${groups.size} groups\n\n`);
|
|
466
963
|
}
|
|
467
|
-
process.stdout.write(`\n Total: ${entities.length} entities in ${groups.size} groups\n\n`);
|
|
468
964
|
}
|
|
469
965
|
}
|
|
470
966
|
function outputEntities(entities, args, bim, jsonOutput) {
|