@ifc-lite/cli 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ask.d.ts +2 -0
- package/dist/commands/ask.d.ts.map +1 -0
- package/dist/commands/ask.js +524 -0
- package/dist/commands/ask.js.map +1 -0
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +120 -27
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +149 -11
- package/dist/commands/export.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 +353 -0
- package/dist/commands/mutate.js.map +1 -0
- package/dist/commands/query.d.ts.map +1 -1
- package/dist/commands/query.js +446 -79
- 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 +270 -0
- package/dist/commands/stats.js.map +1 -0
- package/dist/index.js +35 -1
- package/dist/index.js.map +1 -1
- package/dist/output.d.ts +4 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +13 -0
- package/dist/output.js.map +1 -1
- package/package.json +6 -5
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,33 +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');
|
|
116
197
|
const propertyNames = hasFlag(args, '--property-names');
|
|
117
198
|
const uniqueProp = getFlag(args, '--unique');
|
|
118
199
|
const groupBy = getFlag(args, '--group-by');
|
|
119
200
|
const spatialSummary = hasFlag(args, '--summary');
|
|
201
|
+
// B9/F6: Auto-prefix Ifc for --type
|
|
202
|
+
if (type) {
|
|
203
|
+
type = normalizeTypeName(type);
|
|
204
|
+
}
|
|
120
205
|
const { bim } = await createHeadlessContext(filePath);
|
|
121
206
|
// --quantity-names: list available quantities per entity type
|
|
122
207
|
if (quantityNames) {
|
|
@@ -252,24 +337,46 @@ export async function queryCommand(args) {
|
|
|
252
337
|
}
|
|
253
338
|
return;
|
|
254
339
|
}
|
|
255
|
-
// --unique: distinct values for a property path
|
|
340
|
+
// B6/F8: --unique: distinct values for a property path, material, or storey
|
|
256
341
|
if (uniqueProp) {
|
|
257
342
|
const targetType = type;
|
|
258
343
|
if (!targetType)
|
|
259
|
-
fatal('--unique requires --type (e.g., --type IfcWall --unique
|
|
260
|
-
const dotIdx = uniqueProp.indexOf('.');
|
|
261
|
-
if (dotIdx <= 0)
|
|
262
|
-
fatal(`Invalid --unique path: "${uniqueProp}". Expected: PsetName.PropName`);
|
|
263
|
-
const psetName = uniqueProp.slice(0, dotIdx);
|
|
264
|
-
const propName = uniqueProp.slice(dotIdx + 1);
|
|
344
|
+
fatal('--unique requires --type (e.g., --type IfcWall --unique material)');
|
|
265
345
|
const entities = bim.query().byType(...targetType.split(',')).toArray();
|
|
266
346
|
const valueCounts = new Map();
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
|
273
380
|
}
|
|
274
381
|
if (jsonOutput) {
|
|
275
382
|
const result = {};
|
|
@@ -293,7 +400,7 @@ export async function queryCommand(args) {
|
|
|
293
400
|
if (storeys.length > 0) {
|
|
294
401
|
for (const storey of storeys) {
|
|
295
402
|
const contained = bim.contains(storey.ref);
|
|
296
|
-
tree[storey.name || `Storey #${storey.ref.expressId}`] = contained.map(e => ({
|
|
403
|
+
tree[storey.name || `Storey #${storey.ref.expressId}`] = contained.map((e) => ({
|
|
297
404
|
type: e.type,
|
|
298
405
|
name: e.name,
|
|
299
406
|
globalId: e.globalId,
|
|
@@ -305,7 +412,7 @@ export async function queryCommand(args) {
|
|
|
305
412
|
const buildings = bim.query().byType('IfcBuilding').toArray();
|
|
306
413
|
for (const building of buildings) {
|
|
307
414
|
const contained = bim.contains(building.ref);
|
|
308
|
-
tree[building.name || `Building #${building.ref.expressId}`] = contained.map(e => ({
|
|
415
|
+
tree[building.name || `Building #${building.ref.expressId}`] = contained.map((e) => ({
|
|
309
416
|
type: e.type,
|
|
310
417
|
name: e.name,
|
|
311
418
|
globalId: e.globalId,
|
|
@@ -353,69 +460,126 @@ export async function queryCommand(args) {
|
|
|
353
460
|
// --storey filter: restrict to entities in a specific storey
|
|
354
461
|
if (storeyFilter) {
|
|
355
462
|
const storeys = bim.storeys();
|
|
356
|
-
const matchedStorey = storeys.find(s => s.name === storeyFilter ||
|
|
463
|
+
const matchedStorey = storeys.find((s) => s.name === storeyFilter ||
|
|
357
464
|
s.name.toLowerCase().includes(storeyFilter.toLowerCase()) ||
|
|
358
465
|
String(s.ref.expressId) === storeyFilter);
|
|
359
466
|
if (!matchedStorey) {
|
|
360
|
-
const names = storeys.map(s => s.name).filter(Boolean).join(', ');
|
|
467
|
+
const names = storeys.map((s) => s.name).filter(Boolean).join(', ');
|
|
361
468
|
fatal(`Storey "${storeyFilter}" not found. Available: ${names || '(none)'}`);
|
|
362
469
|
}
|
|
363
470
|
const contained = bim.contains(matchedStorey.ref);
|
|
364
|
-
const storeyIds = new Set(contained.map(e => e.ref.expressId));
|
|
471
|
+
const storeyIds = new Set(contained.map((e) => e.ref.expressId));
|
|
365
472
|
// Post-filter: only keep entities that are in this storey
|
|
366
473
|
const baseEntities = q.toArray();
|
|
367
|
-
|
|
368
|
-
// 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)
|
|
369
476
|
if (propFilter) {
|
|
370
477
|
const parsed = parseWhereFilter(propFilter);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const prop = pset.properties.find((p) => p.name === parsed.propName);
|
|
378
|
-
if (!prop)
|
|
379
|
-
return false;
|
|
380
|
-
if (parsed.operator === 'exists')
|
|
381
|
-
return true;
|
|
382
|
-
return String(prop.value) === String(parsed.value);
|
|
383
|
-
});
|
|
384
|
-
if (sumQuantity) {
|
|
385
|
-
outputSum(finalEntities, sumQuantity, bim, jsonOutput);
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (countOnly) {
|
|
389
|
-
outputCount(finalEntities.length, jsonOutput);
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
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);
|
|
393
484
|
return;
|
|
394
485
|
}
|
|
395
486
|
if (sumQuantity) {
|
|
396
487
|
outputSum(storeyEntities, sumQuantity, bim, jsonOutput);
|
|
397
488
|
return;
|
|
398
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
|
+
}
|
|
399
506
|
if (countOnly) {
|
|
400
507
|
outputCount(storeyEntities.length, jsonOutput);
|
|
401
508
|
return;
|
|
402
509
|
}
|
|
510
|
+
if (sortBy) {
|
|
511
|
+
storeyEntities = sortEntities(storeyEntities, sortBy, descSort, bim);
|
|
512
|
+
}
|
|
403
513
|
outputEntities(storeyEntities, args, bim, jsonOutput);
|
|
404
514
|
return;
|
|
405
515
|
}
|
|
406
|
-
// --where filter
|
|
516
|
+
// --where filter: search both property sets and quantity sets (B3)
|
|
407
517
|
if (propFilter) {
|
|
408
518
|
const parsed = parseWhereFilter(propFilter);
|
|
409
|
-
|
|
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;
|
|
410
564
|
}
|
|
411
|
-
if (limit)
|
|
565
|
+
if (limit && !groupBy)
|
|
412
566
|
q = q.limit(parseInt(limit, 10));
|
|
413
567
|
if (offset)
|
|
414
568
|
q = q.offset(parseInt(offset, 10));
|
|
415
|
-
// --group-by
|
|
416
|
-
if (groupBy
|
|
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) {
|
|
417
580
|
const entities = q.toArray();
|
|
418
|
-
outputGroupBy
|
|
581
|
+
// B12: pass limit to outputGroupBy to limit groups, not entities
|
|
582
|
+
outputGroupBy(entities, groupBy, aggQuantity, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined, aggMode);
|
|
419
583
|
return;
|
|
420
584
|
}
|
|
421
585
|
// --sum mode: aggregate a quantity across matched entities
|
|
@@ -424,10 +588,29 @@ export async function queryCommand(args) {
|
|
|
424
588
|
outputSum(entities, sumQuantity, bim, jsonOutput);
|
|
425
589
|
return;
|
|
426
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
|
+
}
|
|
427
609
|
// --group-by mode: pivot table grouped by a property or 'type'/'material'
|
|
428
610
|
if (groupBy) {
|
|
429
611
|
const entities = q.toArray();
|
|
430
|
-
outputGroupBy
|
|
612
|
+
// B12: pass limit to outputGroupBy to limit groups, not entities
|
|
613
|
+
outputGroupBy(entities, groupBy, undefined, bim, jsonOutput, limit ? parseInt(limit, 10) : undefined);
|
|
431
614
|
return;
|
|
432
615
|
}
|
|
433
616
|
if (countOnly) {
|
|
@@ -435,7 +618,11 @@ export async function queryCommand(args) {
|
|
|
435
618
|
outputCount(count, jsonOutput);
|
|
436
619
|
return;
|
|
437
620
|
}
|
|
438
|
-
|
|
621
|
+
let entities = q.toArray();
|
|
622
|
+
// F7: --sort by quantity
|
|
623
|
+
if (sortBy) {
|
|
624
|
+
entities = sortEntities(entities, sortBy, descSort, bim);
|
|
625
|
+
}
|
|
439
626
|
outputEntities(entities, args, bim, jsonOutput);
|
|
440
627
|
}
|
|
441
628
|
function outputCount(count, jsonOutput) {
|
|
@@ -473,6 +660,33 @@ function outputSum(entities, quantityName, bim, jsonOutput) {
|
|
|
473
660
|
}
|
|
474
661
|
}
|
|
475
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
|
+
}
|
|
476
690
|
// Check for ambiguous area/volume quantities and warn
|
|
477
691
|
const similarNames = [...allQuantityNames.entries()]
|
|
478
692
|
.filter(([key]) => {
|
|
@@ -518,7 +732,133 @@ function outputSum(entities, quantityName, bim, jsonOutput) {
|
|
|
518
732
|
}
|
|
519
733
|
}
|
|
520
734
|
}
|
|
521
|
-
|
|
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
|
+
}
|
|
522
862
|
const groups = new Map();
|
|
523
863
|
for (const e of entities) {
|
|
524
864
|
let groupValue;
|
|
@@ -531,7 +871,7 @@ function outputGroupBy(entities, groupByKey, sumQuantity, bim, jsonOutput) {
|
|
|
531
871
|
}
|
|
532
872
|
else if (groupByKey === 'material') {
|
|
533
873
|
const mat = bim.materials(e.ref);
|
|
534
|
-
groupValue = mat?.materials?.[0]
|
|
874
|
+
groupValue = mat?.materials?.[0] ?? mat?.name ?? '(no material)';
|
|
535
875
|
}
|
|
536
876
|
else if (groupByKey.includes('.')) {
|
|
537
877
|
// PsetName.PropName
|
|
@@ -552,48 +892,75 @@ function outputGroupBy(entities, groupByKey, sumQuantity, bim, jsonOutput) {
|
|
|
552
892
|
groups.set(groupValue, [e]);
|
|
553
893
|
}
|
|
554
894
|
}
|
|
555
|
-
// Compute per-group
|
|
556
|
-
const
|
|
895
|
+
// Compute per-group aggregation if a quantity is specified alongside --group-by
|
|
896
|
+
const mode = aggMode ?? 'sum';
|
|
897
|
+
const groupAgg = new Map();
|
|
557
898
|
if (sumQuantity) {
|
|
558
899
|
for (const [key, groupEntities] of groups) {
|
|
559
900
|
let sum = 0;
|
|
901
|
+
let count = 0;
|
|
902
|
+
let minVal = Infinity;
|
|
903
|
+
let maxVal = -Infinity;
|
|
560
904
|
for (const e of groupEntities) {
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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;
|
|
567
913
|
}
|
|
568
914
|
}
|
|
569
|
-
|
|
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);
|
|
570
923
|
}
|
|
571
924
|
}
|
|
925
|
+
const modeLabel = mode === 'sum' ? 'sum' : mode;
|
|
572
926
|
if (jsonOutput) {
|
|
573
927
|
const result = {};
|
|
574
|
-
|
|
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) {
|
|
575
933
|
const entry = { count: groupEntities.length };
|
|
576
934
|
if (sumQuantity)
|
|
577
|
-
entry[sumQuantity] =
|
|
935
|
+
entry[sumQuantity] = groupAgg.get(key) ?? 0;
|
|
936
|
+
if (sumQuantity && mode !== 'sum')
|
|
937
|
+
entry.aggregation = mode;
|
|
578
938
|
result[key] = entry;
|
|
579
939
|
}
|
|
580
940
|
printJson(result);
|
|
581
941
|
}
|
|
582
942
|
else {
|
|
583
|
-
|
|
584
|
-
|
|
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`);
|
|
585
948
|
for (const [key, groupEntities] of sorted) {
|
|
586
949
|
if (sumQuantity) {
|
|
587
|
-
const
|
|
588
|
-
process.stdout.write(` ${key}: ${groupEntities.length} elements, ${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`);
|
|
589
952
|
}
|
|
590
953
|
else {
|
|
591
954
|
process.stdout.write(` ${key}: ${groupEntities.length}\n`);
|
|
592
955
|
}
|
|
593
956
|
}
|
|
594
957
|
if (sumQuantity) {
|
|
595
|
-
const grandTotal = [...
|
|
596
|
-
process.stdout.write(`\n Total: ${entities.length} entities in ${groups.size} groups
|
|
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`);
|
|
960
|
+
if (grandTotal === 0 && entities.length > 0) {
|
|
961
|
+
process.stderr.write(`\n Warning: All ${sumQuantity} values are 0. The file may not contain quantity data for this property.\n`);
|
|
962
|
+
}
|
|
963
|
+
process.stdout.write('\n');
|
|
597
964
|
}
|
|
598
965
|
else {
|
|
599
966
|
process.stdout.write(`\n Total: ${entities.length} entities in ${groups.size} groups\n\n`);
|