@ifc-lite/viewer 1.13.0 → 1.14.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/assets/{Arrow.dom-VW5W1XFO.js → Arrow.dom-HLSMJR_v.js} +1 -1
  3. package/dist/assets/{browser-C6mwD6n0.js → browser-Ch0OnmZN.js} +1 -1
  4. package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
  5. package/dist/assets/{index-BzoX4cQC.js → index-DJbbSLF9.js} +27195 -24454
  6. package/dist/assets/{index-DQE23JyT.js → index-JPFMj8C9.js} +4 -4
  7. package/dist/assets/index-Qp8stcGO.css +1 -0
  8. package/dist/assets/{native-bridge-BibEEmFV.js → native-bridge-BzC7HkDs.js} +1 -1
  9. package/dist/assets/{wasm-bridge-CYzUd3Io.js → wasm-bridge-B_7dPwOa.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +19 -19
  12. package/src/components/viewer/BulkPropertyEditor.tsx +8 -1
  13. package/src/components/viewer/DataConnector.tsx +8 -1
  14. package/src/components/viewer/ExportChangesButton.tsx +8 -1
  15. package/src/components/viewer/ExportDialog.tsx +8 -1
  16. package/src/components/viewer/HierarchyPanel.tsx +7 -1
  17. package/src/components/viewer/MainToolbar.tsx +51 -18
  18. package/src/components/viewer/PropertiesPanel.tsx +209 -15
  19. package/src/components/viewer/Viewport.tsx +5 -1
  20. package/src/components/viewer/ViewportContainer.tsx +59 -37
  21. package/src/components/viewer/hierarchy/useHierarchyTree.ts +8 -4
  22. package/src/components/viewer/properties/BsddCard.tsx +507 -0
  23. package/src/components/viewer/properties/QuantitySetCard.tsx +1 -0
  24. package/src/components/viewer/useGeometryStreaming.ts +189 -55
  25. package/src/components/viewer/useMouseControls.ts +55 -14
  26. package/src/components/viewer/useTouchControls.ts +2 -0
  27. package/src/hooks/useIfc.ts +19 -1
  28. package/src/hooks/useIfcCache.ts +6 -1
  29. package/src/hooks/useIfcFederation.ts +16 -1
  30. package/src/hooks/useIfcLoader.ts +16 -4
  31. package/src/index.css +7 -0
  32. package/src/lib/scripts/templates/bim-globals.d.ts +33 -0
  33. package/src/lib/scripts/templates/create-building.ts +491 -0
  34. package/src/lib/scripts/templates.ts +8 -0
  35. package/src/sdk/adapters/export-adapter.ts +84 -0
  36. package/src/sdk/adapters/model-adapter.ts +8 -0
  37. package/src/services/bsdd.ts +262 -0
  38. package/src/store/index.ts +2 -2
  39. package/src/store/slices/dataSlice.ts +9 -4
  40. package/src/store/slices/mutationSlice.ts +155 -1
  41. package/src/utils/localParsingUtils.ts +3 -1
  42. package/tsconfig.json +12 -1
  43. package/vite.config.ts +7 -0
  44. package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
  45. package/dist/assets/index-Cx134arv.css +0 -1
@@ -0,0 +1,507 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * bSDD (buildingSMART Data Dictionary) integration card.
7
+ *
8
+ * Shows schema-defined property sets and properties for the selected
9
+ * IFC entity type, fetched live from the bSDD API. Users can add
10
+ * properties to the element in one click.
11
+ */
12
+
13
+ import { useState, useEffect, useMemo, useCallback } from 'react';
14
+ import { BookOpen, Plus, Check, Loader2, ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
15
+ import { Button } from '@/components/ui/button';
16
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
17
+ import { Badge } from '@/components/ui/badge';
18
+ import { useViewerStore } from '@/store';
19
+ import { type PropertyValue, PropertyValueType, QuantityType } from '@ifc-lite/data';
20
+ import {
21
+ fetchClassInfo,
22
+ bsddDataTypeLabel,
23
+ type BsddClassInfo,
24
+ type BsddClassProperty,
25
+ } from '@/services/bsdd';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers for Qto_* (quantity set) detection and mapping
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Returns true when the property set name denotes a quantity set */
32
+ function isQuantitySet(psetName: string): boolean {
33
+ return psetName.startsWith('Qto_');
34
+ }
35
+
36
+ /** Infer QuantityType from bSDD unit strings */
37
+ function inferQuantityType(units: string[] | null): QuantityType {
38
+ if (!units || units.length === 0) return QuantityType.Count;
39
+ const u = units[0].toLowerCase();
40
+ if (u === 'm' || u === 'mm' || u === 'cm') return QuantityType.Length;
41
+ if (u.includes('m²') || u.includes('m2')) return QuantityType.Area;
42
+ if (u.includes('m³') || u.includes('m3')) return QuantityType.Volume;
43
+ if (u === 'kg' || u === 'g' || u === 't') return QuantityType.Weight;
44
+ if (u === 's' || u === 'h' || u === 'min') return QuantityType.Time;
45
+ return QuantityType.Count;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // bSDD data type → PropertyValueType mapping
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function toPropertyValueType(bsddType: string | null): PropertyValueType {
53
+ if (!bsddType) return PropertyValueType.String;
54
+ const lower = bsddType.toLowerCase();
55
+ if (lower === 'boolean') return PropertyValueType.Boolean;
56
+ if (lower === 'real' || lower === 'number') return PropertyValueType.Real;
57
+ if (lower === 'integer') return PropertyValueType.Integer;
58
+ if (lower === 'character' || lower === 'string') return PropertyValueType.String;
59
+ return PropertyValueType.Label;
60
+ }
61
+
62
+ function defaultValue(_bsddType: string | null): PropertyValue {
63
+ // Always return empty string – user fills in values manually
64
+ return '';
65
+ }
66
+
67
+ /** bSDD properties with null propertySet are IFC entity-level attributes */
68
+ const BSDD_ATTRIBUTES_GROUP = 'Attributes';
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Component props
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export interface BsddCardProps {
75
+ /** IFC type name of the selected entity, e.g. "IfcWall" */
76
+ entityType: string;
77
+ /** Model ID for mutations */
78
+ modelId: string;
79
+ /** Express ID of the entity to add properties to */
80
+ entityId: number;
81
+ /** Names of property sets already present on the entity */
82
+ existingPsets: string[];
83
+ /** Names of properties already present on the entity (flat list: "PsetName:PropName") */
84
+ existingProps: Set<string>;
85
+ /** Names of quantity sets already present on the entity */
86
+ existingQsets?: string[];
87
+ /** Names of quantities already present (flat list: "QsetName:QuantName") */
88
+ existingQuants?: Set<string>;
89
+ /** Names of entity-level attributes that already have values */
90
+ existingAttributes?: Set<string>;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Main component
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export function BsddCard({
98
+ entityType,
99
+ modelId,
100
+ entityId,
101
+ existingPsets,
102
+ existingProps,
103
+ existingQsets = [],
104
+ existingQuants = new Set<string>(),
105
+ existingAttributes = new Set<string>(),
106
+ }: BsddCardProps) {
107
+ const [classInfo, setClassInfo] = useState<BsddClassInfo | null>(null);
108
+ const [loading, setLoading] = useState(false);
109
+ const [error, setError] = useState<string | null>(null);
110
+ const [expandedPsets, setExpandedPsets] = useState<Set<string>>(new Set());
111
+ const [addedKeys, setAddedKeys] = useState<Set<string>>(new Set());
112
+
113
+ const setProperty = useViewerStore((s) => s.setProperty);
114
+ const createPropertySet = useViewerStore((s) => s.createPropertySet);
115
+ const setQuantity = useViewerStore((s) => s.setQuantity);
116
+ const createQuantitySet = useViewerStore((s) => s.createQuantitySet);
117
+ const storeSetAttribute = useViewerStore((s) => s.setAttribute);
118
+ const bumpMutationVersion = useViewerStore((s) => s.bumpMutationVersion);
119
+
120
+ // Fetch class info from bSDD when entity type changes
121
+ useEffect(() => {
122
+ let cancelled = false;
123
+ setClassInfo(null);
124
+ setError(null);
125
+ setAddedKeys(new Set());
126
+
127
+ if (!entityType) return;
128
+
129
+ setLoading(true);
130
+ fetchClassInfo(entityType).then(
131
+ (info) => {
132
+ if (cancelled) return;
133
+ setLoading(false);
134
+ if (info && info.classProperties.length > 0) {
135
+ setClassInfo(info);
136
+ } else {
137
+ setClassInfo(null);
138
+ }
139
+ },
140
+ (err) => {
141
+ if (cancelled) return;
142
+ setLoading(false);
143
+ setError(err instanceof Error ? err.message : 'Failed to fetch bSDD data');
144
+ },
145
+ );
146
+
147
+ return () => {
148
+ cancelled = true;
149
+ };
150
+ }, [entityType]);
151
+
152
+ // Group properties by property set name
153
+ const groupedProps = useMemo(() => {
154
+ if (!classInfo) return new Map<string, BsddClassProperty[]>();
155
+ const map = new Map<string, BsddClassProperty[]>();
156
+ for (const prop of classInfo.classProperties) {
157
+ // Null propertySet → IFC entity attributes (Name, Description, etc.)
158
+ const psetName = prop.propertySet || BSDD_ATTRIBUTES_GROUP;
159
+ let list = map.get(psetName);
160
+ if (!list) {
161
+ list = [];
162
+ map.set(psetName, list);
163
+ }
164
+ list.push(prop);
165
+ }
166
+ return map;
167
+ }, [classInfo]);
168
+
169
+ const togglePset = useCallback((name: string) => {
170
+ setExpandedPsets((prev) => {
171
+ const next = new Set(prev);
172
+ if (next.has(name)) next.delete(name);
173
+ else next.add(name);
174
+ return next;
175
+ });
176
+ }, []);
177
+
178
+ const handleAddProperty = useCallback(
179
+ (psetName: string, prop: BsddClassProperty) => {
180
+ let normalizedModelId = modelId;
181
+ if (modelId === 'legacy') normalizedModelId = '__legacy__';
182
+
183
+ if (psetName === BSDD_ATTRIBUTES_GROUP) {
184
+ // Route entity-level attributes (Name, Description, ObjectType, Tag, etc.)
185
+ storeSetAttribute(normalizedModelId, entityId, prop.name, '');
186
+ } else if (isQuantitySet(psetName)) {
187
+ // Route Qto_* through quantity creation
188
+ const qType = inferQuantityType(prop.units);
189
+ const qsetExists = existingQsets.includes(psetName);
190
+
191
+ if (!qsetExists) {
192
+ createQuantitySet(normalizedModelId, entityId, psetName, [
193
+ { name: prop.name, value: NaN, quantityType: qType, unit: prop.units?.[0] },
194
+ ]);
195
+ } else {
196
+ setQuantity(
197
+ normalizedModelId,
198
+ entityId,
199
+ psetName,
200
+ prop.name,
201
+ NaN,
202
+ qType,
203
+ prop.units?.[0],
204
+ );
205
+ }
206
+ } else {
207
+ // Route Pset_* / other through property creation
208
+ const valueType = toPropertyValueType(prop.dataType);
209
+ const value = defaultValue(prop.dataType);
210
+ const psetExists = existingPsets.includes(psetName);
211
+
212
+ if (!psetExists) {
213
+ createPropertySet(normalizedModelId, entityId, psetName, [
214
+ { name: prop.name, value, type: valueType },
215
+ ]);
216
+ } else {
217
+ setProperty(
218
+ normalizedModelId,
219
+ entityId,
220
+ psetName,
221
+ prop.name,
222
+ value,
223
+ valueType,
224
+ );
225
+ }
226
+ }
227
+
228
+ bumpMutationVersion();
229
+ setAddedKeys((prev) => new Set(prev).add(`${psetName}:${prop.name}`));
230
+ },
231
+ [modelId, entityId, existingPsets, existingQsets, setProperty, createPropertySet, setQuantity, createQuantitySet, storeSetAttribute, bumpMutationVersion],
232
+ );
233
+
234
+ const handleAddAllInPset = useCallback(
235
+ (psetName: string, props: BsddClassProperty[]) => {
236
+ let normalizedModelId = modelId;
237
+ if (modelId === 'legacy') normalizedModelId = '__legacy__';
238
+
239
+ const isAttrGroup = psetName === BSDD_ATTRIBUTES_GROUP;
240
+
241
+ // Determine which "existing" set to check against
242
+ const existingSet = isAttrGroup
243
+ ? existingAttributes
244
+ : isQuantitySet(psetName)
245
+ ? existingQuants
246
+ : existingProps;
247
+
248
+ // For attributes, key is just the name; for props/quants, key is "PsetName:PropName"
249
+ const toAdd = props.filter(
250
+ (p) => {
251
+ const key = isAttrGroup ? p.name : `${psetName}:${p.name}`;
252
+ const addedKey = `${psetName}:${p.name}`;
253
+ return !existingSet.has(key) && !addedKeys.has(addedKey);
254
+ },
255
+ );
256
+ if (toAdd.length === 0) return;
257
+
258
+ if (isAttrGroup) {
259
+ // Route entity-level attributes
260
+ for (const p of toAdd) {
261
+ storeSetAttribute(normalizedModelId, entityId, p.name, '');
262
+ }
263
+ } else if (isQuantitySet(psetName)) {
264
+ // Route Qto_* through quantity creation
265
+ const qsetExists = existingQsets.includes(psetName);
266
+
267
+ if (!qsetExists) {
268
+ createQuantitySet(
269
+ normalizedModelId,
270
+ entityId,
271
+ psetName,
272
+ toAdd.map((p) => ({
273
+ name: p.name,
274
+ value: NaN,
275
+ quantityType: inferQuantityType(p.units),
276
+ unit: p.units?.[0],
277
+ })),
278
+ );
279
+ } else {
280
+ for (const p of toAdd) {
281
+ setQuantity(
282
+ normalizedModelId,
283
+ entityId,
284
+ psetName,
285
+ p.name,
286
+ NaN,
287
+ inferQuantityType(p.units),
288
+ p.units?.[0],
289
+ );
290
+ }
291
+ }
292
+ } else {
293
+ const psetExists = existingPsets.includes(psetName);
294
+
295
+ if (!psetExists) {
296
+ createPropertySet(
297
+ normalizedModelId,
298
+ entityId,
299
+ psetName,
300
+ toAdd.map((p) => ({
301
+ name: p.name,
302
+ value: defaultValue(p.dataType),
303
+ type: toPropertyValueType(p.dataType),
304
+ })),
305
+ );
306
+ } else {
307
+ for (const p of toAdd) {
308
+ setProperty(
309
+ normalizedModelId,
310
+ entityId,
311
+ psetName,
312
+ p.name,
313
+ defaultValue(p.dataType),
314
+ toPropertyValueType(p.dataType),
315
+ );
316
+ }
317
+ }
318
+ }
319
+
320
+ bumpMutationVersion();
321
+ setAddedKeys((prev) => {
322
+ const next = new Set(prev);
323
+ for (const p of toAdd) next.add(`${psetName}:${p.name}`);
324
+ return next;
325
+ });
326
+ },
327
+ [modelId, entityId, existingPsets, existingQsets, existingProps, existingQuants, existingAttributes, addedKeys, setProperty, createPropertySet, setQuantity, createQuantitySet, storeSetAttribute, bumpMutationVersion],
328
+ );
329
+
330
+ // Loading state
331
+ if (loading) {
332
+ return (
333
+ <div className="flex items-center gap-2 px-3 py-6 text-xs text-zinc-400">
334
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
335
+ <span>Loading bSDD data for {entityType}...</span>
336
+ </div>
337
+ );
338
+ }
339
+
340
+ // Error state
341
+ if (error) {
342
+ return (
343
+ <div className="px-3 py-4 text-xs text-red-500/70">
344
+ <p>Could not load bSDD data: {error}</p>
345
+ </div>
346
+ );
347
+ }
348
+
349
+ // No data
350
+ if (!classInfo || groupedProps.size === 0) {
351
+ return (
352
+ <div className="flex flex-col items-center justify-center text-center px-4 py-8 text-xs text-zinc-400 gap-2">
353
+ <BookOpen className="h-6 w-6 text-zinc-300 dark:text-zinc-600" />
354
+ <p>No bSDD data available for <span className="font-mono font-medium">{entityType}</span></p>
355
+ </div>
356
+ );
357
+ }
358
+
359
+ return (
360
+ <div className="space-y-2 w-full min-w-0 overflow-hidden">
361
+ {/* Header with class description */}
362
+ {classInfo.definition && (
363
+ <div className="px-1 pb-1 text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed">
364
+ {classInfo.definition}
365
+ </div>
366
+ )}
367
+
368
+ {/* Property sets from bSDD */}
369
+ {Array.from(groupedProps.entries()).map(([psetName, props]) => {
370
+ const isExpanded = expandedPsets.has(psetName);
371
+ const isAttrGroup = psetName === BSDD_ATTRIBUTES_GROUP;
372
+ const isQto = isQuantitySet(psetName);
373
+ // For attributes, check against existingAttributes (keyed by name only);
374
+ // for quants/props, check against existingQuants/existingProps (keyed by "PsetName:PropName")
375
+ const existingSet = isAttrGroup ? existingAttributes : isQto ? existingQuants : existingProps;
376
+ const makeKey = (p: BsddClassProperty) => isAttrGroup ? p.name : `${psetName}:${p.name}`;
377
+ const allAlreadyExist = props.every(
378
+ (p) =>
379
+ existingSet.has(makeKey(p)) ||
380
+ addedKeys.has(`${psetName}:${p.name}`),
381
+ );
382
+ const psetExistsOnEntity = isAttrGroup
383
+ ? true // Attributes section always exists on the entity
384
+ : isQto
385
+ ? existingQsets.includes(psetName)
386
+ : existingPsets.includes(psetName);
387
+ const addableCount = props.filter(
388
+ (p) =>
389
+ !existingSet.has(makeKey(p)) &&
390
+ !addedKeys.has(`${psetName}:${p.name}`),
391
+ ).length;
392
+
393
+ return (
394
+ <div
395
+ key={psetName}
396
+ className="border-2 border-sky-200/60 dark:border-sky-800/40 bg-sky-50/20 dark:bg-sky-950/10 w-full overflow-hidden"
397
+ >
398
+ {/* Pset header */}
399
+ <button
400
+ className="flex items-center gap-1.5 w-full p-2 hover:bg-sky-50 dark:hover:bg-sky-900/20 text-left transition-colors overflow-hidden"
401
+ onClick={() => togglePset(psetName)}
402
+ >
403
+ {isExpanded ? (
404
+ <ChevronDown className="h-3 w-3 text-sky-500 shrink-0" />
405
+ ) : (
406
+ <ChevronRight className="h-3 w-3 text-sky-500 shrink-0" />
407
+ )}
408
+ <span className="font-bold text-xs text-sky-800 dark:text-sky-300 truncate flex-1 min-w-0">
409
+ {psetName}
410
+ </span>
411
+ <span className="text-[10px] font-mono bg-sky-100 dark:bg-sky-900/50 px-1 py-0.5 border border-sky-200 dark:border-sky-800 text-sky-600 dark:text-sky-400 shrink-0">
412
+ {props.length}
413
+ </span>
414
+ {addableCount > 0 && (
415
+ <Tooltip>
416
+ <TooltipTrigger asChild>
417
+ <Button
418
+ variant="ghost"
419
+ size="icon"
420
+ className="h-5 w-5 p-0 shrink-0 hover:bg-sky-200 dark:hover:bg-sky-800"
421
+ onClick={(e) => {
422
+ e.stopPropagation();
423
+ handleAddAllInPset(psetName, props);
424
+ }}
425
+ >
426
+ <Plus className="h-3 w-3 text-sky-600 dark:text-sky-400" />
427
+ </Button>
428
+ </TooltipTrigger>
429
+ <TooltipContent>Add all {addableCount} properties</TooltipContent>
430
+ </Tooltip>
431
+ )}
432
+ {allAlreadyExist && (
433
+ <Check className="h-3 w-3 text-emerald-500 shrink-0" />
434
+ )}
435
+ </button>
436
+
437
+ {/* Properties */}
438
+ {isExpanded && (
439
+ <div className="border-t-2 border-sky-200/60 dark:border-sky-800/40 divide-y divide-sky-100 dark:divide-sky-900/30">
440
+ {props.map((prop) => {
441
+ const existKey = makeKey(prop);
442
+ const addedKey = `${psetName}:${prop.name}`;
443
+ const alreadyExists = existingSet.has(existKey) || addedKeys.has(addedKey);
444
+
445
+ return (
446
+ <div
447
+ key={prop.name}
448
+ className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs overflow-hidden ${
449
+ alreadyExists
450
+ ? 'bg-emerald-50/30 dark:bg-emerald-950/10'
451
+ : 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20'
452
+ }`}
453
+ >
454
+ <Tooltip>
455
+ <TooltipTrigger asChild>
456
+ <span className="font-medium text-zinc-600 dark:text-zinc-400 cursor-help truncate flex-1 min-w-0">
457
+ {prop.name}
458
+ </span>
459
+ </TooltipTrigger>
460
+ <TooltipContent side="top" className="max-w-xs text-[10px]">
461
+ <p className="font-medium">{prop.name}</p>
462
+ {prop.description && <p className="mt-0.5 text-zinc-400">{prop.description}</p>}
463
+ {prop.dataType && <p className="mt-0.5 text-sky-400">{bsddDataTypeLabel(prop.dataType)}</p>}
464
+ </TooltipContent>
465
+ </Tooltip>
466
+ {/* Add button - always visible on right */}
467
+ {alreadyExists ? (
468
+ <Check className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
469
+ ) : (
470
+ <Tooltip>
471
+ <TooltipTrigger asChild>
472
+ <Button
473
+ variant="ghost"
474
+ size="icon"
475
+ className="h-5 w-5 p-0 shrink-0 hover:bg-sky-200 dark:hover:bg-sky-800"
476
+ onClick={() => handleAddProperty(psetName, prop)}
477
+ >
478
+ <Plus className="h-3 w-3 text-sky-600 dark:text-sky-400" />
479
+ </Button>
480
+ </TooltipTrigger>
481
+ <TooltipContent>Add to element</TooltipContent>
482
+ </Tooltip>
483
+ )}
484
+ </div>
485
+ );
486
+ })}
487
+ </div>
488
+ )}
489
+ </div>
490
+ );
491
+ })}
492
+
493
+ {/* Footer link */}
494
+ <div className="flex items-center justify-center pt-1 pb-1">
495
+ <a
496
+ href={`https://search.bsdd.buildingsmart.org/uri/buildingsmart/ifc/4.3/class/${entityType}`}
497
+ target="_blank"
498
+ rel="noopener noreferrer"
499
+ className="flex items-center gap-1 text-[10px] text-sky-500/70 hover:text-sky-600 transition-colors"
500
+ >
501
+ <ExternalLink className="h-2.5 w-2.5" />
502
+ View on bSDD
503
+ </a>
504
+ </div>
505
+ </div>
506
+ );
507
+ }
@@ -23,6 +23,7 @@ const QUANTITY_TYPE_NAMES: Record<number, string> = {
23
23
 
24
24
  export function QuantitySetCard({ qset }: { qset: QuantitySet }) {
25
25
  const formatValue = (value: number, type: number): string => {
26
+ if (isNaN(value)) return '\u2014'; // em-dash for empty values
26
27
  const formatted = value.toLocaleString(undefined, { maximumFractionDigits: 3 });
27
28
  switch (type) {
28
29
  case 0: return `${formatted} m`;