diff --git a/packages/core/src/core/Viewpoints/src/viewpoint.ts b/packages/core/src/core/Viewpoints/src/viewpoint.ts
index ddfd4aa83..3ec01f527 100644
--- a/packages/core/src/core/Viewpoints/src/viewpoint.ts
+++ b/packages/core/src/core/Viewpoints/src/viewpoint.ts
@@ -1,7 +1,7 @@
/* eslint-disable camelcase */
import * as THREE from "three";
import * as FRAGS from "@thatopen/fragments";
-import { UUID } from "../../../utils";
+import { UUID, XML } from "../../../utils";
import {
ViewpointVector,
BCFViewpoint,
@@ -648,26 +648,33 @@ export class Viewpoint {
const globalID = await item.getGuid();
if (!globalID) continue;
const tag = (await item.getAttributes())?.getValue("Tag");
- let tagAttribute: string | null = null;
- if (tag) tagAttribute = `AuthoringToolId="${tag}"`;
- tags += `\n`;
+ // AuthoringToolId is a child element in both BCF 2.1 and 3.0.
+ tags += tag
+ ? `\n${XML.escape(tag)}`
+ : `\n`;
}
}
} else {
- tags = [...this.selectionComponents]
- .map((globalId) => ``)
+ const guids =
+ from === "selection"
+ ? this.selectionComponents
+ : this.exceptionComponents;
+ tags = [...guids]
+ .map((globalId) => ``)
.join(`\n`);
}
return tags;
}
- private createColorTags() {
+ private createColorTags(version: string) {
let colorTags = "";
for (const [color, components] of this.componentColors.entries()) {
- const tags = components
- .map((globalId) => `\n`)
+ let tags = components
+ .map((globalId) => `\n`)
.join("\n");
- colorTags += `\n${tags}\n`;
+ // BCF 3.0 wraps a colour's components in ; 2.1 does not.
+ if (version === "3") tags = `${tags}\n`;
+ colorTags += `\n${tags}\n`;
}
if (colorTags.length !== 0) {
@@ -796,7 +803,11 @@ export class Viewpoint {
${upVector.y}
`;
- const cameraRatioXML = `${this.camera.aspect_ratio}`;
+ // AspectRatio exists only in BCF 3.0, after ViewToWorldScale / FieldOfView.
+ const cameraRatioXML =
+ version === "3"
+ ? `${this.camera.aspect_ratio}`
+ : "";
let cameraXML = "";
if ("view_to_world_scale" in this.camera) {
@@ -804,16 +815,16 @@ export class Viewpoint {
${cameraViewpointXML}
${cameraDirectionXML}
${cameraUpVectorXML}
- ${cameraRatioXML}
${this.camera.view_to_world_scale}
+ ${cameraRatioXML}
`;
} else if ("field_of_view" in this.camera) {
cameraXML = `
${cameraViewpointXML}
${cameraDirectionXML}
${cameraUpVectorXML}
- ${cameraRatioXML}
${this.camera.field_of_view}
+ ${cameraRatioXML}
`;
}
@@ -821,7 +832,7 @@ export class Viewpoint {
const selectionTags = (await this.createComponentTags("selection")).trim();
const exceptionTags = (await this.createComponentTags("exception")).trim();
- const colorTags = this.createColorTags();
+ const colorTags = this.createColorTags(version);
return `
diff --git a/packages/core/src/openbim/BCFTopics/bcf-export.test.ts b/packages/core/src/openbim/BCFTopics/bcf-export.test.ts
new file mode 100644
index 000000000..34bd86b53
--- /dev/null
+++ b/packages/core/src/openbim/BCFTopics/bcf-export.test.ts
@@ -0,0 +1,109 @@
+import JSZip from "jszip";
+import { XMLValidator } from "fast-xml-parser";
+import { describe, expect, it } from "vitest";
+import { loadSourceBundle } from "../../../tests/source-bundle";
+
+// Needs the real `Components`: see `loadSourceBundle`.
+const OBC = await loadSourceBundle();
+
+const GUID = "2O2Fr$t4X7Zf8NOew3FLOH";
+const HIDDEN = "0K5f3$Pz95u8Wq_1mQ1Ab2";
+
+const exportZip = async (version: "2.1" | "3") => {
+ const components = new OBC.Components();
+ const bcf = components.get(OBC.BCFTopics);
+ bcf.setup({
+ version,
+ types: new Set(["Clash & Coordination"]),
+ labels: new Set(["MEP "]),
+ });
+ const topic = bcf.create({
+ title: "Duct & beam",
+ type: "Clash & Coordination",
+ });
+ bcf.documents.set("a3d5b9c2-1111-4222-8333-444455556666", {
+ type: "internal",
+ fileName: "R&D notes.txt",
+ description: "x < y",
+ data: new TextEncoder().encode("hi"),
+ } as any);
+ const vp = components.get(OBC.Viewpoints).create();
+ vp.title = "Level 2/North";
+ vp.selectionComponents.add(GUID);
+ vp.exceptionComponents.add(HIDDEN);
+ vp.componentColors.set("FF0000", [GUID]);
+ topic.viewpoints.add(vp.guid);
+ const blob = await bcf.export([topic]);
+ const data = new Uint8Array(await blob.arrayBuffer());
+ const zip = await JSZip.loadAsync(data);
+ const files = new Map();
+ for (const [name, file] of Object.entries(zip.files)) {
+ if (!file.dir && /(\.xml|\.bcf|\.bcfv|bcf\.version)$/.test(name)) {
+ files.set(name, await file.async("string"));
+ }
+ }
+ return { files, data, vp, OBC };
+};
+
+describe("BCFTopics export", () => {
+ it.each(["2.1", "3"] as const)(
+ "writes well-formed XML with escaped values (BCF %s)",
+ async (version) => {
+ const { files } = await exportZip(version);
+ for (const [name, xml] of files) {
+ expect(XMLValidator.validate(xml), name).toBe(true);
+ }
+ expect(files.get("extensions.xml")).toMatch(/^<\?xml /);
+ expect(files.get("extensions.xml")).toContain("Clash & Coordination");
+ },
+ );
+
+ it("names viewpoint files by guid, matching the markup", async () => {
+ const { files, vp } = await exportZip("3");
+ const bcfv = [...files.keys()].filter((n) => n.endsWith(".bcfv"));
+ expect(bcfv).toHaveLength(1);
+ expect(bcfv[0].split("/")).toHaveLength(2);
+ expect(bcfv[0].endsWith(`/${vp.guid}.bcfv`)).toBe(true);
+ const markup = [...files.entries()].find(([n]) =>
+ n.endsWith("markup.bcf"),
+ )![1];
+ expect(markup).toContain(`${vp.guid}.bcfv`);
+ });
+
+ it("writes the 3.0 visualization layout", async () => {
+ const { files } = await exportZip("3");
+ const bcfv = [...files.entries()].find(([n]) => n.endsWith(".bcfv"))![1];
+ expect(bcfv).toMatch(
+ /\s*\s*([\s\S]*?)<\/Exceptions>/)![1];
+ expect(exceptions).toContain(HIDDEN);
+ expect(exceptions).not.toContain(GUID);
+ expect(bcfv.indexOf("")).toBeLessThan(
+ bcfv.indexOf(""),
+ );
+ });
+
+ it("writes the 2.1 visualization layout", async () => {
+ const { files } = await exportZip("2.1");
+ const bcfv = [...files.entries()].find(([n]) => n.endsWith(".bcfv"))![1];
+ expect(bcfv).toMatch(/\s*");
+ });
+
+ it.each(["2.1", "3"] as const)(
+ "reads its own colouring and exceptions back (BCF %s)",
+ async (version) => {
+ const { data, OBC: lib } = await exportZip(version);
+ const components = new lib.Components();
+ const bcf = components.get(lib.BCFTopics);
+ bcf.setup({ version });
+ const { viewpoints } = await bcf.load(data);
+ const [vp] = viewpoints;
+ expect([...vp.componentColors.get("FF0000")!]).toEqual([GUID]);
+ expect([...vp.exceptionComponents]).toEqual([HIDDEN]);
+ expect([...vp.selectionComponents]).toEqual([GUID]);
+ },
+ );
+});
diff --git a/packages/core/src/openbim/BCFTopics/index.ts b/packages/core/src/openbim/BCFTopics/index.ts
index 6effe85dc..086fea130 100644
--- a/packages/core/src/openbim/BCFTopics/index.ts
+++ b/packages/core/src/openbim/BCFTopics/index.ts
@@ -298,8 +298,7 @@ export class BCFTopics
"bcf.version",
`
- `,
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>`,
);
for (const [guid, doc] of this.documents.entries()) {
@@ -316,8 +315,8 @@ export class BCFTopics
const { type, description } = doc;
if (type === "external") continue;
docTags.push(`
- ${doc.fileName}
- ${description ? `${description}` : ""}
+ ${XML.escape(doc.fileName)}
+ ${description ? `${XML.escape(description)}` : ""}
`);
// zip.file(`documents/${guid}`, doc.data);
}
@@ -345,11 +344,9 @@ export class BCFTopics
for (const viewpointID of topic.viewpoints) {
const viewpoint = viewpoints.list.get(viewpointID);
if (!viewpoint) continue;
- const viewpointFileName = viewpoint.title ?? viewpoint.guid;
- topicFolder.file(
- `${viewpointFileName}.bcfv`,
- await viewpoint.serialize(),
- );
+ // The markup references this name (Topic.serialize); a title could
+ // hold "/" or repeat, so the guid is used.
+ topicFolder.file(`${viewpoint.guid}.bcfv`, await viewpoint.serialize());
const snapshotData = viewpoints.snapshots.get(viewpoint.snapshot);
if (!snapshotData) continue;
@@ -373,33 +370,36 @@ export class BCFTopics
}
private serializeExtensions() {
+ const tag = (name: string, value: string) =>
+ `<${name}>${XML.escape(value)}${name}>`;
+
const types = [...this.config.types]
- .map((type) => `${type}`)
+ .map((type) => tag("TopicType", type))
.join("\n");
const statuses = [...this.config.statuses]
- .map((status) => `${status}`)
+ .map((status) => tag("TopicStatus", status))
.join("\n");
const priorities = [...this.config.priorities]
- .map((priority) => `${priority}`)
+ .map((priority) => tag("Priority", priority))
.join("\n");
const labels = [...this.config.labels]
- .map((label) => `${label}`)
+ .map((label) => tag("TopicLabel", label))
.join("\n");
const stages = [...this.config.stages]
- .map((stage) => `${stage}`)
+ .map((stage) => tag("Stage", stage))
.join("\n");
const users = [...this.config.users]
- .map((user) => `${user}`)
+ .map((user) => tag("User", user))
.join("\n");
- return `
-
-
+ // The XML declaration must be the very first characters of the file.
+ return `
+
${types.length !== 0 ? `\n${types}\n` : ""}
${statuses.length !== 0 ? `\n${statuses}\n` : ""}
${priorities.length !== 0 ? `\n${priorities}\n` : ""}
@@ -632,7 +632,10 @@ export class BCFTopics
? Coloring.Color
: [Coloring.Color];
for (const colorData of colors) {
- const { Color, Component } = colorData;
+ const { Color } = colorData;
+ // BCF 3.0 wraps the components in ; 2.1 does not.
+ const Component =
+ colorData.Components?.Component ?? colorData.Component ?? [];
if (!(Color.length === 6 || Color.length === 8)) continue;
const color = Color.length === 6 ? Color : Color.slice(2);
const colorComponents = Array.isArray(Component)
@@ -640,7 +643,7 @@ export class BCFTopics
: [Component];
const guids = colorComponents
.map((component: any) => {
- if (!component.IfcGuid) return null;
+ if (!component?.IfcGuid) return null;
return { ifc_guid: component.IfcGuid };
})
.filter((data: any) => data !== null) as ViewpointComponent[];
diff --git a/packages/core/src/openbim/BCFTopics/src/Topic.ts b/packages/core/src/openbim/BCFTopics/src/Topic.ts
index 2aaf2b42a..1c3be0bf6 100644
--- a/packages/core/src/openbim/BCFTopics/src/Topic.ts
+++ b/packages/core/src/openbim/BCFTopics/src/Topic.ts
@@ -277,7 +277,7 @@ export class Topic implements BCFTopic {
return viewpoints.map((viewpoint) => {
const xmlData: Record = {
$Guid: viewpoint.guid,
- Viewpoint: `${viewpoint.title ?? viewpoint.guid}.bcfv`,
+ Viewpoint: `${viewpoint.guid}.bcfv`,
};
const snapshotData = manager.snapshots.get(viewpoint.snapshot);
if (snapshotData) {
diff --git a/packages/core/src/utils/xml.ts b/packages/core/src/utils/xml.ts
index ae5328430..fb525b477 100644
--- a/packages/core/src/utils/xml.ts
+++ b/packages/core/src/utils/xml.ts
@@ -15,6 +15,19 @@ export class XML {
trimValues: true,
});
+ /**
+ * Escapes a value for XML text or a double-quoted attribute, so a value
+ * holding `&`, `<`, `>` or quotes is written as data, not markup.
+ */
+ static escape(value: unknown) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
static builder = new XMLBuilder({
attributeNamePrefix: "$",
ignoreAttributes: false,