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
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": "^0.8.2",
"matrix-js-sdk": "catalog:",
"super-fast-md5": "catalog:",
"@floating-ui/dom": "catalog:",
Expand Down
92 changes: 92 additions & 0 deletions packages/base/stl-meta-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Header-only STL sniffer. Reads just enough to (a) confirm the bytes really are
// STL geometry and (b) surface the cheap descriptive facts that live in the
// file's head — it never scans the facet body. Binary STL carries its facet
// count and an optional COLOR= tag in the fixed 84-byte header; ASCII STL
// carries its solid name on the first line. The physical bounding box is
// intentionally NOT computed here: it requires walking every facet, and the
// live client-side viewer already derives true (transform-correct) dimensions
// from the geometry it loads, so index-time never pays for the scan. Pure JS
// (DataView/TextDecoder), kept in a plain `.ts` module (mirroring
// `png-meta-extractor.ts`) so it is directly unit-testable. Returns `undefined`
// for anything that isn't STL; the calling FileDef turns that into a
// `FileContentMismatchError` so the extractor falls back to the base FileDef.

export interface StlMetadata {
encoding: string;
solidName?: string;
binaryHeader?: string;
// Present for binary STL (read straight from the header's uint32). Absent for
// ASCII STL, where a count would require scanning the whole facet body.
facetCount?: number;
hasColorData: boolean;
}

// How many bytes off the top we decode to sniff an ASCII STL. The `solid` line
// and the first `facet normal` always sit at the very start, so a small prefix
// is enough to both validate and read the solid name.
const ASCII_SNIFF_BYTES = 4096;

// Turn the raw 80-byte binary header into a readable string: keep printable
// ASCII, collapse control/hi bytes to spaces, trim. Empty → undefined.
function decodeBinaryHeader(bytes: Uint8Array): string | undefined {
return (
new TextDecoder('latin1')
.decode(bytes.subarray(0, 80))
.split('')
.map((character) => {
let code = character.charCodeAt(0);
return code < 32 || code > 126 ? ' ' : character;
})
.join('')
.trim() || undefined
);
}

export function parseStl(
buf: ArrayBuffer,
): { stlMetadata: StlMetadata } | undefined {
let bytes = new Uint8Array(buf);
let view = new DataView(buf);

// Binary detection first: a binary STL is exactly 84 + 50 × facetCount bytes
// (some writers append trailing data, hence `<=`). This must precede the
// ASCII check because a binary header can itself begin with the word "solid".
let declaredBinaryFacets = bytes.length >= 84 ? view.getUint32(80, true) : 0;
let isBinary =
declaredBinaryFacets > 0 && 84 + declaredBinaryFacets * 50 <= bytes.length;

if (isBinary) {
let binaryHeader = decodeBinaryHeader(bytes);
return {
stlMetadata: {
encoding: 'binary',
binaryHeader,
facetCount: declaredBinaryFacets,
// Materialise/other writers flag per-vertex color in the header; the
// reliable, header-only signal is a COLOR= token. (The per-facet
// attribute-byte heuristic needed a full scan and over-reported, so
// it's dropped.)
hasColorData: /COLOR=/i.test(binaryHeader ?? ''),
},
};
}

// ASCII STL: validate against a small prefix (the `solid` keyword plus the
// first `facet normal`, which always appear at the top) and read the solid
// name from the first line.
let head = new TextDecoder().decode(
bytes.subarray(0, Math.min(bytes.length, ASCII_SNIFF_BYTES)),
);
if (!/^\s*solid\b/i.test(head) || !/\bfacet\s+normal\b/i.test(head)) {
return undefined;
}
let solidName =
head.match(/^\s*solid(?:\s+([^\r\n]+))?/i)?.[1]?.trim() || undefined;
return {
stlMetadata: {
encoding: 'ASCII',
solidName,
hasColorData: false,
},
};
}
186 changes: 186 additions & 0 deletions packages/base/stl-model-def.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import GlimmerComponent from '@glimmer/component';
import File3dIcon from '@cardstack/boxel-icons/file-3d';
import { byteStreamToUint8Array } from '@cardstack/runtime-common';
import { DEFAULT_FILE_SIZE_LIMIT_BYTES } from '@cardstack/runtime-common/constants';
import {
BaseDefComponent,
Component,
FieldDef,
StringField,
contains,
field,
} from './card-api';
import NumberField from './number';
import BooleanField from './boolean';
import {
FileContentMismatchError,
type ByteStream,
type SerializedFile,
} from './file-api';
import {
ThreeDModelDef,
ModelIsolatedBody,
ModelInspectorSection,
getExtension,
type ModelInspectorRow,
} from './three-d-model-def';
import { parseStl, type StlMetadata } from './stl-meta-extractor';

export class StlMetadataField extends FieldDef {
static displayName = 'STL Mesh Metadata';
static icon = File3dIcon;
@field encoding = contains(StringField);
@field solidName = contains(StringField);
@field binaryHeader = contains(StringField);
@field facetCount = contains(NumberField);
@field hasColorData = contains(BooleanField);

static embedded = class Embedded extends Component<typeof StlMetadataField> {
<template>
<dl class='stl-meta'>
{{#if @model.encoding}}<div><dt>Encoding</dt><dd
>{{@model.encoding}}</dd></div>{{/if}}
{{#if @model.solidName}}<div><dt>Solid</dt><dd
>{{@model.solidName}}</dd></div>{{/if}}
{{#if @model.facetCount}}<div><dt>Facets</dt><dd
>{{@model.facetCount}}</dd></div>{{/if}}
<div><dt>Color data</dt><dd>{{if
@model.hasColorData
'Present'
'None'
}}</dd></div>
{{#if @model.binaryHeader}}<div><dt>Header</dt><dd
class='mono'
>{{@model.binaryHeader}}</dd></div>{{/if}}
</dl>
<style scoped>
.stl-meta {
margin: 0;
display: grid;
gap: 5px;
}
.stl-meta div {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
}
dt {
color: var(--boxel-450);
font: 0.5625rem var(--boxel-monospace-font-family, monospace);
text-transform: uppercase;
}
dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.mono {
font-family: var(--boxel-monospace-font-family, monospace);
}
</style>
</template>
};
}

class StlIsolated extends GlimmerComponent<{ Args: { model: StlDef } }> {
get stlRows(): ModelInspectorRow[] {
let s = this.args.model.stlMetadata;
let rows: ModelInspectorRow[] = [];
if (!s) {
return rows;
}
if (s.encoding) {
rows.push({ term: 'Encoding', detail: s.encoding });
}
if (s.solidName) {
rows.push({ term: 'Solid', detail: s.solidName });
}
if (s.facetCount) {
rows.push({ term: 'Facets', detail: s.facetCount });
}
rows.push({
term: 'Color data',
detail: s.hasColorData ? 'Present' : 'None',
});
if (s.binaryHeader) {
rows.push({ term: 'Header', detail: s.binaryHeader });
}
return rows;
}

<template>
<ModelIsolatedBody @model={{@model}}>
{{#if @model.stlMetadata}}
<ModelInspectorSection @heading='STL mesh' @rows={{this.stlRows}} />
{{/if}}
</ModelIsolatedBody>
</template>
}

export class StlDef extends ThreeDModelDef {
static displayName = 'STL Mesh';
static icon = File3dIcon;
static acceptTypes = '.stl,model/stl,application/sla';

@field stlMetadata = contains(StlMetadataField);

static isolated: BaseDefComponent = StlIsolated;

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
options: {
contentHash?: string;
contentSize?: number;
// Backstop bound on the bytes we're willing to sniff at index time,
// threaded from the host (see `FileDefAttributesExtractor`); defaults to
// the realm's standard file-size limit (`DEFAULT_FILE_SIZE_LIMIT_BYTES`),
// the same ceiling the write path enforces, so the two stay in step.
fileSizeLimitBytes?: number;
} = {},
): Promise<
// `stlMetadata` is optional: over the size cap we skip the sniff and return
// only the base file attributes (see below).
SerializedFile<Partial<{ stlMetadata: StlMetadata }>>
> {
let extension = getExtension(url);
if (extension !== '.stl') {
throw new FileContentMismatchError(
`Expected .stl file extension, got "${extension || 'none'}"`,
);
}

let bytesPromise: Promise<Uint8Array> | undefined;
let memoizedStream = async () => {
bytesPromise ??= byteStreamToUint8Array(await getStream());
return bytesPromise;
};

let base = await super.extractAttributes(url, memoizedStream, options);
let bytes = await memoizedStream();
// Over the size cap, skip the sniff but keep the StlDef type — the file
// still renders via the live client-side viewer (which parses its own
// geometry); it just has an empty inspector panel and the cube placeholder.
// Do NOT throw FileContentMismatchError here: that would demote the file to
// a plain FileDef and lose the 3D card entirely.
let sizeCap = options.fileSizeLimitBytes ?? DEFAULT_FILE_SIZE_LIMIT_BYTES;
if (bytes.byteLength > sizeCap) {
console.warn(
`[StlDef] skipping metadata extraction for ${url}: ${bytes.byteLength} bytes exceeds cap ${sizeCap}`,
);
return { ...base };
}
let parsed = parseStl(
bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer,
);
if (!parsed) {
throw new FileContentMismatchError(
'File does not contain parseable STL geometry',
);
}
return { ...base, ...parsed };
}
}
Loading
Loading