@ifc-lite/bcf 1.18.2 → 2.0.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.
@@ -0,0 +1,202 @@
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
+ import { extractElement, unescapeXml } from './xml-text.js';
5
+ /**
6
+ * Every `<Component>` element in an XML fragment.
7
+ *
8
+ * The two branches are spelled out rather than factored behind a common
9
+ * `<Component[^>]*` prefix as
10
+ * `<Component[^>]*(?:\/>|>[\s\S]*?<\/Component>)`, and that is the whole
11
+ * point: `[^>]*` is greedy and eats the `/` of a self-closing tag, so the
12
+ * `\/>` alternative can never fire. A uniform list still parsed, because the
13
+ * engine backtracks and gives the `/` back when no later `</Component>`
14
+ * exists. A MIXED list did not: `<Component .../><Component ...>...</Component>`
15
+ * matched as ONE element spanning both, and the first component silently
16
+ * inherited the second's `authoringToolId` and `originatingSystem`.
17
+ *
18
+ * That is why every fixture passed. Uniform lists are the only shape the suite
19
+ * had, and they are the one shape the defect cannot reach.
20
+ */
21
+ const COMPONENT_ELEMENT = /<Component[^>]*?\/>|<Component[^>]*>[\s\S]*?<\/Component>/g;
22
+ /** Parse each `<Component>` in `xml`, dropping any that does not parse. */
23
+ function parseComponentElements(xml) {
24
+ const components = [];
25
+ for (const match of xml.matchAll(COMPONENT_ELEMENT)) {
26
+ const component = parseComponent(match[0]);
27
+ if (component)
28
+ components.push(component);
29
+ }
30
+ return components;
31
+ }
32
+ /**
33
+ * Parse components (selection/visibility/coloring)
34
+ */
35
+ export function parseComponents(content) {
36
+ const componentsMatch = content.match(/<Components>([\s\S]*?)<\/Components>/);
37
+ if (!componentsMatch)
38
+ return undefined;
39
+ const componentsContent = componentsMatch[1];
40
+ // Parse selection
41
+ const selection = parseComponentList(componentsContent, 'Selection');
42
+ // Parse visibility
43
+ let visibility = parseVisibility(componentsContent);
44
+ // Parse coloring
45
+ const coloring = parseColoring(componentsContent);
46
+ // Nothing read `ViewSetupHints` back, so every hint was lost on read —
47
+ // including out of our own archives. It was invisible because no writer
48
+ // fixture set the hints, so the round trip compared `undefined` to
49
+ // `undefined`.
50
+ //
51
+ // Searched across the whole `Components` body on purpose, because the two
52
+ // schema versions disagree on placement: 2.1 puts the element on
53
+ // `Components`, 3.0 nests it inside `Visibility`, and `writer.ts` emits
54
+ // whichever the requested version calls for. Anchoring to either one would
55
+ // read half the files we produce.
56
+ const viewSetupHints = parseViewSetupHints(componentsContent);
57
+ if (viewSetupHints) {
58
+ // Visibility is required by the schema, but tolerate a file that omits it:
59
+ // DefaultVisibility's schema default is true.
60
+ visibility = { ...(visibility ?? { defaultVisibility: true }), viewSetupHints };
61
+ }
62
+ if (!selection && !visibility && !coloring) {
63
+ return undefined;
64
+ }
65
+ return {
66
+ selection: selection?.length ? selection : undefined,
67
+ visibility,
68
+ coloring: coloring?.length ? coloring : undefined,
69
+ };
70
+ }
71
+ /**
72
+ * Parse a list of components
73
+ */
74
+ function parseComponentList(content, elementName) {
75
+ const match = content.match(new RegExp(`<${elementName}>([\\s\\S]*?)<\\/${elementName}>`));
76
+ if (!match)
77
+ return undefined;
78
+ const components = parseComponentElements(match[1]);
79
+ return components.length > 0 ? components : undefined;
80
+ }
81
+ /**
82
+ * Parse a single component
83
+ */
84
+ function parseComponent(content) {
85
+ const ifcGuidMatch = content.match(/IfcGuid="([^"]+)"/);
86
+ /** An attribute value, decoded the same way `extractElement` decodes an
87
+ * element's text, so `AuthoringToolId="A &amp; B"` and the element spelling
88
+ * of the same field yield the same string. Deliberately not
89
+ * `reader.ts::extractAttr`, which is contracted to take an already-captured
90
+ * attribute string and does not decode entities. Without this the two spellings of the SAME field
91
+ * disagreed: `AuthoringToolId="A &amp; B"` came back as the literal
92
+ * `A &amp; B` while `<AuthoringToolId>A &amp; B</AuthoringToolId>` came back
93
+ * as `A & B`. Which one a file uses is not supposed to change its value. */
94
+ // Only the OPENING TAG. `content` is the whole element, so an unscoped
95
+ // search reads a CHILD's attribute as the component's own:
96
+ // `<Component IfcGuid="G"><Child OriginatingSystem="x"/></Component>` used to
97
+ // report `x`. `\b` for the same reason `reader.ts::extractAttr` has it, or
98
+ // `XAuthoringToolId="sneaky"` satisfies a search for `AuthoringToolId`.
99
+ const openingTag = content.slice(0, content.indexOf('>') + 1);
100
+ const attribute = (name) => {
101
+ const raw = openingTag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
102
+ return raw ? unescapeXml(raw) : undefined;
103
+ };
104
+ // Per BCF 2.1 §"Component" (unchanged in 3.0) only `IfcGuid` is an
105
+ // attribute; `OriginatingSystem` and `AuthoringToolId` are child
106
+ // ELEMENTS — which is exactly what writeComponent() emits. Reading them
107
+ // as attributes matched nothing, so both fields were dropped from every
108
+ // archive, ifc-lite's own included. The attribute spellings are still
109
+ // accepted as a fallback so files from tools that emit the non-spec form
110
+ // keep working; the element form wins when both are present.
111
+ // `|| undefined`, not `??`: an EMPTY element (`<AuthoringToolId></...>`) has
112
+ // to read the same as an empty attribute and as an absent one. Without it
113
+ // the element form returned `''`, which survived the identity guard below
114
+ // with no identity at all, and `writeComponent` then wrote it back as a bare
115
+ // `<Component/>` that the reader discards. Three spellings of "nothing"
116
+ // disagreeing is the same defect this file exists to fix.
117
+ const authoringToolId = (extractElement(content, 'AuthoringToolId') || undefined) ?? attribute('AuthoringToolId');
118
+ const originatingSystem = (extractElement(content, 'OriginatingSystem') || undefined) ?? attribute('OriginatingSystem');
119
+ // A component needs some identity to be meaningful. `IfcGuid` is optional
120
+ // in the schema, so `AuthoringToolId` alone is a valid identification —
121
+ // and used to be discarded here because it was never found.
122
+ if (!ifcGuidMatch && authoringToolId === undefined) {
123
+ return undefined;
124
+ }
125
+ return {
126
+ ifcGuid: ifcGuidMatch?.[1] === undefined ? undefined : unescapeXml(ifcGuidMatch[1]),
127
+ authoringToolId,
128
+ originatingSystem,
129
+ };
130
+ }
131
+ /**
132
+ * Parse visibility settings
133
+ */
134
+ function parseVisibility(content) {
135
+ // Both branches, for the same reason as `COMPONENT_ELEMENT`: `<Exceptions>`
136
+ // and `<ViewSetupHints>` are optional, so `<Visibility DefaultVisibility=
137
+ // "false"/>` is schema-legal. Matching only the paired form made the whole
138
+ // `<Components>` block return undefined for such a file, dropping the
139
+ // selection and colouring with it. ifc-lite's own writer always emits the
140
+ // paired form, which is why no fixture reached this.
141
+ const visibilityMatch = content.match(/<Visibility[^>]*?\/>|<Visibility[^>]*>[\s\S]*?<\/Visibility>/);
142
+ if (!visibilityMatch)
143
+ return undefined;
144
+ // Read from the `<Visibility>` element, NOT from the enclosing `<Components>`
145
+ // string: a `DefaultVisibility` attribute on any earlier element (a
146
+ // `<Selection>` component, say) won the match and inverted the answer, so a
147
+ // file saying show-all hid everything.
148
+ const defaultVisMatch = visibilityMatch[0].match(/DefaultVisibility="([^"]+)"/);
149
+ const defaultVisibility = defaultVisMatch?.[1] !== 'false';
150
+ const exceptions = parseComponentList(visibilityMatch[0], 'Exceptions');
151
+ return {
152
+ defaultVisibility,
153
+ exceptions,
154
+ };
155
+ }
156
+ /**
157
+ * Parse coloring settings
158
+ */
159
+ function parseColoring(content) {
160
+ const coloringMatch = content.match(/<Coloring>([\s\S]*?)<\/Coloring>/);
161
+ if (!coloringMatch)
162
+ return undefined;
163
+ const colorings = [];
164
+ const colorMatches = coloringMatch[1].matchAll(/<Color\s+Color="([^"]+)"[^>]*>([\s\S]*?)<\/Color>/g);
165
+ for (const match of colorMatches) {
166
+ const color = match[1];
167
+ const components = parseComponentElements(match[2]);
168
+ if (components.length > 0) {
169
+ colorings.push({ color, components });
170
+ }
171
+ }
172
+ return colorings.length > 0 ? colorings : undefined;
173
+ }
174
+ /**
175
+ * Parse the `<ViewSetupHints>` element that sits directly under `<Components>`.
176
+ *
177
+ * All three attributes are optional xs:boolean; an absent one stays `undefined`
178
+ * rather than collapsing to `false`, because "the author did not say" and "the
179
+ * author said no" mean different things to a viewer applying the hints.
180
+ */
181
+ function parseViewSetupHints(content) {
182
+ const match = content.match(/<ViewSetupHints\b([^>]*)>/);
183
+ if (!match)
184
+ return undefined;
185
+ const attrs = match[1];
186
+ const flag = (name) => {
187
+ const raw = attrs.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
188
+ if (raw === undefined)
189
+ return undefined;
190
+ return raw === 'true' || raw === '1';
191
+ };
192
+ const spacesVisible = flag('SpacesVisible');
193
+ const spaceBoundariesVisible = flag('SpaceBoundariesVisible');
194
+ const openingsVisible = flag('OpeningsVisible');
195
+ if (spacesVisible === undefined
196
+ && spaceBoundariesVisible === undefined
197
+ && openingsVisible === undefined) {
198
+ return undefined;
199
+ }
200
+ return { spacesVisible, spaceBoundariesVisible, openingsVisible };
201
+ }
202
+ //# sourceMappingURL=reader-components.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reader-components.js","sourceRoot":"","sources":["../src/reader-components.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"reader.d.ts","sourceRoot":"","sources":["../src/reader.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,UAAU,EAoBX,MAAM,YAAY,CAAC;AAyIpB;;;;;;GAMG;AACH,wBAAsB,OAAO,CAC3B,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,WAAW,GAAG,UAAU,EAC5C,MAAM,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,GACpF,OAAO,CAAC,UAAU,CAAC,CA4CrB"}
1
+ {"version":3,"file":"reader.d.ts","sourceRoot":"","sources":["../src/reader.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EACV,UAAU,EAgBX,MAAM,YAAY,CAAC;AAyIpB;;;;;;GAMG;AACH,wBAAsB,OAAO,CAC3B,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,WAAW,GAAG,UAAU,EAC5C,MAAM,CAAC,EAAE;IAAE,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,GACpF,OAAO,CAAC,UAAU,CAAC,CA4CrB"}
package/dist/reader.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * Parses .bcfzip files into BCFProject structure
8
8
  */
9
9
  import JSZip from 'jszip';
10
+ import { parseComponents } from './reader-components.js';
11
+ import { extractElement, unescapeXml } from './xml-text.js';
10
12
  import { parseFiniteFloat } from './numeric.js';
11
13
  /**
12
14
  * Resource caps guarding against a malicious (zip-bomb) .bcfzip: a tiny
@@ -177,10 +179,14 @@ async function readProjectFile(zip, budget) {
177
179
  }
178
180
  const content = await readEntryCapped(projectFile, 'string', budget);
179
181
  const projectIdMatch = content.match(/ProjectId="([^"]+)"/);
180
- const nameMatch = content.match(/<Name>([^<]+)<\/Name>/);
182
+ // extractElement, not a raw regex: writeProjectFile escapes the name with
183
+ // escapeXml, so a raw match hands back the literal entities (`A &amp; B`) and
184
+ // the next export escapes them again. Every other element in this reader goes
185
+ // through extractElement precisely so the escape has an inverse.
186
+ const name = extractElement(content, 'Name');
181
187
  return {
182
188
  projectId: projectIdMatch?.[1],
183
- name: nameMatch?.[1],
189
+ name,
184
190
  };
185
191
  }
186
192
  /**
@@ -348,47 +354,31 @@ function parseHeaderFiles(markupContent) {
348
354
  function extractAttr(attrsString, attrName) {
349
355
  return attrsString.match(new RegExp(`\\b${attrName}="([^"]*)"`))?.[1];
350
356
  }
351
- /**
352
- * Extract a simple element value from XML
353
- *
354
- * Values are unescaped so writer.ts's escapeXml() round-trips correctly
355
- * (see escapeXml/unescapeXml regression: & < > " ' in titles/descriptions/
356
- * comments must come back exactly as written, not as literal entities).
357
- */
358
- function extractElement(content, elementName) {
359
- const match = content.match(new RegExp(`<${elementName}>([^<]*)<\\/${elementName}>`));
360
- return match?.[1] !== undefined ? unescapeXml(match[1]) : undefined;
361
- }
362
- /**
363
- * Unescape XML entities produced by writer.ts's escapeXml()
364
- *
365
- * &amp; must be decoded last so a literal "&lt;" written as "&amp;lt;"
366
- * doesn't get corrupted into "<" by an earlier pass.
367
- */
368
- function unescapeXml(str) {
369
- return str
370
- .replace(/&lt;/g, '<')
371
- .replace(/&gt;/g, '>')
372
- .replace(/&quot;/g, '"')
373
- .replace(/&apos;/g, "'")
374
- .replace(/&amp;/g, '&');
375
- }
376
357
  /**
377
358
  * Extract BIM snippet from topic content
378
359
  */
379
360
  function extractBimSnippet(content) {
380
- const match = content.match(/<BimSnippet\s+SnippetType="([^"]+)"[^>]*>([\s\S]*?)<\/BimSnippet>/);
361
+ // Attributes are captured generically and pulled out with extractAttr rather
362
+ // than anchoring SnippetType to first position: our own writer always emits
363
+ // it first, so an anchored regex round-trips our files and silently drops the
364
+ // entire snippet from a foreign tool's file that orders the two attributes
365
+ // the other way (see extractAttr's note on attribute order).
366
+ const match = content.match(/<BimSnippet\b([^>]*)>([\s\S]*?)<\/BimSnippet>/);
381
367
  if (!match)
382
368
  return undefined;
369
+ const snippetType = extractAttr(match[1], 'SnippetType');
370
+ if (!snippetType)
371
+ return undefined;
383
372
  // BCF 2.1 spells this `isExternal`, 3.0 `IsExternal` (same rename as the
384
373
  // Header `<File>` attribute in reader.ts's parseHeaderFiles); accept either
385
- // casing so a spec-correct 3.0 file's flag isn't silently read as false.
386
- const isExternalMatch = match[0].match(/\b[Ii]sExternal="([^"]+)"/);
374
+ // casing so a spec-correct 3.0 file's flag isn't silently read as false, and
375
+ // the xs:boolean `1`/`0` forms alongside `true`/`false`.
376
+ const isExternalRaw = match[1].match(/\b[Ii]sExternal="([^"]*)"/)?.[1];
387
377
  const reference = extractElement(match[2], 'Reference');
388
378
  const referenceSchema = extractElement(match[2], 'ReferenceSchema');
389
379
  return {
390
- snippetType: match[1],
391
- isExternal: isExternalMatch?.[1] === 'true',
380
+ snippetType,
381
+ isExternal: isExternalRaw === 'true' || isExternalRaw === '1',
392
382
  reference: reference || '',
393
383
  referenceSchema,
394
384
  };
@@ -522,6 +512,33 @@ async function parseViewpoints(zip, topicFolder, markupContent, budget) {
522
512
  snapshotFile: snapshotFileMatch?.[1],
523
513
  });
524
514
  }
515
+ // BCF 3.0 nests viewpoint entries inside <Topic>, wrapped in a plural
516
+ // <Viewpoints> container that (unlike 2.1's) carries no Guid itself; each
517
+ // entry is a singular <ViewPoint Guid="..."> -- capital P, distinct from
518
+ // the lowercase-p <Viewpoint Guid="..."/> a Comment uses to reference a
519
+ // viewpoint -- per buildingSMART/BCF-XML markup.xsd (release_3_0, Topic's
520
+ // Viewpoints element wraps `ViewPoint` entries) and confirmed against the
521
+ // buildingSMART/BCF-XML Test Cases/v3.0/Visualization/Perspective camera
522
+ // fixture, whose markup.bcf reads
523
+ // `<Viewpoints><ViewPoint Guid="f99eb1ed-...">`. The plural-with-Guid regex
524
+ // above can never match this shape (the wrapper has no Guid attribute), so
525
+ // without this pass every 3.0 file's markup-declared snapshot filename was
526
+ // silently dropped and resolution fell through to guessing our own
527
+ // Snapshot_<guid> naming convention -- which a third-party 3.0 file has no
528
+ // reason to follow.
529
+ const viewPointElementRegex = /<ViewPoint\b([^>]*)>([\s\S]*?)<\/ViewPoint>/g;
530
+ for (const match of markupContent.matchAll(viewPointElementRegex)) {
531
+ const guid = extractAttr(match[1], 'Guid');
532
+ if (!guid || viewpointInfoMap.has(guid))
533
+ continue;
534
+ const content = match[2];
535
+ const viewpointFileMatch = content.match(/<Viewpoint>([^<]+)<\/Viewpoint>/);
536
+ const snapshotFileMatch = content.match(/<Snapshot>([^<]+)<\/Snapshot>/);
537
+ viewpointInfoMap.set(guid, {
538
+ viewpointFile: viewpointFileMatch?.[1],
539
+ snapshotFile: snapshotFileMatch?.[1],
540
+ });
541
+ }
525
542
  // Also match self-closing viewpoint references
526
543
  const simpleViewpointRefs = markupContent.matchAll(/<Viewpoints\b([^>]*)\/>/g);
527
544
  for (const match of simpleViewpointRefs) {
@@ -677,8 +694,33 @@ function parsePerspectiveCamera(content) {
677
694
  cameraDirection: direction,
678
695
  cameraUpVector: upVector,
679
696
  fieldOfView: fov,
697
+ ...parseAspectRatio(cameraContent),
680
698
  };
681
699
  }
700
+ /**
701
+ * Parse the optional `<AspectRatio>` of either camera type.
702
+ *
703
+ * BCF 3.0's visinfo.xsd makes `AspectRatio` (a `PositiveDouble`) a REQUIRED
704
+ * child of both camera types; BCF 2.1 has no such element. It is read here for
705
+ * both, since the element's presence — not the archive's declared version — is
706
+ * what says whether there is a value to keep.
707
+ *
708
+ * Reading it matters beyond fidelity: the writer refuses to emit a 3.0 camera
709
+ * without one, so a 3.0 archive from another tool could otherwise be read and
710
+ * then not written back. Returned as a spread-able partial so an absent or
711
+ * unusable value leaves the property off entirely rather than setting it to
712
+ * `undefined`, and a non-positive value is dropped rather than carried into
713
+ * output the schema would reject.
714
+ */
715
+ function parseAspectRatio(cameraContent) {
716
+ const raw = extractElement(cameraContent, 'AspectRatio');
717
+ if (!raw)
718
+ return {};
719
+ const value = parseFiniteFloat(raw);
720
+ if (value === undefined || !(value > 0))
721
+ return {};
722
+ return { aspectRatio: value };
723
+ }
682
724
  /**
683
725
  * Parse orthogonal camera from viewpoint content
684
726
  */
@@ -705,6 +747,7 @@ function parseOrthogonalCamera(content) {
705
747
  cameraDirection: direction,
706
748
  cameraUpVector: upVector,
707
749
  viewToWorldScale: scale,
750
+ ...parseAspectRatio(cameraContent),
708
751
  };
709
752
  }
710
753
  /**
@@ -743,102 +786,6 @@ function parsePoint(content, elementName) {
743
786
  function parseDirection(content, elementName) {
744
787
  return parsePoint(content, elementName);
745
788
  }
746
- /**
747
- * Parse components (selection/visibility/coloring)
748
- */
749
- function parseComponents(content) {
750
- const componentsMatch = content.match(/<Components>([\s\S]*?)<\/Components>/);
751
- if (!componentsMatch)
752
- return undefined;
753
- const componentsContent = componentsMatch[1];
754
- // Parse selection
755
- const selection = parseComponentList(componentsContent, 'Selection');
756
- // Parse visibility
757
- const visibility = parseVisibility(componentsContent);
758
- // Parse coloring
759
- const coloring = parseColoring(componentsContent);
760
- if (!selection && !visibility && !coloring) {
761
- return undefined;
762
- }
763
- return {
764
- selection: selection?.length ? selection : undefined,
765
- visibility,
766
- coloring: coloring?.length ? coloring : undefined,
767
- };
768
- }
769
- /**
770
- * Parse a list of components
771
- */
772
- function parseComponentList(content, elementName) {
773
- const match = content.match(new RegExp(`<${elementName}>([\\s\\S]*?)<\\/${elementName}>`));
774
- if (!match)
775
- return undefined;
776
- const components = [];
777
- const componentMatches = match[1].matchAll(/<Component[^>]*(?:\/>|>[\s\S]*?<\/Component>)/g);
778
- for (const compMatch of componentMatches) {
779
- const component = parseComponent(compMatch[0]);
780
- if (component) {
781
- components.push(component);
782
- }
783
- }
784
- return components.length > 0 ? components : undefined;
785
- }
786
- /**
787
- * Parse a single component
788
- */
789
- function parseComponent(content) {
790
- const ifcGuidMatch = content.match(/IfcGuid="([^"]+)"/);
791
- const authoringToolIdMatch = content.match(/AuthoringToolId="([^"]+)"/);
792
- const originatingSystemMatch = content.match(/OriginatingSystem="([^"]+)"/);
793
- if (!ifcGuidMatch && !authoringToolIdMatch) {
794
- return undefined;
795
- }
796
- return {
797
- ifcGuid: ifcGuidMatch?.[1],
798
- authoringToolId: authoringToolIdMatch?.[1],
799
- originatingSystem: originatingSystemMatch?.[1],
800
- };
801
- }
802
- /**
803
- * Parse visibility settings
804
- */
805
- function parseVisibility(content) {
806
- const visibilityMatch = content.match(/<Visibility[^>]*>([\s\S]*?)<\/Visibility>/);
807
- if (!visibilityMatch)
808
- return undefined;
809
- const defaultVisMatch = content.match(/DefaultVisibility="([^"]+)"/);
810
- const defaultVisibility = defaultVisMatch?.[1] !== 'false';
811
- const exceptions = parseComponentList(visibilityMatch[1], 'Exceptions');
812
- return {
813
- defaultVisibility,
814
- exceptions,
815
- };
816
- }
817
- /**
818
- * Parse coloring settings
819
- */
820
- function parseColoring(content) {
821
- const coloringMatch = content.match(/<Coloring>([\s\S]*?)<\/Coloring>/);
822
- if (!coloringMatch)
823
- return undefined;
824
- const colorings = [];
825
- const colorMatches = coloringMatch[1].matchAll(/<Color\s+Color="([^"]+)"[^>]*>([\s\S]*?)<\/Color>/g);
826
- for (const match of colorMatches) {
827
- const color = match[1];
828
- const components = [];
829
- const componentMatches = match[2].matchAll(/<Component[^>]*(?:\/>|>[\s\S]*?<\/Component>)/g);
830
- for (const compMatch of componentMatches) {
831
- const component = parseComponent(compMatch[0]);
832
- if (component) {
833
- components.push(component);
834
- }
835
- }
836
- if (components.length > 0) {
837
- colorings.push({ color, components });
838
- }
839
- }
840
- return colorings.length > 0 ? colorings : undefined;
841
- }
842
789
  /**
843
790
  * Parse lines
844
791
  */
@@ -877,20 +824,30 @@ function parseClippingPlanes(content) {
877
824
  }
878
825
  /**
879
826
  * Parse bitmaps
827
+ *
828
+ * The two BCF versions diverge in shape (see writer.ts's writeBitmap/
829
+ * writeViewpointFiles for the write side of this):
830
+ * - BCF 3.0: entries sit inside a `<Bitmaps>` wrapper, and the per-entry
831
+ * format element is named `<Format>`. No tag inside an entry shares the
832
+ * entry's own name, so a plain non-greedy `<Bitmap>...</Bitmap>` match
833
+ * is unambiguous.
834
+ * - BCF 2.1: entries sit DIRECTLY under `<VisualizationInfo>` (no wrapper),
835
+ * and the format element is confusingly also named `<Bitmap>`, nested one
836
+ * level inside the entry (`<Bitmap><Bitmap>PNG</Bitmap><Reference>...`).
837
+ * A naive non-greedy `<Bitmap>...</Bitmap>` match on that shape terminates
838
+ * at the FIRST `</Bitmap>` it sees -- the inner format tag's closing tag,
839
+ * not the entry's -- and silently drops the rest of the entry. It must be
840
+ * matched with an explicit two-level pattern instead.
880
841
  */
881
842
  function parseBitmaps(content) {
882
843
  const bitmaps = [];
883
844
  const bitmapsMatch = content.match(/<Bitmaps>([\s\S]*?)<\/Bitmaps>/);
884
- if (!bitmapsMatch)
885
- return bitmaps;
886
- const bitmapMatches = bitmapsMatch[1].matchAll(/<Bitmap>([\s\S]*?)<\/Bitmap>/g);
887
- for (const match of bitmapMatches) {
888
- const format = extractElement(match[1], 'Format') || extractElement(match[1], 'Bitmap');
889
- const reference = extractElement(match[1], 'Reference');
890
- const location = parsePoint(match[1], 'Location');
891
- const normal = parseDirection(match[1], 'Normal');
892
- const up = parseDirection(match[1], 'Up');
893
- const height = extractElement(match[1], 'Height');
845
+ const pushBitmap = (format, body) => {
846
+ const reference = extractElement(body, 'Reference');
847
+ const location = parsePoint(body, 'Location');
848
+ const normal = parseDirection(body, 'Normal');
849
+ const up = parseDirection(body, 'Up');
850
+ const height = extractElement(body, 'Height');
894
851
  if (format && reference && location && normal && up && height) {
895
852
  bitmaps.push({
896
853
  format: format.toUpperCase() === 'JPG' ? 'JPG' : 'PNG',
@@ -901,6 +858,19 @@ function parseBitmaps(content) {
901
858
  height: parseFloat(height),
902
859
  });
903
860
  }
861
+ };
862
+ if (bitmapsMatch) {
863
+ // BCF 3.0 shape: <Bitmaps><Bitmap><Format>...</Format>...</Bitmap>...</Bitmaps>
864
+ for (const match of bitmapsMatch[1].matchAll(/<Bitmap>([\s\S]*?)<\/Bitmap>/g)) {
865
+ pushBitmap(extractElement(match[1], 'Format'), match[1]);
866
+ }
867
+ }
868
+ else {
869
+ // BCF 2.1 shape: <Bitmap><Bitmap>PNG</Bitmap>...</Bitmap>, unwrapped,
870
+ // directly under VisualizationInfo.
871
+ for (const match of content.matchAll(/<Bitmap>\s*<Bitmap>([\s\S]*?)<\/Bitmap>([\s\S]*?)<\/Bitmap>/g)) {
872
+ pushBitmap(match[1], match[2]);
873
+ }
904
874
  }
905
875
  return bitmaps;
906
876
  }