@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.
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +3 -1
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +28 -2
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +27 -1
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/info.d.ts.map +1 -1
- package/dist/commands/info.js +27 -9
- package/dist/commands/info.js.map +1 -1
- package/dist/commands/query.d.ts.map +1 -1
- package/dist/commands/query.js +553 -27
- package/dist/commands/query.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +29 -0
- package/dist/commands/validate.js.map +1 -1
- package/dist/headless-backend.d.ts.map +1 -1
- package/dist/headless-backend.js +27 -8
- package/dist/headless-backend.js.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +11 -0
- package/dist/loader.js.map +1 -1
- package/dist/output.d.ts +1 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +7 -1
- package/dist/output.js.map +1 -1
- package/package.json +7 -7
package/dist/commands/query.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|