@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.
@@ -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
- 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');
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 (PsetName.PropName)
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 PsetName.PropName)');
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
- for (const e of entities) {
268
- const psets = bim.properties(e.ref);
269
- const pset = psets.find((p) => p.name === psetName);
270
- const prop = pset?.properties?.find((p) => p.name === propName);
271
- const val = prop?.value != null ? String(prop.value) : '(no value)';
272
- valueCounts.set(val, (valueCounts.get(val) ?? 0) + 1);
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
- const storeyEntities = baseEntities.filter(e => storeyIds.has(e.ref.expressId));
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
- // Re-apply via manual filtering since we've already resolved entities
372
- const finalEntities = storeyEntities.filter(e => {
373
- const props = bim.properties(e.ref);
374
- const pset = props.find(p => p.name === parsed.psetName);
375
- if (!pset)
376
- return false;
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 with proper syntax validation
516
+ // --where filter: search both property sets and quantity sets (B3)
407
517
  if (propFilter) {
408
518
  const parsed = parseWhereFilter(propFilter);
409
- 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;
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 + --sum combo: aggregate per group
416
- if (groupBy && sumQuantity) {
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(entities, groupBy, sumQuantity, bim, jsonOutput);
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(entities, groupBy, undefined, 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);
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
- 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
+ }
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
- 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
+ }
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]?.name ?? mat?.name ?? '(no material)';
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 sum if --sum is specified alongside --group-by
556
- const groupSums = new Map();
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 qsets = bim.quantities(e.ref);
562
- for (const qset of qsets) {
563
- for (const q of qset.quantities) {
564
- if (q.name === sumQuantity)
565
- sum += Number(q.value) || 0;
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
- groupSums.set(key, sum);
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
- for (const [key, groupEntities] of groups) {
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] = groupSums.get(key) ?? 0;
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
- const sorted = [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
584
- process.stdout.write(`\nGrouped by ${groupByKey}${sumQuantity ? ` (sum: ${sumQuantity})` : ''}:\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`);
585
948
  for (const [key, groupEntities] of sorted) {
586
949
  if (sumQuantity) {
587
- const sum = groupSums.get(key) ?? 0;
588
- process.stdout.write(` ${key}: ${groupEntities.length} elements, ${sumQuantity}: ${sum}\n`);
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 = [...groupSums.values()].reduce((a, b) => a + b, 0);
596
- process.stdout.write(`\n Total: ${entities.length} entities in ${groups.size} groups, ${sumQuantity}: ${grandTotal}\n\n`);
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`);