Skip to content
Closed
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
210 changes: 210 additions & 0 deletions packages/base/3mf-file-def.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { byteStreamToUint8Array } from '@cardstack/runtime-common';
import Cube3dSphereIcon from '@cardstack/boxel-icons/cube-3d-sphere';
import {
BaseDefComponent,
Component,
StringField,
contains,
field,
} from './card-api';
import BooleanField from './boolean';
import {
FileDef,
type ByteStream,
type SerializedFile,
} from './file-api';
import { extract3mfMetadata } from './3mf-meta-extractor';

type ThreeMfExtra = {
title?: string;
designer?: string;
description?: string;
license?: string;
unit?: string;
hasThumbnail: boolean;
};

function threeMfTitle(
model: { title?: string | null; name?: string | null } | null | undefined,
): string {
return model?.title ?? model?.name ?? 'Untitled 3MF model';
}

class Isolated extends Component<typeof ThreeMfDef> {
get title() {
return threeMfTitle(this.args.model);
}
<template>
<article class='threemf-isolated' data-test-threemf-isolated>
<header class='threemf-isolated__header'>
<Cube3dSphereIcon
class='threemf-isolated__icon'
width='32'
height='32'
/>
<div class='threemf-isolated__heading'>
<div class='threemf-isolated__title'>{{this.title}}</div>
{{#if @model.designer}}
<div class='threemf-isolated__designer'>by {{@model.designer}}</div>
{{/if}}
</div>
</header>
{{#if @model.description}}
<p class='threemf-isolated__description'>{{@model.description}}</p>
{{/if}}
<dl class='threemf-isolated__facts'>
{{#if @model.unit}}
<div class='threemf-isolated__fact'>
<dt>Units</dt>
<dd>{{@model.unit}}</dd>
</div>
{{/if}}
{{#if @model.license}}
<div class='threemf-isolated__fact'>
<dt>License</dt>
<dd>{{@model.license}}</dd>
</div>
{{/if}}
</dl>
</article>
<style scoped>
.threemf-isolated {
display: flex;
flex-direction: column;
gap: var(--boxel-sp);
padding: var(--boxel-sp-lg);
max-width: 100%;
}
.threemf-isolated__header {
display: flex;
align-items: center;
gap: var(--boxel-sp);
}
.threemf-isolated__icon {
color: var(--boxel-600);
flex-shrink: 0;
}
.threemf-isolated__heading {
min-width: 0;
flex: 1;
}
.threemf-isolated__title {
font-weight: 600;
color: var(--boxel-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.threemf-isolated__designer {
color: var(--boxel-600);
font-size: var(--boxel-font-sm);
}
.threemf-isolated__description {
color: var(--boxel-700);
margin: 0;
}
.threemf-isolated__facts {
display: flex;
flex-wrap: wrap;
gap: var(--boxel-sp) var(--boxel-sp-lg);
margin: 0;
}
.threemf-isolated__fact {
display: flex;
flex-direction: column;
}
.threemf-isolated__fact dt {
color: var(--boxel-500);
font-size: var(--boxel-font-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.threemf-isolated__fact dd {
color: var(--boxel-900);
margin: 0;
}
</style>
</template>
}

class Embedded extends Component<typeof ThreeMfDef> {
get title() {
return threeMfTitle(this.args.model);
}
<template>
<div class='threemf-embedded' data-test-threemf-embedded>
<Cube3dSphereIcon
class='threemf-embedded__icon'
width='20'
height='20'
/>
<div class='threemf-embedded__meta'>
<div class='threemf-embedded__title'>{{this.title}}</div>
{{#if @model.designer}}
<div class='threemf-embedded__designer'>by {{@model.designer}}</div>
{{/if}}
</div>
</div>
<style scoped>
.threemf-embedded {
display: flex;
align-items: center;
gap: var(--boxel-sp-xs);
min-width: 0;
}
.threemf-embedded__icon {
color: var(--boxel-600);
flex-shrink: 0;
}
.threemf-embedded__meta {
min-width: 0;
}
.threemf-embedded__title {
font-weight: 600;
color: var(--boxel-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.threemf-embedded__designer {
color: var(--boxel-600);
font-size: var(--boxel-font-sm);
}
</style>
</template>
}

// 3MF (3D Manufacturing Format) — the ZIP+XML successor to STL used for 3D
// printing. Unlike STL, it self-describes with title/designer/description/
// license text that is not in the filename, so extracting it delivers real
// full-text search value. See `3mf-meta-extractor.ts` for the bounded parse.
export class ThreeMfDef extends FileDef {
static displayName = '3MF Model';
static icon = Cube3dSphereIcon;
static acceptTypes = '.3mf,model/3mf';

@field title = contains(StringField);
@field designer = contains(StringField);
@field description = contains(StringField);
@field license = contains(StringField);
@field unit = contains(StringField);
@field hasThumbnail = contains(BooleanField);

static isolated: BaseDefComponent = Isolated;
static embedded: BaseDefComponent = Embedded;

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
options: { contentHash?: string; contentSize?: number } = {},
): Promise<SerializedFile<ThreeMfExtra>> {
let base = await super.extractAttributes(url, getStream, options);
// 3MF is a ZIP container, so we need the whole file in memory to unzip it;
// `extract3mfMetadata` then bounds the XML parse to the model header.
let bytes = await byteStreamToUint8Array(await getStream());
let metadata = extract3mfMetadata(bytes);
return { ...base, ...metadata };
}
}

export default ThreeMfDef;
127 changes: 127 additions & 0 deletions packages/base/3mf-meta-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { unzipSync, strFromU8 } from 'fflate';
import { FileContentMismatchError } from './file-api';

// A 3MF file is an OPC package: a ZIP archive of XML parts. The part we read is
// the 3D model (conventionally `3D/3dmodel.model`), whose `<model>` root carries
// a `unit` attribute and a `<metadata>` block — both sitting *above* the
// `<resources>` geometry. Everything a user searches for (title, designer,
// description, license) lives in that header block and is absent from the
// filename, which is why 3MF earns its own FileDef.

export interface ThreeMfMetadata {
title?: string;
designer?: string;
description?: string;
license?: string;
unit?: string;
hasThumbnail: boolean;
}

// The model part holds all the geometry, so it can be large. We only need the
// header, which is small and at the top — decode at most this many bytes before
// parsing so the work stays bounded on the indexing hot path regardless of mesh
// size.
const MODEL_HEADER_MAX_BYTES = 262_144; // 256 KB

// Case-insensitive, namespace-prefix tolerant (`<m:model …>`) matchers. These
// live in a plain `.ts` module (not `.gts`) so regex literals are safe from the
// content-tag lexer quirks that affect `.gts` files.
const MODEL_PART_RE = /\.model$/i;
const GEOMETRY_START_RE = /<(?:\w+:)?(?:resources|build)\b/i;
const MODEL_UNIT_RE =
/<(?:\w+:)?model\b[^>]*\bunit\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
const METADATA_RE =
/<(?:\w+:)?metadata\b[^>]*\bname\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/(?:\w+:)?metadata>/gi;
const THUMBNAIL_RE = /thumbnail/i;
const IMAGE_EXT_RE = /\.(?:png|jpe?g)$/i;

// 3MF's default unit when the `<model>` element omits it (spec §the model
// element): millimeter. We fill it in so the field is always meaningful.
const DEFAULT_UNIT = 'millimeter';

function decodeXmlEntities(value: string): string {
return value
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
.replace(/&amp;/g, '&');
}

function firstMatch(...values: (string | undefined)[]): string | undefined {
for (let value of values) {
if (value != null && value.trim() !== '') {
return value;
}
}
return undefined;
}

export function extract3mfMetadata(bytes: Uint8Array): ThreeMfMetadata {
// Collect every entry name via the filter callback (called for all entries)
// while only inflating the model part — the thumbnail/texture parts stay
// compressed. The names give us the thumbnail signal for free.
let names: string[] = [];
let files: Record<string, Uint8Array>;
try {
files = unzipSync(bytes, {
filter: (file) => {
names.push(file.name);
return MODEL_PART_RE.test(file.name);
},
});
} catch {
throw new FileContentMismatchError('3MF file is not a valid ZIP archive');
}

let modelName =
Object.keys(files).find((name) => name === '3D/3dmodel.model') ??
Object.keys(files).find((name) => MODEL_PART_RE.test(name));
if (!modelName) {
throw new FileContentMismatchError(
'3MF archive has no 3D model part (no *.model entry)',
);
}

let modelBytes = files[modelName];
let header = strFromU8(modelBytes.subarray(0, MODEL_HEADER_MAX_BYTES));
let geometryStart = header.search(GEOMETRY_START_RE);
if (geometryStart >= 0) {
header = header.slice(0, geometryStart);
}

let unitMatch = header.match(MODEL_UNIT_RE);
let unit = firstMatch(unitMatch?.[1], unitMatch?.[2]) ?? DEFAULT_UNIT;

let metadata: Record<string, string> = {};
let match: RegExpExecArray | null;
while ((match = METADATA_RE.exec(header))) {
let name = firstMatch(match[1], match[2]);
if (!name) {
continue;
}
let value = decodeXmlEntities((match[3] ?? '').trim());
if (value !== '') {
metadata[name] = value;
}
}
// Reset lastIndex so the shared /g regex is safe on the next call.
METADATA_RE.lastIndex = 0;

let hasThumbnail = names.some(
(name) => THUMBNAIL_RE.test(name) && IMAGE_EXT_RE.test(name),
);

return {
title: metadata['Title'],
designer: metadata['Designer'],
description: metadata['Description'],
license: firstMatch(metadata['LicenseTerms'], metadata['Copyright']),
unit,
hasThumbnail,
};
}
1 change: 1 addition & 0 deletions packages/base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"ember-modifier": "^3.2.1",
"ember-resources": "catalog:",
"ember-template-lint": "catalog:",
"fflate": "catalog:",
"matrix-js-sdk": "catalog:",
"super-fast-md5": "catalog:",
"@floating-ui/dom": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions packages/host/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"ethers": "catalog:",
"eventemitter3": "catalog:",
"fast-json-stable-stringify": "catalog:",
"fflate": "catalog:",
"filesize": "catalog:",
"flat": "catalog:",
"glimmer-scoped-css": "catalog:",
Expand Down
Loading
Loading