Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 25 additions & 14 deletions packages/core/src/core/Viewpoints/src/viewpoint.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Component IfcGuid="${globalID}" ${tagAttribute ?? ""} />`;
// AuthoringToolId is a child element in both BCF 2.1 and 3.0.
tags += tag
? `\n<Component IfcGuid="${XML.escape(globalID)}"><AuthoringToolId>${XML.escape(tag)}</AuthoringToolId></Component>`
: `\n<Component IfcGuid="${XML.escape(globalID)}" />`;
}
}
} else {
tags = [...this.selectionComponents]
.map((globalId) => `<Component IfcGuid="${globalId}" />`)
const guids =
from === "selection"
? this.selectionComponents
: this.exceptionComponents;
tags = [...guids]
.map((globalId) => `<Component IfcGuid="${XML.escape(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<Component IfcGuid="${globalId}" />`)
let tags = components
.map((globalId) => `\n<Component IfcGuid="${XML.escape(globalId)}" />`)
.join("\n");
colorTags += `<Color Color="${color}">\n${tags}\n</Color>`;
// BCF 3.0 wraps a colour's components in <Components>; 2.1 does not.
if (version === "3") tags = `<Components>${tags}\n</Components>`;
colorTags += `<Color Color="${XML.escape(color)}">\n${tags}\n</Color>`;
}

if (colorTags.length !== 0) {
Expand Down Expand Up @@ -796,32 +803,36 @@ export class Viewpoint {
<Z>${upVector.y}</Z>
</CameraUpVector>`;

const cameraRatioXML = `<AspectRatio>${this.camera.aspect_ratio}</AspectRatio>`;
// AspectRatio exists only in BCF 3.0, after ViewToWorldScale / FieldOfView.
const cameraRatioXML =
version === "3"
? `<AspectRatio>${this.camera.aspect_ratio}</AspectRatio>`
: "";

let cameraXML = "";
if ("view_to_world_scale" in this.camera) {
cameraXML = `<OrthogonalCamera>
${cameraViewpointXML}
${cameraDirectionXML}
${cameraUpVectorXML}
${cameraRatioXML}
<ViewToWorldScale>${this.camera.view_to_world_scale}</ViewToWorldScale>
${cameraRatioXML}
</OrthogonalCamera>`;
} else if ("field_of_view" in this.camera) {
cameraXML = `<PerspectiveCamera>
${cameraViewpointXML}
${cameraDirectionXML}
${cameraUpVectorXML}
${cameraRatioXML}
<FieldOfView>${this.camera.field_of_view}</FieldOfView>
${cameraRatioXML}
</PerspectiveCamera>`;
}

const viewSetupHints = `<ViewSetupHints SpacesVisible="${this.spacesVisible ?? false}" SpaceBoundariesVisible="${this.spaceBoundariesVisible ?? false}" OpeningsVisible="${this.openingsVisible ?? false}" />`;

const selectionTags = (await this.createComponentTags("selection")).trim();
const exceptionTags = (await this.createComponentTags("exception")).trim();
const colorTags = this.createColorTags();
const colorTags = this.createColorTags(version);

return `<?xml version="1.0" encoding="UTF-8"?>
<VisualizationInfo Guid="${this.guid}">
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/openbim/BCFTopics/bcf-export.test.ts
Original file line number Diff line number Diff line change
@@ -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 <priority>"]),
});
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<string, string>();
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 &amp; 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(
/<Color Color="FF0000">\s*<Components>\s*<Component IfcGuid=/,
);
// Exceptions hold the exception components, not the selection.
const exceptions = bcfv.match(/<Exceptions>([\s\S]*?)<\/Exceptions>/)![1];
expect(exceptions).toContain(HIDDEN);
expect(exceptions).not.toContain(GUID);
expect(bcfv.indexOf("<FieldOfView>")).toBeLessThan(
bcfv.indexOf("<AspectRatio>"),
);
});

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(/<Color Color="FF0000">\s*<Component IfcGuid=/);
expect(bcfv).not.toContain("<AspectRatio>");
});

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]);
},
);
});
43 changes: 23 additions & 20 deletions packages/core/src/openbim/BCFTopics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,7 @@ export class BCFTopics
"bcf.version",
`<?xml version="1.0" encoding="UTF-8"?>
<Version VersionId="${versionId}" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/buildingSMART/BCF-XML/${schemaRelease}/Schemas/version.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
</Version>`,
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>`,
);

for (const [guid, doc] of this.documents.entries()) {
Expand All @@ -316,8 +315,8 @@ export class BCFTopics
const { type, description } = doc;
if (type === "external") continue;
docTags.push(`<Document Guid="${guid}">
<Filename>${doc.fileName}</Filename>
${description ? `<Description>${description}</Description>` : ""}
<Filename>${XML.escape(doc.fileName)}</Filename>
${description ? `<Description>${XML.escape(description)}</Description>` : ""}
</Document>`);
// zip.file(`documents/${guid}`, doc.data);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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) => `<TopicType>${type}</TopicType>`)
.map((type) => tag("TopicType", type))
.join("\n");

const statuses = [...this.config.statuses]
.map((status) => `<TopicStatus>${status}</TopicStatus>`)
.map((status) => tag("TopicStatus", status))
.join("\n");

const priorities = [...this.config.priorities]
.map((priority) => `<Priority>${priority}</Priority>`)
.map((priority) => tag("Priority", priority))
.join("\n");

const labels = [...this.config.labels]
.map((label) => `<TopicLabel>${label}</TopicLabel>`)
.map((label) => tag("TopicLabel", label))
.join("\n");

const stages = [...this.config.stages]
.map((stage) => `<Stage>${stage}</Stage>`)
.map((stage) => tag("Stage", stage))
.join("\n");

const users = [...this.config.users]
.map((user) => `<User>${user}</User>`)
.map((user) => tag("User", user))
.join("\n");

return `
<?xml version="1.0" encoding="UTF-8"?>
<Extensions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="your-schema-location.xsd">
// The XML declaration must be the very first characters of the file.
return `<?xml version="1.0" encoding="UTF-8"?>
<Extensions>
${types.length !== 0 ? `<TopicTypes>\n${types}\n</TopicTypes>` : ""}
${statuses.length !== 0 ? `<TopicStatuses>\n${statuses}\n</TopicStatuses>` : ""}
${priorities.length !== 0 ? `<Priorities>\n${priorities}\n</Priorities>` : ""}
Expand Down Expand Up @@ -632,15 +632,18 @@ 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 <Components>; 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)
? Component
: [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[];
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/openbim/BCFTopics/src/Topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export class Topic implements BCFTopic {
return viewpoints.map((viewpoint) => {
const xmlData: Record<string, string> = {
$Guid: viewpoint.guid,
Viewpoint: `${viewpoint.title ?? viewpoint.guid}.bcfv`,
Viewpoint: `${viewpoint.guid}.bcfv`,
};
const snapshotData = manager.snapshots.get(viewpoint.snapshot);
if (snapshotData) {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/utils/xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

static builder = new XMLBuilder({
attributeNamePrefix: "$",
ignoreAttributes: false,
Expand Down
Loading