@ifc-lite/cli 0.2.0 → 0.4.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.
@@ -10,6 +10,86 @@
10
10
  */
11
11
  import { createHeadlessContext } from '../loader.js';
12
12
  import { printJson, formatTable, getFlag, hasFlag, fatal } from '../output.js';
13
+ /**
14
+ * Standard IFC quantity set definitions — maps entity type to its standard Qto_ sets
15
+ * and the quantities within each set. Used for disambiguation warnings.
16
+ */
17
+ const STANDARD_QTO_MAP = {
18
+ IfcWall: {
19
+ Qto_WallBaseQuantities: ['Length', 'Width', 'Height', 'GrossFootprintArea', 'NetFootprintArea', 'GrossSideArea', 'NetSideArea', 'GrossVolume', 'NetVolume'],
20
+ },
21
+ IfcSlab: {
22
+ Qto_SlabBaseQuantities: ['Width', 'Length', 'Depth', 'Perimeter', 'GrossArea', 'NetArea', 'GrossVolume', 'NetVolume'],
23
+ },
24
+ IfcDoor: {
25
+ Qto_DoorBaseQuantities: ['Width', 'Height', 'Perimeter', 'Area'],
26
+ },
27
+ IfcWindow: {
28
+ Qto_WindowBaseQuantities: ['Width', 'Height', 'Perimeter', 'Area'],
29
+ },
30
+ IfcColumn: {
31
+ Qto_ColumnBaseQuantities: ['Length', 'CrossSectionArea', 'OuterSurfaceArea', 'GrossSurfaceArea', 'NetSurfaceArea', 'GrossVolume', 'NetVolume', 'GrossWeight', 'NetWeight'],
32
+ },
33
+ IfcBeam: {
34
+ Qto_BeamBaseQuantities: ['Length', 'CrossSectionArea', 'OuterSurfaceArea', 'GrossSurfaceArea', 'NetSurfaceArea', 'GrossVolume', 'NetVolume', 'GrossWeight', 'NetWeight'],
35
+ },
36
+ IfcSpace: {
37
+ Qto_SpaceBaseQuantities: ['Height', 'FinishCeilingHeight', 'FinishFloorHeight', 'GrossPerimeter', 'NetPerimeter', 'GrossFloorArea', 'NetFloorArea', 'GrossWallArea', 'NetWallArea', 'GrossCeilingArea', 'NetCeilingArea', 'GrossVolume', 'NetVolume'],
38
+ },
39
+ IfcRoof: {
40
+ Qto_RoofBaseQuantities: ['GrossArea', 'NetArea', 'ProjectedArea'],
41
+ },
42
+ IfcStair: {
43
+ Qto_StairBaseQuantities: ['Length', 'GrossVolume', 'NetVolume'],
44
+ },
45
+ IfcRailing: {
46
+ Qto_RailingBaseQuantities: ['Length'],
47
+ },
48
+ IfcMember: {
49
+ Qto_MemberBaseQuantities: ['Length', 'CrossSectionArea', 'OuterSurfaceArea', 'GrossSurfaceArea', 'NetSurfaceArea', 'GrossVolume', 'NetVolume', 'GrossWeight', 'NetWeight'],
50
+ },
51
+ IfcPlate: {
52
+ Qto_PlateBaseQuantities: ['Width', 'Length', 'Perimeter', 'GrossArea', 'NetArea', 'GrossVolume', 'NetVolume', 'GrossWeight', 'NetWeight'],
53
+ },
54
+ IfcCovering: {
55
+ Qto_CoveringBaseQuantities: ['Width', 'Length', 'GrossArea', 'NetArea'],
56
+ },
57
+ IfcFooting: {
58
+ Qto_FootingBaseQuantities: ['Length', 'Width', 'Height', 'CrossSectionArea', 'OuterSurfaceArea', 'GrossVolume', 'NetVolume', 'GrossWeight', 'NetWeight'],
59
+ },
60
+ };
61
+ /**
62
+ * Parse a --where filter string into psetName, propName, operator, value.
63
+ * Supported formats:
64
+ * PsetName.PropName=Value (equals)
65
+ * PsetName.PropName!=Value (not equals)
66
+ * PsetName.PropName>Value (greater than)
67
+ * PsetName.PropName<Value (less than)
68
+ * PsetName.PropName>=Value (greater or equal)
69
+ * PsetName.PropName<=Value (less or equal)
70
+ * PsetName.PropName~Value (contains)
71
+ * PsetName.PropName (exists)
72
+ */
73
+ function parseWhereFilter(filter) {
74
+ const dotIdx = filter.indexOf('.');
75
+ if (dotIdx <= 0) {
76
+ fatal(`Invalid --where syntax: "${filter}". Expected: PsetName.PropName[=Value]`);
77
+ }
78
+ const psetName = filter.slice(0, dotIdx);
79
+ const rest = filter.slice(dotIdx + 1);
80
+ // Try multi-char operators first, then single-char
81
+ for (const op of ['!=', '>=', '<=', '>', '<', '=', '~']) {
82
+ const opIdx = rest.indexOf(op);
83
+ if (opIdx > 0) {
84
+ const propName = rest.slice(0, opIdx);
85
+ const value = rest.slice(opIdx + op.length);
86
+ const mappedOp = op === '~' ? 'contains' : op;
87
+ return { psetName, propName, operator: mappedOp, value };
88
+ }
89
+ }
90
+ // No operator found — exists check
91
+ return { psetName, propName: rest, operator: 'exists' };
92
+ }
13
93
  export async function queryCommand(args) {
14
94
  const filePath = args.find(a => !a.startsWith('-'));
15
95
  if (!filePath)
@@ -30,18 +110,236 @@ export async function queryCommand(args) {
30
110
  const jsonOutput = hasFlag(args, '--json');
31
111
  const countOnly = hasFlag(args, '--count');
32
112
  const spatial = hasFlag(args, '--spatial');
113
+ const sumQuantity = getFlag(args, '--sum');
114
+ const storeyFilter = getFlag(args, '--storey');
115
+ const quantityNames = hasFlag(args, '--quantity-names');
116
+ const propertyNames = hasFlag(args, '--property-names');
117
+ const uniqueProp = getFlag(args, '--unique');
118
+ const groupBy = getFlag(args, '--group-by');
119
+ const spatialSummary = hasFlag(args, '--summary');
33
120
  const { bim } = await createHeadlessContext(filePath);
121
+ // --quantity-names: list available quantities per entity type
122
+ if (quantityNames) {
123
+ const targetType = type;
124
+ if (!targetType)
125
+ fatal('--quantity-names requires --type (e.g., --type IfcWall --quantity-names)');
126
+ const entities = bim.query().byType(...targetType.split(',')).limit(50).toArray();
127
+ // Collect all quantity names seen, grouped by qset
128
+ const qsetMap = {};
129
+ for (const e of entities) {
130
+ const qsets = bim.quantities(e.ref);
131
+ for (const qset of qsets) {
132
+ if (!qsetMap[qset.name])
133
+ qsetMap[qset.name] = new Map();
134
+ const qmap = qsetMap[qset.name];
135
+ for (const q of qset.quantities) {
136
+ const existing = qmap.get(q.name);
137
+ const numVal = Number(q.value) || 0;
138
+ if (existing) {
139
+ existing.count++;
140
+ if (existing.sampleValues.length < 3)
141
+ existing.sampleValues.push(numVal);
142
+ }
143
+ else {
144
+ qmap.set(q.name, { count: 1, sampleValues: [numVal] });
145
+ }
146
+ }
147
+ }
148
+ }
149
+ if (jsonOutput) {
150
+ const result = {};
151
+ for (const [qsetName, qmap] of Object.entries(qsetMap)) {
152
+ result[qsetName] = {};
153
+ for (const [qName, info] of qmap) {
154
+ result[qsetName][qName] = {
155
+ foundIn: `${info.count}/${entities.length} entities`,
156
+ sampleValues: info.sampleValues,
157
+ fullReference: `${qsetName}.${qName}`,
158
+ };
159
+ }
160
+ }
161
+ // Add standard reference if available
162
+ const stdRef = STANDARD_QTO_MAP[targetType];
163
+ if (stdRef) {
164
+ printJson({ availableQuantities: result, standardReference: stdRef, note: 'Use --sum <QuantityName> to aggregate. Use full QsetName.QuantityName for unambiguous reference.' });
165
+ }
166
+ else {
167
+ printJson({ availableQuantities: result, note: 'Use --sum <QuantityName> to aggregate.' });
168
+ }
169
+ }
170
+ else {
171
+ process.stdout.write(`\nQuantities available for ${targetType} (sampled ${entities.length} entities):\n\n`);
172
+ for (const [qsetName, qmap] of Object.entries(qsetMap)) {
173
+ process.stdout.write(` ${qsetName}:\n`);
174
+ for (const [qName, info] of qmap) {
175
+ const samples = info.sampleValues.map(v => v.toFixed(2)).join(', ');
176
+ process.stdout.write(` ${qName} (${info.count}/${entities.length} entities) samples: [${samples}]\n`);
177
+ }
178
+ process.stdout.write('\n');
179
+ }
180
+ // Warn about ambiguity
181
+ const allNames = new Map();
182
+ for (const [qsetName, qmap] of Object.entries(qsetMap)) {
183
+ for (const qName of qmap.keys()) {
184
+ const sets = allNames.get(qName) ?? [];
185
+ sets.push(qsetName);
186
+ allNames.set(qName, sets);
187
+ }
188
+ }
189
+ const areaNames = [...allNames.entries()].filter(([name]) => name.toLowerCase().includes('area') || name.toLowerCase().includes('surface'));
190
+ if (areaNames.length > 1) {
191
+ process.stderr.write(`WARNING: Multiple area quantities found. Choose carefully:\n`);
192
+ for (const [name, sets] of areaNames) {
193
+ process.stderr.write(` - ${name} (in ${sets.join(', ')})\n`);
194
+ }
195
+ process.stderr.write(` Use --sum <exact-name> with the correct quantity for your analysis.\n\n`);
196
+ }
197
+ }
198
+ return;
199
+ }
200
+ // --property-names: list available properties per entity type
201
+ if (propertyNames) {
202
+ const targetType = type;
203
+ if (!targetType)
204
+ fatal('--property-names requires --type (e.g., --type IfcWall --property-names)');
205
+ const entities = bim.query().byType(...targetType.split(',')).limit(50).toArray();
206
+ const psetMap = {};
207
+ for (const e of entities) {
208
+ const psets = bim.properties(e.ref);
209
+ for (const pset of psets) {
210
+ if (!psetMap[pset.name])
211
+ psetMap[pset.name] = new Map();
212
+ const pmap = psetMap[pset.name];
213
+ for (const p of pset.properties) {
214
+ const existing = pmap.get(p.name);
215
+ const strVal = p.value != null ? String(p.value) : '';
216
+ if (existing) {
217
+ existing.count++;
218
+ if (existing.sampleValues.length < 3 && strVal && !existing.sampleValues.includes(strVal)) {
219
+ existing.sampleValues.push(strVal);
220
+ }
221
+ }
222
+ else {
223
+ pmap.set(p.name, { count: 1, sampleValues: strVal ? [strVal] : [] });
224
+ }
225
+ }
226
+ }
227
+ }
228
+ if (jsonOutput) {
229
+ const result = {};
230
+ for (const [psetName, pmap] of Object.entries(psetMap)) {
231
+ result[psetName] = {};
232
+ for (const [propName, info] of pmap) {
233
+ result[psetName][propName] = {
234
+ foundIn: `${info.count}/${entities.length} entities`,
235
+ sampleValues: info.sampleValues,
236
+ filterPath: `${psetName}.${propName}`,
237
+ };
238
+ }
239
+ }
240
+ printJson({ availableProperties: result, note: 'Use --where PsetName.PropName=Value to filter.' });
241
+ }
242
+ else {
243
+ process.stdout.write(`\nProperties available for ${targetType} (sampled ${entities.length} entities):\n\n`);
244
+ for (const [psetName, pmap] of Object.entries(psetMap)) {
245
+ process.stdout.write(` ${psetName}:\n`);
246
+ for (const [propName, info] of pmap) {
247
+ const samples = info.sampleValues.length > 0 ? ` samples: [${info.sampleValues.map(v => `"${v}"`).join(', ')}]` : '';
248
+ process.stdout.write(` ${propName} (${info.count}/${entities.length} entities)${samples}\n`);
249
+ }
250
+ process.stdout.write('\n');
251
+ }
252
+ }
253
+ return;
254
+ }
255
+ // --unique: distinct values for a property path (PsetName.PropName)
256
+ if (uniqueProp) {
257
+ const targetType = type;
258
+ 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);
265
+ const entities = bim.query().byType(...targetType.split(',')).toArray();
266
+ 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);
273
+ }
274
+ if (jsonOutput) {
275
+ const result = {};
276
+ for (const [val, count] of valueCounts)
277
+ result[val] = count;
278
+ printJson({ property: uniqueProp, distinctValues: result, totalEntities: entities.length });
279
+ }
280
+ else {
281
+ const sorted = [...valueCounts.entries()].sort((a, b) => b[1] - a[1]);
282
+ for (const [val, count] of sorted) {
283
+ process.stdout.write(`${val} (${count})\n`);
284
+ }
285
+ process.stderr.write(`\n${sorted.length} distinct values across ${entities.length} entities\n`);
286
+ }
287
+ return;
288
+ }
34
289
  // Spatial tree mode
35
290
  if (spatial) {
36
291
  const storeys = bim.storeys();
37
292
  const tree = {};
38
- for (const storey of storeys) {
39
- const contained = bim.contains(storey.ref);
40
- tree[storey.name || `Storey #${storey.ref.expressId}`] = contained.map(e => ({
41
- type: e.type,
42
- name: e.name,
43
- globalId: e.globalId,
44
- }));
293
+ if (storeys.length > 0) {
294
+ for (const storey of storeys) {
295
+ const contained = bim.contains(storey.ref);
296
+ tree[storey.name || `Storey #${storey.ref.expressId}`] = contained.map(e => ({
297
+ type: e.type,
298
+ name: e.name,
299
+ globalId: e.globalId,
300
+ }));
301
+ }
302
+ }
303
+ else {
304
+ // Fall back to buildings when no storeys exist
305
+ const buildings = bim.query().byType('IfcBuilding').toArray();
306
+ for (const building of buildings) {
307
+ const contained = bim.contains(building.ref);
308
+ tree[building.name || `Building #${building.ref.expressId}`] = contained.map(e => ({
309
+ type: e.type,
310
+ name: e.name,
311
+ globalId: e.globalId,
312
+ }));
313
+ }
314
+ if (buildings.length === 0) {
315
+ process.stderr.write('No storeys or buildings found in spatial structure\n');
316
+ }
317
+ }
318
+ if (spatialSummary) {
319
+ // Summary mode: type counts per storey instead of listing every element
320
+ const summary = {};
321
+ for (const [storeyName, elements] of Object.entries(tree)) {
322
+ const counts = {};
323
+ for (const elem of elements) {
324
+ counts[elem.type] = (counts[elem.type] || 0) + 1;
325
+ }
326
+ summary[storeyName] = counts;
327
+ }
328
+ if (jsonOutput) {
329
+ printJson(summary);
330
+ }
331
+ else {
332
+ for (const [storeyName, counts] of Object.entries(summary)) {
333
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
334
+ process.stdout.write(`\n ${storeyName} (${total} elements):\n`);
335
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
336
+ for (const [typeName, count] of sorted) {
337
+ process.stdout.write(` ${typeName}: ${count}\n`);
338
+ }
339
+ }
340
+ process.stdout.write('\n');
341
+ }
342
+ return;
45
343
  }
46
344
  printJson(tree);
47
345
  return;
@@ -52,42 +350,270 @@ export async function queryCommand(args) {
52
350
  const types = type.split(',');
53
351
  q = q.byType(...types);
54
352
  }
55
- if (propFilter) {
56
- // Format: "PsetName.PropName=Value" or "PsetName.PropName"
57
- const eqIdx = propFilter.indexOf('=');
58
- const dotIdx = propFilter.indexOf('.');
59
- if (dotIdx > 0) {
60
- const psetName = propFilter.slice(0, dotIdx);
61
- if (eqIdx > dotIdx) {
62
- const propName = propFilter.slice(dotIdx + 1, eqIdx);
63
- const value = propFilter.slice(eqIdx + 1);
64
- q = q.where(psetName, propName, '=', value);
353
+ // --storey filter: restrict to entities in a specific storey
354
+ if (storeyFilter) {
355
+ const storeys = bim.storeys();
356
+ const matchedStorey = storeys.find(s => s.name === storeyFilter ||
357
+ s.name.toLowerCase().includes(storeyFilter.toLowerCase()) ||
358
+ String(s.ref.expressId) === storeyFilter);
359
+ if (!matchedStorey) {
360
+ const names = storeys.map(s => s.name).filter(Boolean).join(', ');
361
+ fatal(`Storey "${storeyFilter}" not found. Available: ${names || '(none)'}`);
362
+ }
363
+ const contained = bim.contains(matchedStorey.ref);
364
+ const storeyIds = new Set(contained.map(e => e.ref.expressId));
365
+ // Post-filter: only keep entities that are in this storey
366
+ const baseEntities = q.toArray();
367
+ const storeyEntities = baseEntities.filter(e => storeyIds.has(e.ref.expressId));
368
+ // Apply --where filter to storey-filtered entities
369
+ if (propFilter) {
370
+ 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;
65
387
  }
66
- else {
67
- const propName = propFilter.slice(dotIdx + 1);
68
- q = q.where(psetName, propName, 'exists');
388
+ if (countOnly) {
389
+ outputCount(finalEntities.length, jsonOutput);
390
+ return;
69
391
  }
392
+ outputEntities(finalEntities, args, bim, jsonOutput);
393
+ return;
394
+ }
395
+ if (sumQuantity) {
396
+ outputSum(storeyEntities, sumQuantity, bim, jsonOutput);
397
+ return;
398
+ }
399
+ if (countOnly) {
400
+ outputCount(storeyEntities.length, jsonOutput);
401
+ return;
70
402
  }
403
+ outputEntities(storeyEntities, args, bim, jsonOutput);
404
+ return;
405
+ }
406
+ // --where filter with proper syntax validation
407
+ if (propFilter) {
408
+ const parsed = parseWhereFilter(propFilter);
409
+ q = q.where(parsed.psetName, parsed.propName, parsed.operator, parsed.value);
71
410
  }
72
411
  if (limit)
73
412
  q = q.limit(parseInt(limit, 10));
74
413
  if (offset)
75
414
  q = q.offset(parseInt(offset, 10));
415
+ // --group-by + --sum combo: aggregate per group
416
+ if (groupBy && sumQuantity) {
417
+ const entities = q.toArray();
418
+ outputGroupBy(entities, groupBy, sumQuantity, bim, jsonOutput);
419
+ return;
420
+ }
421
+ // --sum mode: aggregate a quantity across matched entities
422
+ if (sumQuantity) {
423
+ const entities = q.toArray();
424
+ outputSum(entities, sumQuantity, bim, jsonOutput);
425
+ return;
426
+ }
427
+ // --group-by mode: pivot table grouped by a property or 'type'/'material'
428
+ if (groupBy) {
429
+ const entities = q.toArray();
430
+ outputGroupBy(entities, groupBy, undefined, bim, jsonOutput);
431
+ return;
432
+ }
76
433
  if (countOnly) {
77
434
  const count = q.count();
78
- if (jsonOutput) {
79
- printJson({ count });
435
+ outputCount(count, jsonOutput);
436
+ return;
437
+ }
438
+ const entities = q.toArray();
439
+ outputEntities(entities, args, bim, jsonOutput);
440
+ }
441
+ function outputCount(count, jsonOutput) {
442
+ if (jsonOutput) {
443
+ printJson({ count });
444
+ }
445
+ else {
446
+ process.stdout.write(`${count}\n`);
447
+ }
448
+ }
449
+ function outputSum(entities, quantityName, bim, jsonOutput) {
450
+ let total = 0;
451
+ let matched = 0;
452
+ // Track all quantity names seen (for disambiguation warning)
453
+ const allQuantityNames = new Map();
454
+ const matchedQsets = new Set();
455
+ for (const e of entities) {
456
+ const qsets = bim.quantities(e.ref);
457
+ for (const qset of qsets) {
458
+ for (const q of qset.quantities) {
459
+ // Track all quantities for disambiguation
460
+ const key = `${qset.name}.${q.name}`;
461
+ const existing = allQuantityNames.get(key);
462
+ if (existing) {
463
+ existing.count++;
464
+ }
465
+ else {
466
+ allQuantityNames.set(key, { qsetName: qset.name, count: 1 });
467
+ }
468
+ if (q.name === quantityName) {
469
+ total += Number(q.value) || 0;
470
+ matched++;
471
+ matchedQsets.add(qset.name);
472
+ }
473
+ }
474
+ }
475
+ }
476
+ // Check for ambiguous area/volume quantities and warn
477
+ const similarNames = [...allQuantityNames.entries()]
478
+ .filter(([key]) => {
479
+ const qName = key.split('.').pop().toLowerCase();
480
+ const searchName = quantityName.toLowerCase();
481
+ // Warn if there are other quantities with similar base concept (area, volume, etc.)
482
+ if (searchName.includes('area'))
483
+ return qName.includes('area') || qName.includes('surface');
484
+ if (searchName.includes('volume'))
485
+ return qName.includes('volume');
486
+ if (searchName.includes('length'))
487
+ return qName.includes('length');
488
+ return false;
489
+ })
490
+ .filter(([key]) => key.split('.').pop() !== quantityName);
491
+ if (jsonOutput) {
492
+ const result = {
493
+ quantity: quantityName,
494
+ total,
495
+ matchedEntities: matched,
496
+ totalEntities: entities.length,
497
+ fromQuantitySets: [...matchedQsets],
498
+ };
499
+ if (similarNames.length > 0) {
500
+ result.warning = 'Other similar quantities exist — verify you are using the correct one';
501
+ result.alternatives = similarNames.map(([key, info]) => ({
502
+ name: key,
503
+ foundInEntities: info.count,
504
+ }));
505
+ }
506
+ printJson(result);
507
+ }
508
+ else {
509
+ process.stdout.write(`${total}\n`);
510
+ process.stderr.write(`${quantityName}: ${total} (from ${matched} of ${entities.length} entities, qsets: ${[...matchedQsets].join(', ')})\n`);
511
+ if (similarNames.length > 0) {
512
+ process.stderr.write(`\nWARNING: Other similar quantities exist in these entities:\n`);
513
+ for (const [key, info] of similarNames) {
514
+ process.stderr.write(` - ${key} (${info.count} entities)\n`);
515
+ }
516
+ process.stderr.write(` Verify you are summing the correct quantity for your analysis.\n`);
517
+ process.stderr.write(` Use --quantity-names --type <Type> to see all available quantities.\n`);
518
+ }
519
+ }
520
+ }
521
+ function outputGroupBy(entities, groupByKey, sumQuantity, bim, jsonOutput) {
522
+ const groups = new Map();
523
+ for (const e of entities) {
524
+ let groupValue;
525
+ if (groupByKey === 'type') {
526
+ groupValue = e.type;
527
+ }
528
+ else if (groupByKey === 'storey') {
529
+ const storey = bim.storey(e.ref);
530
+ groupValue = storey?.name ?? '(no storey)';
531
+ }
532
+ else if (groupByKey === 'material') {
533
+ const mat = bim.materials(e.ref);
534
+ groupValue = mat?.materials?.[0]?.name ?? mat?.name ?? '(no material)';
535
+ }
536
+ else if (groupByKey.includes('.')) {
537
+ // PsetName.PropName
538
+ const [psetName, propName] = groupByKey.split('.', 2);
539
+ const props = bim.properties(e.ref);
540
+ const pset = props.find((p) => p.name === psetName);
541
+ const prop = pset?.properties?.find((p) => p.name === propName);
542
+ groupValue = prop?.value != null ? String(prop.value) : `(no ${propName})`;
80
543
  }
81
544
  else {
82
- process.stdout.write(`${count}\n`);
545
+ groupValue = e[groupByKey] ?? `(no ${groupByKey})`;
546
+ }
547
+ const existing = groups.get(groupValue);
548
+ if (existing) {
549
+ existing.push(e);
550
+ }
551
+ else {
552
+ groups.set(groupValue, [e]);
83
553
  }
84
- return;
85
554
  }
86
- const entities = q.toArray();
555
+ // Compute per-group sum if --sum is specified alongside --group-by
556
+ const groupSums = new Map();
557
+ if (sumQuantity) {
558
+ for (const [key, groupEntities] of groups) {
559
+ let sum = 0;
560
+ 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
+ }
567
+ }
568
+ }
569
+ groupSums.set(key, sum);
570
+ }
571
+ }
572
+ if (jsonOutput) {
573
+ const result = {};
574
+ for (const [key, groupEntities] of groups) {
575
+ const entry = { count: groupEntities.length };
576
+ if (sumQuantity)
577
+ entry[sumQuantity] = groupSums.get(key) ?? 0;
578
+ result[key] = entry;
579
+ }
580
+ printJson(result);
581
+ }
582
+ 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`);
585
+ for (const [key, groupEntities] of sorted) {
586
+ if (sumQuantity) {
587
+ const sum = groupSums.get(key) ?? 0;
588
+ process.stdout.write(` ${key}: ${groupEntities.length} elements, ${sumQuantity}: ${sum}\n`);
589
+ }
590
+ else {
591
+ process.stdout.write(` ${key}: ${groupEntities.length}\n`);
592
+ }
593
+ }
594
+ 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`);
597
+ }
598
+ else {
599
+ process.stdout.write(`\n Total: ${entities.length} entities in ${groups.size} groups\n\n`);
600
+ }
601
+ }
602
+ }
603
+ function outputEntities(entities, args, bim, jsonOutput) {
604
+ const showProps = hasFlag(args, '--props');
605
+ const showQuantities = hasFlag(args, '--quantities');
606
+ const showMaterials = hasFlag(args, '--materials');
607
+ const showClassifications = hasFlag(args, '--classifications');
608
+ const showAttributes = hasFlag(args, '--attributes');
609
+ const showRelationships = hasFlag(args, '--relationships');
610
+ const showTypeProps = hasFlag(args, '--type-props');
611
+ const showDocuments = hasFlag(args, '--documents');
612
+ const showAll = hasFlag(args, '--all');
87
613
  const needsDetail = showProps || showQuantities || showMaterials || showClassifications
88
614
  || showAttributes || showRelationships || showTypeProps || showDocuments || showAll;
89
615
  if (jsonOutput || needsDetail) {
90
- const result = entities.map(e => {
616
+ const result = entities.map((e) => {
91
617
  const entry = {
92
618
  type: e.type,
93
619
  name: e.name,
@@ -117,7 +643,7 @@ export async function queryCommand(args) {
117
643
  return;
118
644
  }
119
645
  // Table output
120
- const rows = entities.map(e => [e.type, e.name, e.globalId]);
646
+ const rows = entities.map((e) => [e.type, e.name, e.globalId]);
121
647
  process.stdout.write(formatTable(['Type', 'Name', 'GlobalId'], rows) + '\n');
122
648
  process.stderr.write(`\n${entities.length} entities\n`);
123
649
  }