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)}`; + 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,