@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.
@@ -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
- const type = getFlag(args, '--type');
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
- const storeyEntities = baseEntities.filter(e => storeyIds.has(e.ref.expressId));
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
- // Re-apply via manual filtering since we've already resolved entities
281
- const finalEntities = storeyEntities.filter(e => {
282
- const props = bim.properties(e.ref);
283
- const pset = props.find(p => p.name === parsed.psetName);
284
- if (!pset)
285
- return false;
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 with proper syntax validation
516
+ // --where filter: search both property sets and quantity sets (B3)
316
517
  if (propFilter) {
317
518
  const parsed = parseWhereFilter(propFilter);
318
- q = q.where(parsed.psetName, parsed.propName, parsed.operator, parsed.value);
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(entities, groupBy, sumQuantity, bim, jsonOutput);
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
- const entities = q.toArray();
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
- function outputGroupBy(entities, groupByKey, _sumQuantity, bim, jsonOutput) {
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]?.name ?? mat?.name ?? '(no material)';
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
- for (const [key, groupEntities] of groups) {
457
- result[key] = { count: groupEntities.length };
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
- const sorted = [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
463
- process.stdout.write(`\nGrouped by ${groupByKey}:\n\n`);
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
- process.stdout.write(` ${key}: ${groupEntities.length}\n`);
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) {