From cc472771e6948a20b190217fb267a82f09f86f68 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 31 Jul 2026 14:08:40 +0700 Subject: [PATCH 01/13] Add StlDef and ThreeMfDef FileDef subclasses for 3D model files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a shared ModelDef base (extends FileDef) with two leaves: StlDef (.stl) and ThreeMfDef (.3mf). Each extracts format metadata in extractAttributes (pure-JS parsers ported from the handoff realm) and renders a deterministic inline SVG silhouette across atom/fitted/ embedded/isolated — no WebGL, prerender-safe. - ModelDef: shared Model3DInfoField scene facts, silhouette preview, and a thumbnailUrl seam for the shaded-PNG follow-up (CS-12401). - StlDef: ASCII/binary detection, facet/normal/degenerate counts, color-data flag, bounding-box extents. - ThreeMfDef: OPC ZIP unzip (fflate), model-part + slicer-config parse (objects, materials, plates, print parts, extruders, bounds). - Register .stl/.3mf in file-def-code-ref; add fflate dependency. Shaded raster PNG previews are intentionally deferred to CS-12401; the SVG silhouette is the preview until then. CS-12053, CS-12054 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/model-file-def.gts | 332 ++++++++++++ packages/base/package.json | 1 + packages/base/stl-model-def.gts | 339 ++++++++++++ packages/base/three-mf-def.gts | 486 ++++++++++++++++++ .../host/tests/unit/file-def-code-ref-test.ts | 14 + packages/runtime-common/file-def-code-ref.ts | 2 + pnpm-lock.yaml | 8 + 7 files changed, 1182 insertions(+) create mode 100644 packages/base/model-file-def.gts create mode 100644 packages/base/stl-model-def.gts create mode 100644 packages/base/three-mf-def.gts diff --git a/packages/base/model-file-def.gts b/packages/base/model-file-def.gts new file mode 100644 index 00000000000..230957eb4ff --- /dev/null +++ b/packages/base/model-file-def.gts @@ -0,0 +1,332 @@ +import GlimmerComponent from '@glimmer/component'; +import File3dIcon from '@cardstack/boxel-icons/file-3d'; +import { + BaseDefComponent, + FieldDef, + FileDef, + StringField, + contains, + field, +} from './card-api'; +import NumberField from './number'; + +// Generic scene facts shared by every 3D model format (STL, 3MF, and later +// GLB/glTF). Format-specific facts (STL facet counts, 3MF print parts) live on +// the leaf's own metadata FieldDef, not here. +export class Model3DInfoField extends FieldDef { + static displayName = '3D Scene'; + static icon = File3dIcon; + @field meshes = contains(NumberField); + @field materials = contains(NumberField); + @field vertices = contains(NumberField); + @field triangles = contains(NumberField); + @field generator = contains(StringField); +} + +// Deterministic, prerender-safe silhouette. Returns an SVG path `d` string (no +// angle-bracket markup, so it is content-tag safe) describing the 12 edges of a +// box whose proportions match the model's bounding-box extents. The template +// draws it as real / elements. This is the whole preview until the +// shaded-PNG job (CS-12401) populates `thumbnailUrl`. +export function silhouettePath(x = 1, y = 1, z = 1): string { + let max = Math.max(x, y, z, 1e-6); + let w = x / max; + let h = y / max; + let d = z / max; + let ox = 120; + let oy = 128; + let s = 62; + let project = (px: number, py: number, pz: number): [number, number] => { + let ax = (px - 0.5) * w; + let ay = (py - 0.5) * h; + let az = (pz - 0.5) * d; + return [ox + (ax - az) * s, oy + (ax + az) * (s / 2) - ay * s]; + }; + let corners: [number, number][] = [ + project(0, 0, 0), + project(1, 0, 0), + project(1, 0, 1), + project(0, 0, 1), + project(0, 1, 0), + project(1, 1, 0), + project(1, 1, 1), + project(0, 1, 1), + ]; + let edges: [number, number][] = [ + [0, 1], + [1, 2], + [2, 3], + [3, 0], + [4, 5], + [5, 6], + [6, 7], + [7, 4], + [0, 4], + [1, 5], + [2, 6], + [3, 7], + ]; + let fmt = (n: number): string => (Math.round(n * 100) / 100).toString(); + return edges + .map(([a, b]) => { + let from = corners[a]; + let to = corners[b]; + return `M${fmt(from[0])},${fmt(from[1])}L${fmt(to[0])},${fmt(to[1])}`; + }) + .join(' '); +} + +// Shared hero: the generated PNG (`thumbnailUrl`, populated by CS-12401) when +// present, otherwise the SVG silhouette. Kept as a standalone component so both +// the base and the leaf isolated templates reuse it without duplication. +export class ModelPreview extends GlimmerComponent<{ + Args: { model: ModelDef }; + Element: HTMLElement; +}> { + get path() { + let e = this.args.model.extents; + return silhouettePath(e.x, e.y, e.z); + } + +
+ {{#if @model.thumbnailUrl}} + {{@model.name}} + {{else}} + + + + {{/if}} +
+ + +} + +// Generic 3D scene facts rail, styled like the handoff realm's inspector rail. +class ModelSceneRail extends GlimmerComponent<{ + Args: { model: ModelDef }; +}> { + +} + +// Base isolated body (hero + scene rail). Leaf isolated templates render this, +// then append their format-specific metadata rail after it. +export class ModelIsolatedBody extends GlimmerComponent<{ + Args: { model: ModelDef }; +}> { + +} + +class ModelAtomTemplate extends GlimmerComponent<{ Args: { model: ModelDef } }> { + +} + +class ModelFittedTemplate extends GlimmerComponent<{ + Args: { model: ModelDef }; +}> { + +} + +class ModelEmbeddedTemplate extends GlimmerComponent<{ + Args: { model: ModelDef }; +}> { + +} + +class ModelIsolatedTemplate extends GlimmerComponent<{ + Args: { model: ModelDef }; +}> { + +} + +// Base FileDef subclass for 3D model formats. Owns the shared scene metadata, +// the thumbnail seam, and the four format templates. Leaves (StlDef, +// ThreeMfDef) add their format-specific metadata field + extraction and may +// override `isolated` to append that metadata below the shared body. +export class ModelDef extends FileDef { + static displayName = '3D Model'; + static icon = File3dIcon; + + @field model3d = contains(Model3DInfoField); + + // Convention path for a generated raster preview, populated by CS-12401 + // (shaded-PNG job). Empty in this PR, so the templates render the SVG + // silhouette. When set, the templates render . + @field thumbnailUrl = contains(StringField); + + // Bounding-box extents used to draw the silhouette. Leaves override this to + // read their own extracted sizes. + get extents(): { x: number; y: number; z: number } { + return { x: 1, y: 1, z: 1 }; + } + + static isolated: BaseDefComponent = ModelIsolatedTemplate; + static embedded: BaseDefComponent = ModelEmbeddedTemplate; + static fitted: BaseDefComponent = ModelFittedTemplate; + static atom: BaseDefComponent = ModelAtomTemplate; +} diff --git a/packages/base/package.json b/packages/base/package.json index 542b669c508..fce09db8dec 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -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:", diff --git a/packages/base/stl-model-def.gts b/packages/base/stl-model-def.gts new file mode 100644 index 00000000000..00f33f035c1 --- /dev/null +++ b/packages/base/stl-model-def.gts @@ -0,0 +1,339 @@ +import GlimmerComponent from '@glimmer/component'; +import File3dIcon from '@cardstack/boxel-icons/file-3d'; +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +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 { ModelDef, ModelIsolatedBody } from './model-file-def'; + +interface StlMetadata { + encoding: string; + solidName?: string; + binaryHeader?: string; + facetCount: number; + normalCount: number; + degenerateFacetCount: number; + hasColorData: boolean; + sizeX: number; + sizeY: number; + sizeZ: number; +} + +interface Model3dData { + meshes: number; + materials: number; + vertices: number; + triangles: number; + generator?: string; +} + +function getExtension(url: string): string { + try { + let parsed = new URL(url); + let name = parsed.pathname.split('/').pop() ?? ''; + let dot = name.lastIndexOf('.'); + return dot === -1 ? '' : name.slice(dot).toLowerCase(); + } catch { + let dot = url.lastIndexOf('.'); + return dot === -1 ? '' : url.slice(dot).toLowerCase(); + } +} + +// Bounded STL parser. Pure JS (DataView/TextDecoder) so it runs identically in +// the browser, the indexer, and the prerender pass. Ported from the handoff +// realm's metadata-extractor. Produces both the generic scene facts (model3d) +// and STL-specific diagnostics (stlMetadata). +function parseStl( + buf: ArrayBuffer, +): { model3d: Model3dData; stlMetadata: StlMetadata } | undefined { + let bytes = new Uint8Array(buf); + let view = new DataView(buf); + let declaredBinaryFacets = bytes.length >= 84 ? view.getUint32(80, true) : 0; + let expectedBinarySize = 84 + declaredBinaryFacets * 50; + let isBinary = declaredBinaryFacets > 0 && expectedBinarySize <= bytes.length; + let vertices: number[][] = []; + let normalCount = 0; + let facetCount = 0; + let hasColorData = false; + let solidName: string | undefined; + let binaryHeader: string | undefined; + + if (isBinary) { + binaryHeader = + new TextDecoder('latin1') + .decode(bytes.subarray(0, 80)) + .split('') + .map((character) => + character.charCodeAt(0) < 32 || character.charCodeAt(0) > 126 + ? ' ' + : character, + ) + .join('') + .trim() || undefined; + hasColorData = /COLOR=/i.test(binaryHeader ?? ''); + facetCount = Math.min( + declaredBinaryFacets, + Math.floor((bytes.length - 84) / 50), + ); + for (let facet = 0; facet < facetCount; facet++) { + let offset = 84 + facet * 50; + let nx = view.getFloat32(offset, true); + let ny = view.getFloat32(offset + 4, true); + let nz = view.getFloat32(offset + 8, true); + if ( + Number.isFinite(nx + ny + nz) && + Math.abs(nx) + Math.abs(ny) + Math.abs(nz) > 1e-12 + ) { + normalCount++; + } + for (let vertex = 0; vertex < 3; vertex++) { + let p = offset + 12 + vertex * 12; + vertices.push([ + view.getFloat32(p, true), + view.getFloat32(p + 4, true), + view.getFloat32(p + 8, true), + ]); + } + hasColorData ||= view.getUint16(offset + 48, true) !== 0; + } + } else { + let text = new TextDecoder().decode(bytes); + if (!/\bfacet\s+normal\b/i.test(text) || !/\bvertex\s+[-+\d.]/i.test(text)) { + return undefined; + } + solidName = text.match(/^\s*solid(?:\s+([^\r\n]+))?/i)?.[1]?.trim() || undefined; + let normalPattern = + /\bfacet\s+normal\s+([-+\d.eE]+)\s+([-+\d.eE]+)\s+([-+\d.eE]+)/gi; + for (let match; (match = normalPattern.exec(text)); ) { + facetCount++; + let magnitude = + Math.abs(Number(match[1])) + + Math.abs(Number(match[2])) + + Math.abs(Number(match[3])); + if (Number.isFinite(magnitude) && magnitude > 1e-12) { + normalCount++; + } + } + let vertexPattern = /\bvertex\s+([-+\d.eE]+)\s+([-+\d.eE]+)\s+([-+\d.eE]+)/gi; + for (let match; (match = vertexPattern.exec(text)); ) { + vertices.push([Number(match[1]), Number(match[2]), Number(match[3])]); + } + } + + let finiteVertices = vertices.filter((vertex) => vertex.every(Number.isFinite)); + if (!facetCount || finiteVertices.length < 3) { + return undefined; + } + let mins = [Infinity, Infinity, Infinity]; + let maxs = [-Infinity, -Infinity, -Infinity]; + for (let vertex of finiteVertices) { + for (let axis = 0; axis < 3; axis++) { + mins[axis] = Math.min(mins[axis], vertex[axis]); + maxs[axis] = Math.max(maxs[axis], vertex[axis]); + } + } + let degenerateFacetCount = 0; + for (let i = 0; i + 2 < finiteVertices.length; i += 3) { + let a = finiteVertices[i]; + let b = finiteVertices[i + 1]; + let c = finiteVertices[i + 2]; + let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let cross = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + if (cross[0] ** 2 + cross[1] ** 2 + cross[2] ** 2 < 1e-20) { + degenerateFacetCount++; + } + } + let round = (value: number) => Math.round(value * 1_000_000) / 1_000_000; + return { + model3d: { + meshes: 1, + materials: hasColorData ? 1 : 0, + vertices: finiteVertices.length, + triangles: facetCount, + generator: solidName ?? binaryHeader, + }, + stlMetadata: { + encoding: isBinary ? 'binary' : 'ASCII', + solidName, + binaryHeader, + facetCount, + normalCount, + degenerateFacetCount, + hasColorData, + sizeX: round(maxs[0] - mins[0]), + sizeY: round(maxs[1] - mins[1]), + sizeZ: round(maxs[2] - mins[2]), + }, + }; +} + +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 normalCount = contains(NumberField); + @field degenerateFacetCount = contains(NumberField); + @field hasColorData = contains(BooleanField); + @field sizeX = contains(NumberField); + @field sizeY = contains(NumberField); + @field sizeZ = contains(NumberField); + + static embedded = class Embedded extends Component { + + }; +} + +class StlIsolated extends GlimmerComponent<{ Args: { model: StlDef } }> { + +} + +export class StlDef extends ModelDef { + static displayName = 'STL Mesh'; + static icon = File3dIcon; + static acceptTypes = '.stl,model/stl,application/sla'; + + @field stlMetadata = contains(StlMetadataField); + + get extents() { + return { + x: this.stlMetadata?.sizeX ?? 1, + y: this.stlMetadata?.sizeY ?? 1, + z: this.stlMetadata?.sizeZ ?? 1, + }; + } + + static isolated: BaseDefComponent = StlIsolated; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string; contentSize?: number } = {}, + ): Promise> { + let extension = getExtension(url); + if (extension !== '.stl') { + throw new FileContentMismatchError( + `Expected .stl file extension, got "${extension || 'none'}"`, + ); + } + + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await super.extractAttributes(url, memoizedStream, options); + let bytes = await memoizedStream(); + 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 }; + } +} diff --git a/packages/base/three-mf-def.gts b/packages/base/three-mf-def.gts new file mode 100644 index 00000000000..3214ff1d5ef --- /dev/null +++ b/packages/base/three-mf-def.gts @@ -0,0 +1,486 @@ +import GlimmerComponent from '@glimmer/component'; +import { unzipSync, strFromU8 } from 'fflate'; +import PrinterIcon from '@cardstack/boxel-icons/printer'; +import { byteStreamToUint8Array } from '@cardstack/runtime-common'; +import { + BaseDefComponent, + Component, + FieldDef, + StringField, + contains, + containsMany, + field, +} from './card-api'; +import NumberField from './number'; +import TextAreaField from './text-area'; +import { + FileContentMismatchError, + type ByteStream, + type SerializedFile, +} from './file-api'; +import { ModelDef, ModelIsolatedBody } from './model-file-def'; + +interface ThreeMfPrintPartData { + name?: string; + extruder?: number; + faceCount?: number; +} + +interface ThreeMfMetadata { + unit?: string; + language?: string; + modelPart?: string; + sizeX?: number; + sizeY?: number; + sizeZ?: number; + packageEntryCount?: number; + objectCount?: number; + buildItemCount?: number; + componentCount?: number; + textureCount?: number; + materialResourceCount?: number; + extensionCount?: number; + extensions?: string[]; + title?: string; + designer?: string; + application?: string; + bambuStudioVersion?: string; + creationDate?: string; + licenseTerms?: string; + description?: string; + plateCount?: number; + printPartCount?: number; + configuredFaceCount?: number; + extruderCount?: number; + materialNames?: string[]; + materialColors?: string[]; + printParts?: ThreeMfPrintPartData[]; +} + +interface Model3dData { + meshes: number; + materials: number; + vertices: number; + triangles: number; + generator?: string; +} + +function getExtension(url: string): string { + try { + let parsed = new URL(url); + let name = parsed.pathname.split('/').pop() ?? ''; + let dot = name.lastIndexOf('.'); + return dot === -1 ? '' : name.slice(dot).toLowerCase(); + } catch { + let dot = url.lastIndexOf('.'); + return dot === -1 ? '' : url.slice(dot).toLowerCase(); + } +} + +// Bounded 3MF (OPC ZIP) parser. Unzips the package, parses the model part(s) +// and the optional slicer config, and returns the generic scene facts plus the +// 3MF-specific package metadata. Uses `fflate` (bundled dependency, not a CDN +// import) and the runtime `DOMParser` (available in the host/prerender +// Chromium). Ported from the handoff realm's metadata-extractor. +function parseThreeMf( + buf: ArrayBuffer, +): { model3d: Model3dData; threeMfMetadata: ThreeMfMetadata } | undefined { + let files = unzipSync(new Uint8Array(buf)) as Record; + let modelPart = Object.keys(files).find( + (path) => /(?:^|\/)3dmodel\.model$/i.test(path) || /\.model$/i.test(path), + ); + if (!modelPart) { + return undefined; + } + let modelDocuments = Object.entries(files) + .filter(([path]) => /\.model$/i.test(path)) + .map(([path, bytes]) => { + let document = new DOMParser().parseFromString( + strFromU8(bytes), + 'application/xml', + ); + if (document.getElementsByTagName('parsererror').length) { + throw new FileContentMismatchError( + `3MF model XML is malformed: ${path}`, + ); + } + return { path, document, root: document.documentElement }; + }); + let primary = + modelDocuments.find(({ path }) => path === modelPart) ?? modelDocuments[0]; + let root = primary.root; + let elements = (name: string) => + modelDocuments.flatMap(({ document }) => + Array.from(document.getElementsByTagNameNS('*', name)), + ); + let metadata = new Map(); + for (let element of elements('metadata')) { + let key = element.getAttribute('name'); + if (key) { + metadata.set(key.toLowerCase(), element.textContent?.trim() ?? ''); + } + } + let extensions = Array.from( + new Set( + modelDocuments.flatMap(({ root }) => + Array.from(root.attributes) + .filter((attribute) => attribute.name.startsWith('xmlns:')) + .map((attribute) => `${attribute.name.slice(6)} · ${attribute.value}`), + ), + ), + ); + let materialResourceCount = [ + 'basematerials', + 'colorgroup', + 'texture2dgroup', + 'compositematerials', + 'multiproperties', + ].reduce((total, name) => total + elements(name).length, 0); + let materialBases = elements('base'); + let materialNames = materialBases + .map((element) => element.getAttribute('name')) + .filter((value): value is string => Boolean(value)); + let materialColors = materialBases + .map((element) => element.getAttribute('displaycolor')) + .filter((value): value is string => Boolean(value)); + let vertices = elements('vertex') + .map((element) => + ['x', 'y', 'z'].map((axis) => Number(element.getAttribute(axis))), + ) + .filter((vertex) => vertex.every(Number.isFinite)); + let mins = [Infinity, Infinity, Infinity]; + let maxs = [-Infinity, -Infinity, -Infinity]; + for (let vertex of vertices) { + for (let axis = 0; axis < 3; axis++) { + mins[axis] = Math.min(mins[axis], vertex[axis]); + maxs[axis] = Math.max(maxs[axis], vertex[axis]); + } + } + let dimension = (axis: number) => + vertices.length + ? Math.round((maxs[axis] - mins[axis]) * 1_000_000) / 1_000_000 + : undefined; + let configPath = Object.keys(files).find((path) => + /(?:^|\/)model_settings\.config$/i.test(path), + ); + let configuredParts: ThreeMfPrintPartData[] = []; + let plateCount = 0; + let configuredFaceCount = 0; + let extruders = new Set(); + if (configPath) { + let config = new DOMParser().parseFromString( + strFromU8(files[configPath]), + 'application/xml', + ); + if (!config.getElementsByTagName('parsererror').length) { + plateCount = config.getElementsByTagName('plate').length; + for (let part of Array.from(config.getElementsByTagName('part'))) { + let values = new Map(); + for (let child of Array.from(part.children)) { + if (child.localName === 'metadata') { + let key = child.getAttribute('key'); + if (key) { + values.set(key, child.getAttribute('value') ?? ''); + } + } + } + let meshStat = Array.from(part.children).find( + (child) => child.localName === 'mesh_stat', + ); + let faceCount = Number(meshStat?.getAttribute('face_count') ?? 0); + let extruder = Number(values.get('extruder') ?? 0); + if (extruder > 0) { + extruders.add(extruder); + } + configuredFaceCount += Number.isFinite(faceCount) ? faceCount : 0; + configuredParts.push({ + name: + values.get('name') || + `Part ${part.getAttribute('id') ?? configuredParts.length + 1}`, + extruder: extruder || undefined, + faceCount: faceCount || undefined, + }); + } + } + } + if (!configuredParts.length) { + for (let object of elements('object')) { + let triangleCount = object.getElementsByTagNameNS('*', 'triangle').length; + if (triangleCount) { + configuredParts.push({ + name: + object.getAttribute('name') || + `Object ${object.getAttribute('id') ?? configuredParts.length + 1}`, + faceCount: triangleCount, + }); + } + } + } + let application = + metadata.get('application') ?? + metadata.get('producer') ?? + metadata.get('generator'); + return { + model3d: { + meshes: elements('mesh').length, + materials: materialResourceCount, + vertices: elements('vertex').length, + triangles: elements('triangle').length, + generator: application ?? metadata.get('designer'), + }, + threeMfMetadata: { + unit: root.getAttribute('unit') ?? 'millimeter', + language: root.getAttribute('xml:lang') ?? undefined, + modelPart, + sizeX: dimension(0), + sizeY: dimension(1), + sizeZ: dimension(2), + packageEntryCount: Object.keys(files).length, + objectCount: elements('object').length, + buildItemCount: elements('item').length, + componentCount: elements('component').length, + textureCount: elements('texture2d').length, + materialResourceCount, + extensionCount: extensions.length, + extensions, + title: metadata.get('title'), + designer: metadata.get('designer'), + application, + bambuStudioVersion: metadata.get('bambustudio:3mfversion'), + creationDate: metadata.get('creationdate'), + licenseTerms: metadata.get('licenseterms'), + description: metadata.get('description'), + plateCount: plateCount || undefined, + printPartCount: configuredParts.length || undefined, + configuredFaceCount: configuredFaceCount || undefined, + extruderCount: extruders.size || undefined, + materialNames, + materialColors, + printParts: configuredParts, + }, + }; +} + +export class ThreeMfPrintPartField extends FieldDef { + static displayName = '3MF Print Part'; + @field name = contains(StringField); + @field extruder = contains(NumberField); + @field faceCount = contains(NumberField); + + static embedded = class Embedded extends Component< + typeof ThreeMfPrintPartField + > { + + }; +} + +export class ThreeMfMetadataField extends FieldDef { + static displayName = '3MF Package Metadata'; + static icon = PrinterIcon; + @field unit = contains(StringField); + @field language = contains(StringField); + @field modelPart = contains(StringField); + @field sizeX = contains(NumberField); + @field sizeY = contains(NumberField); + @field sizeZ = contains(NumberField); + @field packageEntryCount = contains(NumberField); + @field objectCount = contains(NumberField); + @field buildItemCount = contains(NumberField); + @field componentCount = contains(NumberField); + @field textureCount = contains(NumberField); + @field materialResourceCount = contains(NumberField); + @field extensionCount = contains(NumberField); + @field extensions = containsMany(StringField); + @field title = contains(StringField); + @field designer = contains(StringField); + @field application = contains(StringField); + @field bambuStudioVersion = contains(StringField); + @field creationDate = contains(StringField); + @field licenseTerms = contains(StringField); + @field description = contains(TextAreaField); + @field plateCount = contains(NumberField); + @field printPartCount = contains(NumberField); + @field configuredFaceCount = contains(NumberField); + @field extruderCount = contains(NumberField); + @field materialNames = containsMany(StringField); + @field materialColors = containsMany(StringField); + @field printParts = containsMany(ThreeMfPrintPartField); + + static embedded = class Embedded extends Component< + typeof ThreeMfMetadataField + > { + + }; +} + +class ThreeMfIsolated extends GlimmerComponent<{ Args: { model: ThreeMfDef } }> { + +} + +export class ThreeMfDef extends ModelDef { + static displayName = '3MF Package'; + static icon = PrinterIcon; + static acceptTypes = '.3mf,model/3mf,application/vnd.ms-3mfdocument'; + + @field threeMfMetadata = contains(ThreeMfMetadataField); + + get extents() { + return { + x: this.threeMfMetadata?.sizeX ?? 1, + y: this.threeMfMetadata?.sizeY ?? 1, + z: this.threeMfMetadata?.sizeZ ?? 1, + }; + } + + static isolated: BaseDefComponent = ThreeMfIsolated; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string; contentSize?: number } = {}, + ): Promise< + SerializedFile<{ model3d: Model3dData; threeMfMetadata: ThreeMfMetadata }> + > { + let extension = getExtension(url); + if (extension !== '.3mf') { + throw new FileContentMismatchError( + `Expected .3mf file extension, got "${extension || 'none'}"`, + ); + } + + let bytesPromise: Promise | undefined; + let memoizedStream = async () => { + bytesPromise ??= byteStreamToUint8Array(await getStream()); + return bytesPromise; + }; + + let base = await super.extractAttributes(url, memoizedStream, options); + let bytes = await memoizedStream(); + let parsed = parseThreeMf( + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer, + ); + if (!parsed) { + throw new FileContentMismatchError( + 'File is not a parseable 3MF package', + ); + } + return { ...base, ...parsed }; + } +} diff --git a/packages/host/tests/unit/file-def-code-ref-test.ts b/packages/host/tests/unit/file-def-code-ref-test.ts index 15e0af6c4ab..adb6ce3902d 100644 --- a/packages/host/tests/unit/file-def-code-ref-test.ts +++ b/packages/host/tests/unit/file-def-code-ref-test.ts @@ -59,6 +59,20 @@ module('Unit | isFileDefCodeRef', function (hooks) { ), 'PngDef', ); + assert.true( + isFileDefCodeRef( + { module: baseRRI('stl-model-def'), name: 'StlDef' }, + virtualNetwork, + ), + 'StlDef', + ); + assert.true( + isFileDefCodeRef( + { module: baseRRI('three-mf-def'), name: 'ThreeMfDef' }, + virtualNetwork, + ), + 'ThreeMfDef', + ); }); test('rejects a non-FileDef card ref', function (assert) { diff --git a/packages/runtime-common/file-def-code-ref.ts b/packages/runtime-common/file-def-code-ref.ts index 3a6221a7148..9af80a803d2 100644 --- a/packages/runtime-common/file-def-code-ref.ts +++ b/packages/runtime-common/file-def-code-ref.ts @@ -39,6 +39,8 @@ const FILEDEF_CODE_REF_BY_EXTENSION: Record = { '.opus': { module: baseModule('ogg-audio-def'), name: 'OggDef' }, '.m4a': { module: baseModule('m4a-audio-def'), name: 'M4aDef' }, '.flac': { module: baseModule('flac-audio-def'), name: 'FlacDef' }, + '.stl': { module: baseModule('stl-model-def'), name: 'StlDef' }, + '.3mf': { module: baseModule('three-mf-def'), name: 'ThreeMfDef' }, '.mismatch': { module: './filedef-mismatch' as RealmResourceIdentifier, name: 'FileDef', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c238ef6ca67..77a05a61414 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -940,6 +940,9 @@ importers: ember-template-lint: specifier: 'catalog:' version: 7.9.3 + fflate: + specifier: ^0.8.2 + version: 0.8.3 matrix-js-sdk: specifier: 'catalog:' version: 38.3.0(patch_hash=cee0baf579283943dc5a6b48977e8ed40a13fc111b5de9c91814893f9a4989fb) @@ -10436,6 +10439,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -25111,6 +25117,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 From 8d3f964657da8e4f2c61e4885a8d20b672b9c8e1 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 31 Jul 2026 14:32:37 +0700 Subject: [PATCH 02/13] Fix fflate runtime shim and glint template type error - Register `fflate` as an async module shim in host externals.ts and add it to host deps. Bare imports in realm-served base modules resolve only via the shim registry, not node_modules, so the extractor's `import 'fflate'` would 404 at runtime without this. - Render 3MF materialNames via a joined getter instead of interpolating a string[] directly (glint: string[] is not a ContentValue). - prettier formatting. CS-12053, CS-12054 --- packages/base/model-file-def.gts | 19 ++++-- packages/base/stl-model-def.gts | 75 +++++++++++++++++------ packages/base/three-mf-def.gts | 96 +++++++++++++++++++++--------- packages/host/app/lib/externals.ts | 4 ++ packages/host/package.json | 1 + pnpm-lock.yaml | 3 + 6 files changed, 146 insertions(+), 52 deletions(-) diff --git a/packages/base/model-file-def.gts b/packages/base/model-file-def.gts index 230957eb4ff..85ba410f52c 100644 --- a/packages/base/model-file-def.gts +++ b/packages/base/model-file-def.gts @@ -143,11 +143,16 @@ class ModelSceneRail extends GlimmerComponent<{ +} + // Generic 3D scene facts rail, styled like the handoff realm's inspector rail. class ModelSceneRail extends GlimmerComponent<{ Args: { model: ModelDef }; @@ -179,15 +508,15 @@ class ModelSceneRail extends GlimmerComponent<{ } -// Base isolated body (hero + scene rail). Leaf isolated templates render this, -// then append their format-specific metadata rail after it. +// Base isolated body (live viewer hero + scene rail). Leaf isolated templates +// render this, then append their format-specific metadata rail after it. export class ModelIsolatedBody extends GlimmerComponent<{ Args: { model: ModelDef }; }> { @@ -231,6 +560,8 @@ class ModelAtomTemplate extends GlimmerComponent<{ } +// Fitted is the collection-tile budget format: silhouette only, never mounts a +// WebGL engine. class ModelFittedTemplate extends GlimmerComponent<{ Args: { model: ModelDef }; }> { @@ -254,7 +585,7 @@ class ModelEmbeddedTemplate extends GlimmerComponent<{ } -// Generic 3D scene facts rail, styled like the handoff realm's inspector rail. -class ModelSceneRail extends GlimmerComponent<{ +// Full isolated work surface, mirroring the handoff realm: a header bar +// (icon + name + extension pill) over a two-column body — a bordered live-viewer +// stage on the left and a property inspector on the right. The leaf isolated +// templates render this and pass their format-specific metadata group into the +// inspector via the default block. +export class ModelIsolatedBody extends GlimmerComponent<{ Args: { model: ModelDef }; + Blocks: { default: [] }; }> { + get name() { + return this.args.model.name ?? ''; + } + get baseName() { + return this.name.replace(EXTENSION_RE, '') || this.name; + } + get extension() { + let parts = this.name.split('.'); + return parts.length > 1 ? (parts.pop() ?? '') : ''; + } + -} - -// Base isolated body (live viewer hero + scene rail). Leaf isolated templates -// render this, then append their format-specific metadata rail after it. -export class ModelIsolatedBody extends GlimmerComponent<{ - Args: { model: ModelDef }; -}> { - @@ -608,14 +710,14 @@ class ModelEmbeddedTemplate extends GlimmerComponent<{ .model-embedded__hero { position: relative; aspect-ratio: 4 / 3; - background: var(--boxel-100); - border-radius: var(--boxel-border-radius); + background: var(--muted); + border-radius: var(--radius, var(--boxel-border-radius)); overflow: hidden; } .model-embedded__caption { display: flex; align-items: baseline; - gap: 10px; + gap: 0.625rem; min-width: 0; } .model-embedded__name { @@ -625,8 +727,9 @@ class ModelEmbeddedTemplate extends GlimmerComponent<{ white-space: nowrap; } .model-embedded__caption small { - color: var(--boxel-450); - font: 0.5625rem var(--boxel-monospace-font-family, monospace); + color: var(--muted-foreground); + font-family: var(--font-mono, var(--boxel-monospace-font-family)); + font-size: 0.5625rem; } @@ -635,16 +738,7 @@ class ModelEmbeddedTemplate extends GlimmerComponent<{ class ModelIsolatedTemplate extends GlimmerComponent<{ Args: { model: ModelDef }; }> { - + } // Base FileDef subclass for 3D model formats. Owns the shared scene metadata, diff --git a/packages/base/stl-model-def.gts b/packages/base/stl-model-def.gts index aa365bade47..d876e5685fe 100644 --- a/packages/base/stl-model-def.gts +++ b/packages/base/stl-model-def.gts @@ -262,63 +262,72 @@ export class StlMetadataField extends FieldDef { class StlIsolated extends GlimmerComponent<{ Args: { model: StlDef } }> { } diff --git a/packages/base/three-mf-def.gts b/packages/base/three-mf-def.gts index 681d41f4268..b33e8d8ab56 100644 --- a/packages/base/three-mf-def.gts +++ b/packages/base/three-mf-def.gts @@ -415,56 +415,69 @@ class ThreeMfIsolated extends GlimmerComponent<{ Args: { model: ThreeMfDef }; }> {