diff --git a/packages/base/stl-file-def.gts b/packages/base/stl-file-def.gts new file mode 100644 index 00000000000..85d8f0695b7 --- /dev/null +++ b/packages/base/stl-file-def.gts @@ -0,0 +1,340 @@ +import { readFirstBytes } from '@cardstack/runtime-common'; +import CubeIcon from '@cardstack/boxel-icons/cube'; +import { + BaseDefComponent, + Component, + NumberField, + StringField, + contains, + field, +} from './card-api'; +import { + FileContentMismatchError, + FileDef, + type ByteStream, + type SerializedFile, +} from './file-api'; +import { STL_SNIFF_BYTES, extractStlMetadata } from './stl-meta-extractor'; +import ThreeModelViewer from './three-model-viewer'; + +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(); + } +} + +function stlTitle(model: { name?: string | null } | null | undefined): string { + return model?.name ?? 'Untitled 3D Model'; +} + +// Human label for the stored `format` value; blank when the encoding is +// unknown (e.g. a bare FileDef fallback that never ran STL extraction). +function formatLabel(format: string | null | undefined): string { + if (format === 'binary') { + return 'Binary STL'; + } + if (format === 'ascii') { + return 'ASCII STL'; + } + return ''; +} + +class Isolated extends Component { + get title() { + return stlTitle(this.args.model); + } + + get formatLabel() { + return formatLabel(this.args.model?.format); + } + + +} + +class Embedded extends Component { + get title() { + return stlTitle(this.args.model); + } + + get formatLabel() { + return formatLabel(this.args.model?.format); + } + + +} + +class Fitted extends Component { + get title() { + return stlTitle(this.args.model); + } + + get formatLabel() { + return formatLabel(this.args.model?.format); + } + + +} + +class Atom extends Component { + get title() { + return stlTitle(this.args.model); + } + + +} + +export class StlDef extends FileDef { + static displayName = '3D Model (STL)'; + static icon = CubeIcon; + static acceptTypes = '.stl,model/stl'; + static validExtensions = new Set(['.stl']); + + @field format = contains(StringField); + // Cheap, header-only facts. `triangleCount` is only known for binary STL (the + // header's uint32); ASCII leaves it unset and the viewer fills it in. + // `solidName` is the ASCII `solid ` label; binary STL has none. + @field triangleCount = contains(NumberField); + @field solidName = contains(StringField); + + static isolated: BaseDefComponent = Isolated; + static embedded: BaseDefComponent = Embedded; + static fitted: BaseDefComponent = Fitted; + static atom: BaseDefComponent = Atom; + + static async extractAttributes( + this: typeof StlDef, + url: string, + getStream: () => Promise, + options: { contentHash?: string; contentSize?: number } = {}, + ): Promise< + SerializedFile<{ + format: string; + triangleCount?: number; + solidName?: string; + }> + > { + let extension = getExtension(url); + if (!this.validExtensions.has(extension)) { + throw new FileContentMismatchError( + `Expected ${[...this.validExtensions].join(' or ')} file extension, got "${extension || 'none'}"`, + ); + } + + let base = await super.extractAttributes(url, getStream, options); + let bytes = await readFirstBytes(await getStream(), STL_SNIFF_BYTES); + let contentSize = base.contentSize ?? bytes.byteLength; + let { format, triangleCount, solidName } = extractStlMetadata( + bytes, + contentSize, + ); + + return { + ...base, + format, + triangleCount, + solidName, + }; + } +} diff --git a/packages/base/stl-meta-extractor.ts b/packages/base/stl-meta-extractor.ts new file mode 100644 index 00000000000..4dea71f7d6e --- /dev/null +++ b/packages/base/stl-meta-extractor.ts @@ -0,0 +1,74 @@ +import { FileContentMismatchError } from './file-api'; + +// Binary STL layout: an 80-byte header, a little-endian uint32 triangle count, +// then 50 bytes per triangle (12 floats for the normal + 3 vertices = 48 bytes, +// plus a 2-byte attribute field). So the whole file is exactly +// `84 + 50 * triangleCount` bytes. +const BINARY_HEADER_BYTES = 84; // 80-byte header + 4-byte triangle count +const BYTES_PER_TRIANGLE = 50; + +// Read enough of the head to (a) read the binary triangle count at offset 80 and +// (b) capture the ASCII `solid ` line past any leading whitespace. STL +// solid names are short in practice; 256 bytes is generous and still a bounded, +// index-path-cheap read. +export const STL_SNIFF_BYTES = 256; + +export type StlFormat = 'binary' | 'ascii'; + +// The cheap, header-only facts. Everything here is derivable without walking the +// mesh, so it is safe to compute on the indexing hot path. Expensive, +// full-mesh-parse facts (bounding box, per-facet counts, colour) are computed +// lazily in the 3D viewer instead — never here. +export interface StlMetadata { + format: StlFormat; + // Only known cheaply for binary STL (the header's uint32). ASCII requires a + // full-file `facet` scan, so it is left undefined and filled by the viewer. + triangleCount?: number; + // ASCII STL only: the `solid ` label. Binary STL has no structured name + // (its 80-byte header is freeform), so it is left undefined. + solidName?: string; +} + +// ASCII STL always opens with the token `solid` (after optional leading +// whitespace). A binary header can *also* begin with the bytes "solid", so the +// prefix check alone can't prove ASCII — the binary size identity below is the +// authoritative discriminator and is tried first. +const ASCII_SOLID_RE = /^\s*solid\b/i; +// Capture the solid name on the same line as the opening `solid` token. +const ASCII_SOLID_NAME_RE = /^\s*solid[ \t]+([^\r\n]+)/i; + +// Classify an STL as binary or ASCII from its first bytes plus its total size, +// and pull the cheap header facts. +// +// `bytes` need only cover the first `STL_SNIFF_BYTES` — the caller bounds the +// read so an arbitrarily large mesh never enters the extract window. +// `contentSize` is the file's exact byte length (from the base FileDef extract). +// +// Throws `FileContentMismatchError` when the bytes are neither a size-consistent +// binary STL nor an ASCII `solid …` document, so a mislabeled or truncated file +// falls back to a bare `FileDef` rather than erroring the index row. +export function extractStlMetadata( + bytes: Uint8Array, + contentSize: number, +): StlMetadata { + if (bytes.length >= BINARY_HEADER_BYTES) { + let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let triangleCount = view.getUint32(80, /* littleEndian */ true); + if ( + BINARY_HEADER_BYTES + BYTES_PER_TRIANGLE * triangleCount === + contentSize + ) { + return { format: 'binary', triangleCount }; + } + } + + let head = new TextDecoder().decode(bytes.subarray(0, STL_SNIFF_BYTES)); + if (ASCII_SOLID_RE.test(head)) { + let solidName = ASCII_SOLID_NAME_RE.exec(head)?.[1]?.trim() || undefined; + return { format: 'ascii', solidName }; + } + + throw new FileContentMismatchError( + 'File is not a valid STL: binary size does not match the header triangle count and the content has no ASCII "solid" header', + ); +} diff --git a/packages/base/three-model-viewer.gts b/packages/base/three-model-viewer.gts new file mode 100644 index 00000000000..7c5b4ff2b7e --- /dev/null +++ b/packages/base/three-model-viewer.gts @@ -0,0 +1,354 @@ +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; +import { eq } from '@cardstack/boxel-ui/helpers'; + +// Ported from the file-experience prototype's model preview. three.js is loaded +// from a CDN with a runtime dynamic import — the Boxel card runtime resolves it +// natively in the browser, so the viewer works inside the isolated card render +// without any host build wiring. Only STL and 3MF are wired here. + +interface Signature { + Args: { + url?: string | null; + fileType: 'stl' | '3mf'; + name?: string; + }; + Element: HTMLElement; +} + +// Release GPU resources for every mesh/material/texture under a root. +function disposeObject(root: any) { + root?.traverse?.((node: any) => { + node.geometry?.dispose?.(); + let materials = Array.isArray(node.material) + ? node.material + : [node.material]; + for (let material of materials.filter(Boolean)) { + for (let value of Object.values(material)) { + if ((value as any)?.isTexture) { + (value as any).dispose(); + } + } + material.dispose?.(); + } + }); +} + +const renderModel = modifier( + ( + element: HTMLElement, + [component, url, fileType]: [ThreeModelViewer, string, 'stl' | '3mf'], + ) => { + if (!url) { + return; + } + + let cancelled = false; + let frameId = 0; + let controller = new AbortController(); + let renderer: any; + let scene: any; + let camera: any; + let controls: any; + let modelRoot: any; + let observer: ResizeObserver | undefined; + let frameModel: ((resetDirection?: boolean) => void) | undefined; + let isThreeMf = fileType === '3mf'; + + let resize = () => { + if (!renderer || !camera) { + return; + } + let width = Math.max(1, element.clientWidth); + let height = Math.max(1, element.clientHeight); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + frameModel?.(false); + }; + + let tick = () => { + if (cancelled || !renderer || !scene || !camera) { + return; + } + controls.update(); + renderer.render(scene, camera); + // eslint-disable-next-line @cardstack/boxel/no-raf-for-state -- WebGL paint loop owns no Ember state + frameId = requestAnimationFrame(tick); + }; + + component.setLoading(); + void (async () => { + try { + let [THREE, controlsModule, loaderModule, response] = await Promise.all( + [ + // @ts-expect-error Pinned browser ESM import; Boxel resolves it at runtime + import('https://esm.sh/three@0.160.0'), + // @ts-expect-error Pinned browser ESM import; Boxel resolves it at runtime + import('https://esm.sh/three@0.160.0/examples/jsm/controls/OrbitControls.js'), + isThreeMf + ? // @ts-expect-error Pinned browser ESM import; 3MFLoader brings its own fflate dependency + import('https://esm.sh/three@0.160.0/examples/jsm/loaders/3MFLoader.js') + : // @ts-expect-error Pinned browser ESM import; STLLoader handles ASCII + binary STL + import('https://esm.sh/three@0.160.0/examples/jsm/loaders/STLLoader.js'), + fetch(url, { credentials: 'include', signal: controller.signal }), + ], + ); + if (!response.ok) { + throw new Error(`Model fetch failed with HTTP ${response.status}`); + } + let bytes = await response.arrayBuffer(); + if (cancelled) { + return; + } + + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + scene = new THREE.Scene(); + camera = new THREE.PerspectiveCamera(38, 1, 0.01, 100); + controls = new controlsModule.OrbitControls( + camera, + renderer.domElement, + ); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.05; + renderer.domElement.setAttribute('role', 'img'); + renderer.domElement.setAttribute( + 'aria-label', + `Interactive 3D preview of ${component.args.name ?? 'model'}`, + ); + element.appendChild(renderer.domElement); + + scene.add(new THREE.HemisphereLight(0xffffff, 0x697080, 2.1)); + let key = new THREE.DirectionalLight(0xffffff, 2.4); + key.position.set(3, 5, 4); + scene.add(key); + let fill = new THREE.DirectionalLight(0xaec6ff, 1.1); + fill.position.set(-4, 1, -2); + scene.add(fill); + + controls.enableDamping = true; + controls.dampingFactor = 0.07; + controls.enablePan = false; + + observer = new ResizeObserver(resize); + observer.observe(element); + resize(); + tick(); + + let installModel = (root: any) => { + if (cancelled) { + disposeObject(root); + return; + } + modelRoot = root; + modelRoot.updateMatrixWorld(true); + let bounds = new THREE.Box3().setFromObject(modelRoot); + if (bounds.isEmpty()) { + throw new Error('Model contains no renderable geometry'); + } + scene.add(modelRoot); + let size = bounds.getSize(new THREE.Vector3()); + let center = bounds.getCenter(new THREE.Vector3()); + frameModel = (resetDirection = false) => { + let verticalFov = THREE.MathUtils.degToRad(camera.fov); + let horizontalFov = + 2 * Math.atan(Math.tan(verticalFov / 2) * camera.aspect); + let heightDistance = size.y / (2 * Math.tan(verticalFov / 2)); + let widthDistance = size.x / (2 * Math.tan(horizontalFov / 2)); + let distance = + Math.max(heightDistance, widthDistance, 0.001) * 1.2 + size.z; + let isFlatPrint = + size.z < Math.max(0.001, Math.min(size.x, size.y) * 0.35); + let defaultDirection = isFlatPrint + ? new THREE.Vector3(0.08, -0.14, 1).normalize() + : new THREE.Vector3(0.72, 0.52, 1).normalize(); + let direction = resetDirection + ? defaultDirection + : new THREE.Vector3() + .subVectors(camera.position, controls.target) + .normalize(); + if ( + !Number.isFinite(direction.lengthSq()) || + direction.lengthSq() === 0 + ) { + direction.copy(defaultDirection); + } + controls.target.copy(center); + camera.position + .copy(center) + .add(direction.multiplyScalar(distance)); + camera.near = Math.max(0.001, distance / 1000); + camera.far = Math.max(distance * 10, size.length() * 12, 100); + camera.updateProjectionMatrix(); + controls.minDistance = Math.max(0.001, distance * 0.18); + controls.maxDistance = Math.max(distance * 6, size.length() * 8); + controls.update(); + }; + frameModel(true); + renderer.render(scene, camera); + component.setReady(); + }; + + if (isThreeMf) { + let loader = new loaderModule.ThreeMFLoader(); + installModel(loader.parse(bytes)); + } else { + let loader = new loaderModule.STLLoader(); + let geometry = loader.parse(bytes); + // Repair valid STL files that store zero facet normals. + geometry.computeVertexNormals(); + let hasColors = Boolean( + (geometry as any).hasColors || geometry.getAttribute('color'), + ); + let material = new THREE.MeshStandardMaterial({ + color: hasColors ? 0xffffff : 0xb9c0cb, + vertexColors: hasColors, + roughness: 0.62, + metalness: 0.08, + side: THREE.DoubleSide, + }); + let mesh = new THREE.Mesh(geometry, material); + // STL is commonly Z-up; convert to the three.js Y-up scene. + mesh.rotation.x = -Math.PI / 2; + installModel(mesh); + } + } catch (error) { + if (!cancelled) { + component.setError(error); + } + } + })(); + + let stop = (event: Event) => event.stopPropagation(); + let resetView = (event: Event) => { + event.stopPropagation(); + frameModel?.(true); + }; + for (let eventName of ['pointerdown', 'pointerup', 'click', 'wheel']) { + element.addEventListener(eventName, stop); + } + element.addEventListener('dblclick', resetView); + + return () => { + cancelled = true; + controller.abort(); + cancelAnimationFrame(frameId); + observer?.disconnect(); + controls?.dispose?.(); + disposeObject(modelRoot); + renderer?.dispose?.(); + renderer?.forceContextLoss?.(); + renderer?.domElement?.remove(); + for (let eventName of ['pointerdown', 'pointerup', 'click', 'wheel']) { + element.removeEventListener(eventName, stop); + } + element.removeEventListener('dblclick', resetView); + }; + }, +); + +export default class ThreeModelViewer extends GlimmerComponent { + @tracked state: 'loading' | 'ready' | 'error' = 'loading'; + @tracked errorMessage = ''; + + setLoading = () => { + this.state = 'loading'; + this.errorMessage = ''; + }; + setReady = () => { + this.state = 'ready'; + }; + setError = (error: unknown) => { + this.state = 'error'; + this.errorMessage = + error instanceof Error ? error.message : 'Unable to render model'; + }; + + +} diff --git a/packages/host/tests/acceptance/stl-file-def-test.gts b/packages/host/tests/acceptance/stl-file-def-test.gts new file mode 100644 index 00000000000..a2da7b1c20b --- /dev/null +++ b/packages/host/tests/acceptance/stl-file-def-test.gts @@ -0,0 +1,234 @@ +import { visit, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { + baseRealmRRI, + type FileExtractResponse, + type RenderRouteOptions, + type ResolvedCodeRef, + SupportedMimeType, + type RealmResourceIdentifier, +} from '@cardstack/runtime-common'; +import type { Realm } from '@cardstack/runtime-common/realm'; + +import type NetworkService from '@cardstack/host/services/network'; + +import { + setupLocalIndexing, + setupOnSave, + setupRealmCacheTeardown, + testRealmURL, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + withCachedRealmSetup, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +// A size-consistent binary STL: 80-byte header, little-endian uint32 triangle +// count, then 50 bytes/triangle (payload left zeroed). +function binaryStl(triangleCount: number): Uint8Array { + let bytes = new Uint8Array(84 + 50 * triangleCount); + new DataView(bytes.buffer).setUint32( + 80, + triangleCount, + /* littleEndian */ true, + ); + return bytes; +} + +const ASCII_STL = `solid cube +facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop +endfacet +endsolid cube +`; + +module('Acceptance | stl file def', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + setupRealmCacheTeardown(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + let realm: Realm; + + const renderPath = ( + url: string, + renderOptions: RenderRouteOptions, + nonce = 0, + ) => + `/render/${encodeURIComponent(url)}/${nonce}/${encodeURIComponent( + JSON.stringify(renderOptions), + )}/file-extract`; + + const makeFileURL = (path: string) => new URL(path, testRealmURL).href; + + const stlDefCodeRef = (): ResolvedCodeRef => ({ + module: `${baseRealmRRI}stl-file-def` as RealmResourceIdentifier, + name: 'StlDef', + }); + + async function captureFileExtractResult( + expectedStatus?: 'ready' | 'error', + ): Promise { + await waitUntil( + () => { + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + return false; + } + let status = container.getAttribute( + 'data-prerender-file-extract-status', + ); + if (!status) { + return false; + } + if (expectedStatus && status !== expectedStatus) { + return false; + } + return status === 'ready' || status === 'error'; + }, + { timeout: 5000 }, + ); + + let container = document.querySelector( + '[data-prerender-file-extract]', + ) as HTMLElement | null; + if (!container) { + throw new Error( + 'captureFileExtractResult: missing [data-prerender-file-extract] container after wait', + ); + } + let pre = container.querySelector('pre'); + let text = pre?.textContent?.trim() ?? ''; + return JSON.parse(text) as FileExtractResponse; + } + + hooks.beforeEach(async function () { + ({ realm } = await withCachedRealmSetup(async () => + setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + 'binary.stl': binaryStl(3), + 'ascii.stl': ASCII_STL, + 'readme.md': `# Not an STL file + +This is markdown content.`, + }, + }), + )); + }); + + hooks.afterEach(function () { + delete (globalThis as any).__renderModel; + }); + + test('extracts binary encoding from a binary stl file', async function (assert) { + let url = makeFileURL('binary.stl'); + await visit( + renderPath(url, { + fileExtract: true, + fileDefCodeRef: stlDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.format, 'binary', 'detects binary'); + assert.strictEqual( + result.searchDoc?.triangleCount, + 3, + 'extracts binary triangle count', + ); + assert.strictEqual(result.searchDoc?.name, 'binary.stl'); + assert.ok( + String(result.searchDoc?.contentType).includes('stl'), + 'sets stl content type', + ); + }); + + test('extracts ascii encoding from an ascii stl file', async function (assert) { + let url = makeFileURL('ascii.stl'); + await visit( + renderPath(url, { + fileExtract: true, + fileDefCodeRef: stlDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.strictEqual(result.searchDoc?.format, 'ascii', 'detects ascii'); + assert.strictEqual( + result.searchDoc?.solidName, + 'cube', + 'extracts ascii solid name', + ); + assert.strictEqual(result.searchDoc?.name, 'ascii.stl'); + }); + + test('falls back when stl def is used for non-stl files', async function (assert) { + let url = makeFileURL('readme.md'); + await visit( + renderPath(url, { + fileExtract: true, + fileDefCodeRef: stlDefCodeRef(), + }), + ); + + let result = await captureFileExtractResult('ready'); + assert.strictEqual(result.status, 'ready'); + assert.true(result.mismatch, 'marks mismatch when extension is not stl'); + assert.strictEqual(result.searchDoc?.name, 'readme.md'); + }); + + test('indexing stores stl format and file-meta resolves to StlDef', async function (assert) { + let fileURL = new URL('binary.stl', testRealmURL); + let fileEntry = await realm.realmIndexQueryEngine.file(fileURL); + + assert.ok(fileEntry, 'file entry exists'); + assert.strictEqual( + fileEntry?.searchDoc?.format, + 'binary', + 'index stores stl format', + ); + + let network = getService('network') as NetworkService; + let response = await network.virtualNetwork.fetch(fileURL, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + + assert.true(response.ok, 'file meta request succeeds'); + + let body = await response.json(); + assert.strictEqual(body?.data?.type, 'file-meta'); + assert.strictEqual( + body?.data?.attributes?.format, + 'binary', + 'file meta includes stl format', + ); + assert.strictEqual( + body?.data?.attributes?.triangleCount, + 3, + 'file meta includes triangle count', + ); + assert.deepEqual( + body?.data?.meta?.adoptsFrom, + stlDefCodeRef(), + 'file meta resolves to StlDef', + ); + }); +}); diff --git a/packages/host/tests/integration/components/stl-file-def-test.gts b/packages/host/tests/integration/components/stl-file-def-test.gts new file mode 100644 index 00000000000..959c7b5fdfb --- /dev/null +++ b/packages/host/tests/integration/components/stl-file-def-test.gts @@ -0,0 +1,165 @@ +// StlDef is a FileDef subclass for `.stl` 3D-print meshes. These tests exercise +// the real base modules loaded through the realm loader: +// +// 1. `extractStlMetadata` classifies binary vs ASCII from the first bytes plus +// the file size, pulls the cheap header facts (binary triangle count, ASCII +// solid name), and rejects non-STL bytes with FileContentMismatchError. +// 2. `StlDef.extractAttributes` stamps the tier-1 fields and inherits the base +// name/contentType/size, and guards the file extension. + +import { render } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import type { Loader } from '@cardstack/runtime-common/loader'; + +import { setupLocalIndexing, testRealmURL } from '../../helpers'; +import { setupBaseRealm } from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +let loader: Loader; + +// A size-consistent binary STL: 80-byte header, little-endian uint32 triangle +// count, then 50 bytes/triangle. The mesh payload is left zeroed — only the +// header size identity matters to the classifier. +function binaryStl(triangleCount: number): Uint8Array { + let size = 84 + 50 * triangleCount; + let bytes = new Uint8Array(size); + new DataView(bytes.buffer).setUint32( + 80, + triangleCount, + /* littleEndian */ true, + ); + return bytes; +} + +const ASCII_STL = `solid cube +facet normal 0 0 0 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop +endfacet +endsolid cube +`; + +function streamOf(bytes: Uint8Array): () => Promise { + // A fresh copy per call — extractAttributes reads the stream more than once. + return async () => bytes.slice(); +} + +module('Integration | stl file def', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + setupMockMatrix(hooks); + + hooks.beforeEach(function () { + loader = getService('loader-service').loader; + }); + + async function loadBase() { + let { StlDef } = await loader.import('@cardstack/base/stl-file-def'); + let { extractStlMetadata } = await loader.import( + '@cardstack/base/stl-meta-extractor', + ); + let { FileContentMismatchError } = await loader.import( + '@cardstack/base/file-api', + ); + return { StlDef, extractStlMetadata, FileContentMismatchError }; + } + + test('extractStlMetadata reads binary encoding and triangle count', async function (assert) { + let { extractStlMetadata } = await loadBase(); + let bytes = binaryStl(3); + let meta = extractStlMetadata(bytes, bytes.length); + assert.strictEqual(meta.format, 'binary'); + assert.strictEqual(meta.triangleCount, 3, 'binary count from the header'); + assert.strictEqual(meta.solidName, undefined, 'binary has no solid name'); + }); + + test('extractStlMetadata reads ASCII encoding and solid name', async function (assert) { + let { extractStlMetadata } = await loadBase(); + let bytes = new TextEncoder().encode(ASCII_STL); + let meta = extractStlMetadata(bytes, bytes.length); + assert.strictEqual(meta.format, 'ascii'); + assert.strictEqual(meta.solidName, 'cube', 'name from the solid line'); + assert.strictEqual( + meta.triangleCount, + undefined, + 'ASCII count is left for the viewer, not scanned on the index path', + ); + }); + + test('extractStlMetadata rejects non-STL bytes', async function (assert) { + let { extractStlMetadata, FileContentMismatchError } = await loadBase(); + let bytes = new TextEncoder().encode('this is not an stl file'); + assert.throws( + () => extractStlMetadata(bytes, bytes.length), + FileContentMismatchError, + ); + }); + + test('extractStlMetadata classifies whitespace-led ASCII and captures its name', async function (assert) { + let { extractStlMetadata } = await loadBase(); + let bytes = new TextEncoder().encode(' \n solid part\nendsolid part\n'); + let meta = extractStlMetadata(bytes, bytes.length); + assert.strictEqual(meta.format, 'ascii'); + assert.strictEqual(meta.solidName, 'part'); + }); + + test('StlDef.extractAttributes stamps tier-1 fields and inherits base (binary)', async function (assert) { + let { StlDef } = await loadBase(); + let bytes = binaryStl(2); + let url = `${testRealmURL}models/widget.stl`; + let attrs = await StlDef.extractAttributes(url, streamOf(bytes), {}); + + assert.strictEqual(attrs.format, 'binary', 'binary encoding detected'); + assert.strictEqual(attrs.triangleCount, 2, 'binary triangle count stamped'); + assert.strictEqual(attrs.name, 'widget.stl', 'inherits base name'); + assert.strictEqual(attrs.url, url, 'inherits url'); + assert.strictEqual( + attrs.contentSize, + bytes.length, + 'inherits base content size', + ); + }); + + test('StlDef.extractAttributes detects ASCII encoding and solid name', async function (assert) { + let { StlDef } = await loadBase(); + let bytes = new TextEncoder().encode(ASCII_STL); + let url = `${testRealmURL}models/cube.stl`; + let attrs = await StlDef.extractAttributes(url, streamOf(bytes), {}); + assert.strictEqual(attrs.format, 'ascii'); + assert.strictEqual(attrs.solidName, 'cube'); + }); + + test('StlDef.extractAttributes rejects a non-.stl extension', async function (assert) { + let { StlDef, FileContentMismatchError } = await loadBase(); + let bytes = new TextEncoder().encode('# not stl'); + let url = `${testRealmURL}readme.md`; + await assert.rejects( + StlDef.extractAttributes(url, streamOf(bytes), {}), + FileContentMismatchError, + ); + }); + + // With no file url there is nothing to render — the viewer shows its + // source-unavailable fallback and never mounts a canvas host or triggers the + // CDN three.js load. (Real WebGL rendering is verified out-of-band, since it + // needs a browser with a GPU/SwiftShader.) + test('ThreeModelViewer shows a fallback when no url is provided', async function (assert) { + let mod = await loader.import('@cardstack/base/three-model-viewer'); + let Viewer = mod.default; + await render( + , + ); + assert + .dom('[data-test-three-fallback]') + .exists('renders source-unavailable without a url'); + assert.dom('[data-test-three-canvas]').doesNotExist(); + }); +}); 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..4b8f4a6e1f2 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,13 @@ module('Unit | isFileDefCodeRef', function (hooks) { ), 'PngDef', ); + assert.true( + isFileDefCodeRef( + { module: baseRRI('stl-file-def'), name: 'StlDef' }, + virtualNetwork, + ), + 'StlDef', + ); }); 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 9dc1ac622a7..5fe6b15e2ec 100644 --- a/packages/runtime-common/file-def-code-ref.ts +++ b/packages/runtime-common/file-def-code-ref.ts @@ -39,6 +39,7 @@ 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-file-def'), name: 'StlDef' }, '.mismatch': { module: './filedef-mismatch' as RealmResourceIdentifier, name: 'FileDef', diff --git a/packages/runtime-common/infer-content-type.ts b/packages/runtime-common/infer-content-type.ts index ef438efd049..2d44ec19fa9 100644 --- a/packages/runtime-common/infer-content-type.ts +++ b/packages/runtime-common/infer-content-type.ts @@ -29,6 +29,13 @@ export function isBinaryFilename(filename: string): boolean { mimeType.startsWith('font/') || mimeType.startsWith('audio/') || mimeType === 'application/pdf' || - mimeType === 'application/vnd.ms-fontobject' // .eot legacy font + mimeType === 'application/vnd.ms-fontobject' || // .eot legacy font + // STL 3D meshes. `.stl` resolves to `model/stl` (or the legacy + // `application/vnd.ms-pki.stl`) — neither matches a prefix above, so + // without this the common binary encoding would be read as UTF-8 text and + // its bytes mangled. ASCII STL is technically text, but binary is the + // default encoding and erring toward binary is the safe choice. + mimeType === 'model/stl' || + mimeType === 'application/vnd.ms-pki.stl' ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7cf7cd2cdd..434e94e3f41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,33 +6,9 @@ settings: catalogs: default: - '@actions/core': - specifier: ^1.2.6 - version: 1.11.1 - '@actions/github': - specifier: ^4.0.0 - version: 4.0.0 - '@aws-crypto/sha256-js': - specifier: ^5.2.0 - version: 5.2.0 '@babel/core': specifier: ^7.26.10 version: 7.29.7 - '@babel/generator': - specifier: ^7.17.8 - version: 7.29.7 - '@babel/helper-module-imports': - specifier: ^7.18.2 - version: 7.29.7 - '@babel/helper-module-transforms': - specifier: ^7.18.9 - version: 7.29.7 - '@babel/parser': - specifier: 7.27.0 - version: 7.27.0 - '@babel/plugin-proposal-class-properties': - specifier: ^7.18.6 - version: 7.18.6 '@babel/plugin-proposal-decorators': specifier: ^7.23.2 version: 7.29.7 @@ -45,27 +21,6 @@ catalogs: '@babel/plugin-syntax-typescript': specifier: ^7.17.12 version: 7.29.7 - '@babel/plugin-transform-class-properties': - specifier: ^7.22.5 - version: 7.29.7 - '@babel/plugin-transform-class-static-block': - specifier: ^7.22.11 - version: 7.29.7 - '@babel/plugin-transform-modules-amd': - specifier: ^7.13.0 - version: 7.29.7 - '@babel/plugin-transform-typescript': - specifier: ^7.16.8 - version: 7.29.7 - '@babel/preset-typescript': - specifier: ^7.24.7 - version: 7.29.7 - '@babel/runtime': - specifier: ^7.22.11 - version: 7.29.7 - '@babel/traverse': - specifier: 7.27.0 - version: 7.27.0 '@cardstack/view-transitions': specifier: ^0.2.0 version: 0.2.0 @@ -87,21 +42,9 @@ catalogs: '@codemirror/view': specifier: ^6.41.0 version: 6.43.1 - '@ember/string': - specifier: ^4.0.1 - version: 4.0.1 - '@ember/test-helpers': - specifier: ^5.4.1 - version: 5.4.3 '@ember/test-waiters': specifier: ^4.1.1 version: 4.1.2 - '@eslint/eslintrc': - specifier: ^2.1.4 - version: 2.1.4 - '@eslint/js': - specifier: ^8.57.1 - version: 8.57.1 '@floating-ui/dom': specifier: ^1.6.3 version: 1.7.6 @@ -111,36 +54,15 @@ catalogs: '@glint/template': specifier: ^1.7.7 version: 1.7.7 - '@koa/cors': - specifier: ^4.0.0 - version: 4.0.0 - '@koa/router': - specifier: ^14.0.0 - version: 14.0.0 '@lezer/markdown': specifier: ^1.6.3 version: 1.6.4 - '@lukeed/uuid': - specifier: ^2.0.1 - version: 2.0.1 - '@octokit/rest': - specifier: ^22.0.1 - version: 22.0.1 '@percy/cli': specifier: ^1.31.1 version: 1.31.14 '@percy/ember': specifier: ^5.0.0 version: 5.0.1 - '@playwright/test': - specifier: ^1.54.0 - version: 1.61.0 - '@rollup/plugin-babel': - specifier: ^6.0.4 - version: 6.1.0 - '@sentry/node': - specifier: ^8.31.0 - version: 8.55.2 '@simple-dom/interface': specifier: ^1.4.0 version: 1.4.0 @@ -153,84 +75,27 @@ catalogs: '@simple-dom/void-map': specifier: ^1.4.0 version: 1.4.0 - '@sinonjs/fake-timers': - specifier: ^11.2.2 - version: 11.3.1 '@sqlite.org/sqlite-wasm': specifier: 3.45.1-build1 version: 3.45.1-build1 - '@types/archiver': - specifier: ^7.0.0 - version: 7.0.0 - '@types/babel__core': - specifier: ^7.1.19 - version: 7.20.5 - '@types/babel__generator': - specifier: ^7.6.4 - version: 7.27.0 - '@types/babel__traverse': - specifier: ^7.14.2 - version: 7.28.0 - '@types/diff': - specifier: ^5.0.2 - version: 5.2.3 - '@types/dompurify': - specifier: ^3.0.2 - version: 3.2.0 '@types/flat': specifier: ^5.0.5 version: 5.0.5 - '@types/fs-extra': - specifier: ^11.0.4 - version: 11.0.4 '@types/htmlbars-inline-precompile': specifier: ^3.0.3 version: 3.0.4 '@types/indefinite': specifier: ^2.3.4 version: 2.3.4 - '@types/js-string-escape': - specifier: ^1.0.1 - version: 1.0.3 - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 - '@types/jsdom': - specifier: ^21.1.1 - version: 21.1.7 - '@types/jsonwebtoken': - specifier: 9.0.10 - version: 9.0.10 - '@types/koa': - specifier: ^2.13.5 - version: 2.15.2 - '@types/koa-compose': - specifier: ^3.2.5 - version: 3.2.9 - '@types/koa__cors': - specifier: ^4.0.0 - version: 4.0.3 - '@types/koa__router': - specifier: ^12.0.0 - version: 12.0.5 '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 '@types/matrix-js-sdk': specifier: ^11.0.1 version: 11.1.0 - '@types/mime-types': - specifier: ^2.1.1 - version: 2.1.4 '@types/ms': specifier: ^2.1.0 version: 2.1.0 - '@types/node': - specifier: ^24.3.0 - version: 24.13.2 - '@types/pg': - specifier: ^8.11.5 - version: 8.20.0 '@types/pluralize': specifier: ^0.0.30 version: 0.0.30 @@ -243,39 +108,12 @@ catalogs: '@types/rsvp': specifier: ^4.0.9 version: 4.0.9 - '@types/sane': - specifier: ^2.0.1 - version: 2.0.9 - '@types/sinon': - specifier: ^17.0.3 - version: 17.0.4 - '@types/sinonjs__fake-timers': - specifier: ^8.1.5 - version: 8.1.5 '@types/statuses': specifier: ^2.0.5 version: 2.0.6 - '@types/stream-chain': - specifier: ^2.0.1 - version: 2.1.0 - '@types/stream-json': - specifier: ^1.7.3 - version: 1.7.8 - '@types/superagent': - specifier: ^8.1.9 - version: 8.1.10 - '@types/supertest': - specifier: ^2.0.12 - version: 2.0.16 - '@types/tmp': - specifier: ^0.2.3 - version: 0.2.6 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 - '@types/yargs': - specifier: ^17.0.10 - version: 17.0.35 '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 version: 7.18.0 @@ -285,75 +123,36 @@ catalogs: '@universal-ember/test-support': specifier: ^0.5.1 version: 0.5.1 - '@vscode/vsce': - specifier: ^3.1.0 - version: 3.9.2 - acorn: - specifier: ^8.16.0 - version: 8.17.0 ajv: specifier: ^8.17.1 version: 8.20.0 - archiver: - specifier: ^7.0.0 - version: 7.0.1 awesome-phonenumber: specifier: ^7.2.0 version: 7.8.0 - babel-plugin-dynamic-import-node: - specifier: ^2.3.3 - version: 2.3.3 - babel-plugin-ember-template-compilation: - specifier: ^2.4.1 - version: 2.4.1 buffer: specifier: ^6.0.3 version: 6.0.3 camelcase: specifier: ^6.3.0 version: 6.3.0 - chess.js: - specifier: 1.0.0-beta.8 - version: 1.0.0-beta.8 - classnames: - specifier: ^2.3.2 - version: 2.5.1 concurrently: specifier: ^8.2.2 version: 8.2.2 content-tag: specifier: ^4.0.0 version: 4.2.0 - countries-list: - specifier: ^3.1.1 - version: 3.3.0 - cron: - specifier: ^3.1.7 - version: 3.5.0 date-fns: specifier: ^2.28.0 version: 2.30.0 - dayjs: - specifier: ^1.11.7 - version: 1.11.21 decorator-transforms: specifier: ^2.3.0 version: 2.3.2 - diff: - specifier: ^5.1.0 - version: 5.2.2 - dompurify: - specifier: ^3.0.3 - version: 3.4.10 ember-animated: specifier: ^2.3.0 version: 2.3.0 ember-concurrency: specifier: ^5.2.0 version: 5.2.0 - ember-concurrency-ts: - specifier: ^0.3.1 - version: 0.3.1 ember-elsewhere: specifier: ^2.0.1 version: 2.0.1 @@ -375,51 +174,27 @@ catalogs: ember-resources: specifier: ^7.0.7 version: 7.0.8 - ember-set-body-class: - specifier: ^1.0.3 - version: 1.0.3 ember-source: specifier: ~6.10.0 version: 6.10.1 ember-template-lint: specifier: ^7.8.1 version: 7.9.3 - esbuild: - specifier: ^0.24.0 - version: 0.24.2 eslint: specifier: ^8.57.1 version: 8.57.1 eslint-config-prettier: specifier: ^9.1.2 version: 9.1.2 - eslint-doc-generator: - specifier: ^1.7.1 - version: 1.7.1 eslint-plugin-ember: specifier: ^13.3.2 version: 13.3.2 - eslint-plugin-eslint-comments: - specifier: ^3.2.0 - version: 3.2.0 - eslint-plugin-eslint-plugin: - specifier: ^5.5.1 - version: 5.5.1 - eslint-plugin-filenames: - specifier: ^1.3.2 - version: 1.3.2 eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0 - eslint-plugin-markdown: - specifier: ^5.1.0 - version: 5.1.0 eslint-plugin-n: specifier: ^17.17.0 version: 17.24.0 - eslint-plugin-prefer-let: - specifier: ^3.0.1 - version: 3.0.1 eslint-plugin-prettier: specifier: ^5.5.4 version: 5.5.6 @@ -429,15 +204,6 @@ catalogs: eslint-plugin-qunit-dom: specifier: ^1.0.0 version: 1.0.0 - eslint-plugin-simple-import-sort: - specifier: ^8.0.0 - version: 8.0.0 - eslint-plugin-typescript-sort-keys: - specifier: ^3.2.0 - version: 3.3.0 - eslint-plugin-unicorn: - specifier: ^51.0.1 - version: 51.0.1 ethers: specifier: ^6.6.2 version: 6.16.0 @@ -447,99 +213,36 @@ catalogs: fast-json-stable-stringify: specifier: ^2.1.0 version: 2.1.0 - file-loader: - specifier: ^6.2.0 - version: 6.2.0 filesize: specifier: ^10.0.12 version: 10.1.6 flat: specifier: ^5.0.2 version: 5.0.2 - focus-trap: - specifier: ^7.4.3 - version: 7.8.0 - fs-extra: - specifier: ^11.3.0 - version: 11.3.5 glimmer-scoped-css: specifier: ^0.8.1 version: 0.8.1 - glob: - specifier: ^11.0.1 - version: 11.1.0 - http-server: - specifier: ^14.1.1 - version: 14.1.1 - http-status-codes: - specifier: ^2.2.0 - version: 2.3.0 ignore: specifier: ^5.2.0 version: 5.3.2 indefinite: specifier: ^2.4.3 version: 2.5.2 - js-string-escape: - specifier: ^1.0.1 - version: 1.0.1 - js-yaml: - specifier: ^4.1.0 - version: 4.2.0 - jsdom: - specifier: ^21.1.1 - version: 21.1.2 - json-typescript: - specifier: ^1.1.2 - version: 1.1.2 - jsonwebtoken: - specifier: 9.0.3 - version: 9.0.3 katex: specifier: ^0.16.44 version: 0.16.47 - koa: - specifier: ^2.14.1 - version: 2.16.4 - koa-compose: - specifier: ^4.1.0 - version: 4.1.0 - koa-proxies: - specifier: ^0.12.3 - version: 0.12.4 lodash-es: specifier: ^4.17.21 version: 4.18.1 loglevel: specifier: ^1.8.1 version: 1.9.2 - magic-string: - specifier: ^0.30.21 - version: 0.30.21 - marked: - specifier: ^12.0.1 - version: 12.0.2 - marked-alert: - specifier: ^2.1.2 - version: 2.1.2 - marked-extended-tables: - specifier: ^2.0.1 - version: 2.0.1 - marked-footnote: - specifier: ^1.4.0 - version: 1.4.0 - marked-gfm-heading-id: - specifier: ^3.2.0 - version: 3.2.0 matrix-js-sdk: specifier: ^38.3.0 version: 38.3.0 mermaid: specifier: ^11.14.0 version: 11.15.0 - mime-types: - specifier: ^2.1.35 - version: 2.1.35 moment: specifier: ^2.29.4 version: 2.30.1 @@ -549,21 +252,9 @@ catalogs: ms: specifier: ^2.1.3 version: 2.1.3 - node-pg-migrate: - specifier: ^6.2.2 - version: 6.2.2 - npm-run-all: - specifier: ^4.1.5 - version: 4.1.5 - openai: - specifier: 5.12.2 - version: 5.12.2 path-browserify: specifier: ^1.0.1 version: 1.0.1 - pg: - specifier: ^8.11.5 - version: 8.21.0 pluralize: specifier: ^8.0.0 version: 8.0.0 @@ -588,33 +279,12 @@ catalogs: reactiveweb: specifier: ^1.3.0 version: 1.9.3 - recast: - specifier: ^0.23.4 - version: 0.23.11 - requireindex: - specifier: ^1.2.0 - version: 1.2.0 - rollup: - specifier: ^4.18.1 - version: 4.62.0 - rollup-plugin-copy: - specifier: ^3.5.0 - version: 3.5.0 safe-stable-stringify: specifier: ^2.4.3 version: 2.5.0 - sane: - specifier: ^5.0.1 - version: 5.0.1 simple-html-tokenizer: specifier: ^0.5.11 version: 0.5.11 - sinon: - specifier: ^19.0.2 - version: 19.0.5 - sql-parser-cst: - specifier: ^0.28.0 - version: 0.28.1 start-server-and-test: specifier: ^1.14.0 version: 1.15.4 @@ -624,60 +294,33 @@ catalogs: stream-browserify: specifier: ^3.0.0 version: 3.0.0 - stream-chain: - specifier: ^2.2.5 - version: 2.2.5 stream-json: specifier: ^1.8.0 version: 1.9.1 - stripe: - specifier: ^17.2.1 - version: 17.7.0 super-fast-md5: specifier: ^1.0.1 version: 1.0.3 - supertest: - specifier: ^6.2.4 - version: 6.3.4 - svgo: - specifier: ^3.0.2 - version: 3.3.3 testem: specifier: ^3.10.1 version: 3.20.1 testem-multi-reporter: specifier: ^1.2.0 version: 1.2.0 - tmp: - specifier: ^0.2.1 - version: 0.2.7 typescript: specifier: ~5.9.3 version: 5.9.3 - undici: - specifier: ^7.22.0 - version: 7.28.0 util: specifier: ^0.12.5 version: 0.12.5 uuid: specifier: ^9.0.1 version: 9.0.1 - vite: - specifier: ^6.3.2 - version: 6.4.3 - vitest: - specifier: ^2.1.9 - version: 2.1.9 wait-for-localhost-cli: specifier: ^3.2.0 version: 3.2.0 yaml: specifier: ^2.5.1 version: 2.9.0 - yargs: - specifier: ^17.5.1 - version: 17.7.2 overrides: '@types/eslint': 8.56.5 @@ -1221,7 +864,7 @@ importers: version: 5.5.6(@types/eslint@8.56.5)(eslint-config-prettier@9.1.2(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.4) http-server: specifier: ^14.1.1 - version: 14.1.1(debug@4.3.4) + version: 14.1.1 lucide-static: specifier: ^0.447.0 version: 0.447.0 @@ -1565,7 +1208,7 @@ importers: version: 8.0.1(@babel/core@7.29.7)(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(@ember/test-waiters@4.1.2)(axe-core@4.12.1)(qunit@2.26.0) ember-auto-import: specifier: ^2.7.2 - version: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2(postcss@8.5.15)) + version: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2) ember-cli: specifier: ^5.4.1 version: 5.12.0(@babel/core@7.29.7)(@types/node@24.13.2) @@ -1604,7 +1247,7 @@ importers: version: 1.0.0(patch_hash=0e5253a008cc7bf02424d786e7a8fb2397bc48962f3cd1443f2c0fdd8200c96d) ember-freestyle: specifier: 'catalog:' - version: 0.24.0(@babel/core@7.29.7)(@ember/string@4.0.1)(@glint/template@1.7.7)(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.107.2(postcss@8.5.15)) + version: 0.24.0(@babel/core@7.29.7)(@ember/string@4.0.1)(@glint/template@1.7.7)(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.107.2) ember-load-initializers: specifier: ^3.0.0 version: 3.0.1(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5)) @@ -2068,10 +1711,10 @@ importers: version: 1.31.14(typescript@5.9.3) '@percy/ember': specifier: 'catalog:' - version: 5.0.1(@babel/core@7.29.7)(@glint/template@1.7.7)(webpack@5.107.2) + version: 5.0.1(@babel/core@7.29.7)(@glint/template@1.7.7)(webpack@5.107.2(postcss@8.5.15)) '@rollup/plugin-babel': specifier: ^6.0.4 - version: 6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.0) + version: 6.1.0(@babel/core@7.29.7) '@simple-dom/interface': specifier: 'catalog:' version: 1.4.0 @@ -2173,7 +1816,7 @@ importers: version: 1.0.3(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5)) ember-auto-import: specifier: ^2.7.2 - version: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2) + version: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2(postcss@8.5.15)) ember-basic-dropdown: specifier: 8.0.4 version: 8.0.4(patch_hash=19b0fc5d4bd8b9aa296c4065fa5e33bdbb965db0b277810b596eacd0b9e2f428)(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(@glimmer/component@2.1.1)(@glimmer/tracking@1.1.2)(@glint/template@1.7.7)(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5)) @@ -2224,13 +1867,13 @@ importers: version: 2.0.1(@glint/template@1.7.7)(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5)) ember-exam: specifier: ^10.1.0 - version: 10.1.0(@glint/template@1.7.7)(ember-qunit@9.1.0(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(qunit@2.26.0))(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.26.0)(webpack@5.107.2) + version: 10.1.0(@glint/template@1.7.7)(ember-qunit@9.1.0(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(qunit@2.26.0))(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.26.0)(webpack@5.107.2(postcss@8.5.15)) ember-focus-trap: specifier: ^1.0.1 version: 1.2.0(@babel/core@7.29.7) ember-freestyle: specifier: 'catalog:' - version: 0.24.0(@babel/core@7.29.7)(@ember/string@4.0.1)(@glint/template@1.7.7)(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.107.2) + version: 0.24.0(@babel/core@7.29.7)(@ember/string@4.0.1)(@glint/template@1.7.7)(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(webpack@5.107.2(postcss@8.5.15)) ember-keyboard: specifier: 'catalog:' version: 9.0.4(@babel/core@7.29.7)(@ember/test-helpers@5.4.3(@babel/core@7.29.7)) @@ -2284,7 +1927,7 @@ importers: version: 9.1.2(eslint@8.57.1) eslint-plugin-ember: specifier: 'catalog:' - version: 13.3.2(@babel/eslint-parser@7.29.7(@babel/core@7.29.7)(eslint@8.57.1))(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + version: 13.3.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) eslint-plugin-import: specifier: 'catalog:' version: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) @@ -2741,7 +2384,7 @@ importers: version: 11.1.0 http-server: specifier: 'catalog:' - version: 14.1.1(debug@4.3.4) + version: 14.1.1 js-yaml: specifier: 'catalog:' version: 4.2.0 @@ -2834,7 +2477,7 @@ importers: version: 3.2.0 wait-on: specifier: ^9.0.4 - version: 9.0.10(debug@4.3.4) + version: 9.0.10 wtfnode: specifier: ^0.10.1 version: 0.10.1 @@ -18786,10 +18429,10 @@ snapshots: '@percy/dom@1.31.14': {} - '@percy/ember@5.0.1(@babel/core@7.29.7)(@glint/template@1.7.7)(webpack@5.107.2)': + '@percy/ember@5.0.1(@babel/core@7.29.7)(@glint/template@1.7.7)(webpack@5.107.2(postcss@8.5.15))': dependencies: '@percy/sdk-utils': 1.31.14 - ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2) + ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2(postcss@8.5.15)) ember-cli-babel: 8.3.1(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' @@ -18926,6 +18569,14 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 5.4.0 + transitivePeerDependencies: + - supports-color + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.62.0)': dependencies: '@babel/core': 7.29.7 @@ -18954,6 +18605,12 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.2 + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + '@rollup/pluginutils@5.4.0(rollup@4.62.0)': dependencies: '@types/estree': 1.0.9 @@ -20612,7 +20269,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.18.0(debug@4.3.4): + axios@1.18.0: dependencies: follow-redirects: 1.16.0(debug@4.3.4) form-data: 4.0.6 @@ -22884,7 +22541,7 @@ snapshots: heimdalljs-fs-monitor: 1.1.2 heimdalljs-graph: 1.0.0 heimdalljs-logger: 0.1.10 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 inflection: 2.0.1 inquirer: 9.3.8(@types/node@24.13.2) is-git-url: 1.0.0 @@ -23026,7 +22683,7 @@ snapshots: heimdalljs-fs-monitor: 1.1.2 heimdalljs-graph: 1.0.0 heimdalljs-logger: 0.1.10 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 inflection: 3.0.2 inquirer: 13.4.3(@types/node@25.9.3) is-git-url: 1.0.0 @@ -23192,6 +22849,21 @@ snapshots: transitivePeerDependencies: - typescript + ember-eslint-parser@0.13.0(patch_hash=364c7e71472801c87618bf2b2cdabd256358166bbe107f29d321ab35d0a53425)(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@glimmer/syntax': 0.95.0 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + content-tag: 4.2.0 + ember-estree: 0.6.4 + eslint-scope: 9.1.2 + html-tags: 5.1.0 + mathml-tag-names: 4.0.0 + svg-tags: 1.0.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + transitivePeerDependencies: + - typescript + ember-estree@0.6.4: dependencies: '@glimmer/env': 0.1.7 @@ -23199,13 +22871,13 @@ snapshots: content-tag: 4.2.0 oxc-parser: 0.130.0 - ember-exam@10.1.0(@glint/template@1.7.7)(ember-qunit@9.1.0(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(qunit@2.26.0))(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.26.0)(webpack@5.107.2): + ember-exam@10.1.0(@glint/template@1.7.7)(ember-qunit@9.1.0(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(qunit@2.26.0))(ember-source@6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5))(qunit@2.26.0)(webpack@5.107.2(postcss@8.5.15)): dependencies: '@babel/core': 7.29.7 chalk: 5.6.2 cli-table3: 0.6.5 debug: 4.4.3 - ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2) + ember-auto-import: 2.13.1(@glint/template@1.7.7)(webpack@5.107.2(postcss@8.5.15)) ember-cli-babel: 8.3.1(@babel/core@7.29.7) ember-qunit: 9.1.0(@ember/test-helpers@5.4.3(@babel/core@7.29.7))(qunit@2.26.0) ember-source: 6.10.1(patch_hash=ea945024993105fb6cc4ae5cb5e9ea8e0eff6cd5fe0b0033c43dd0cf9453eb0d)(@glimmer/component@2.1.1)(rsvp@4.8.5) @@ -24005,6 +23677,32 @@ snapshots: - '@babel/eslint-parser' - typescript + eslint-plugin-ember@13.3.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3): + dependencies: + '@ember-data/rfc395-data': 0.0.4 + aria-query: 5.3.2 + axobject-query: 4.1.0 + css-tree: 3.2.1 + editorconfig: 3.0.2 + ember-eslint-parser: 0.13.0(patch_hash=364c7e71472801c87618bf2b2cdabd256358166bbe107f29d321ab35d0a53425)(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(typescript@5.9.3) + ember-rfc176-data: 0.3.18 + eslint: 8.57.1 + eslint-utils: 3.0.0(eslint@8.57.1) + estraverse: 5.3.0 + html-tags: 3.3.1 + language-tags: 1.0.9 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + mathml-tag-names: 4.0.0 + requireindex: 1.2.0 + snake-case: 3.0.4 + svg-tags: 1.0.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3) + transitivePeerDependencies: + - '@babel/eslint-parser' + - typescript + eslint-plugin-es-x@7.8.0(eslint@8.57.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) @@ -25387,7 +25085,7 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy@1.18.1(debug@4.3.4): + http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 follow-redirects: 1.16.0(debug@4.3.4) @@ -25395,14 +25093,14 @@ snapshots: transitivePeerDependencies: - debug - http-server@14.1.1(debug@4.3.4): + http-server@14.1.1: dependencies: basic-auth: 2.0.1 chalk: 4.1.2 corser: 2.0.1 he: 1.2.0 html-encoding-sniffer: 3.0.0 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 mime: 1.6.0 minimist: 1.2.8 opener: 1.5.2 @@ -26158,7 +25856,7 @@ snapshots: koa-proxies@0.12.4(koa@2.16.4): dependencies: - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 koa: 2.16.4 path-match: 1.2.4 uuid: 8.3.2 @@ -29220,7 +28918,7 @@ snapshots: execa: 9.6.1 express: 5.2.1 glob: 13.0.6 - http-proxy: 1.18.1(debug@4.3.4) + http-proxy: 1.18.1 js-yaml: 4.2.0 lodash: 4.18.1 minimatch: 10.2.5 @@ -30020,9 +29718,9 @@ snapshots: transitivePeerDependencies: - debug - wait-on@9.0.10(debug@4.3.4): + wait-on@9.0.10: dependencies: - axios: 1.18.0(debug@4.3.4) + axios: 1.18.0 joi: 18.2.1 lodash: 4.18.1 minimist: 1.2.8