diff --git a/cli/build/convert-model-urls-to-file-urls.ts b/cli/build/convert-model-urls-to-file-urls.ts index a629d6a80..a3bf14ee7 100644 --- a/cli/build/convert-model-urls-to-file-urls.ts +++ b/cli/build/convert-model-urls-to-file-urls.ts @@ -1,12 +1,15 @@ +import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" /** - * Convert local file paths in model URLs to file:// URLs for fetch() compatibility. - * The circuit-json-to-gltf library uses fetch() to load GLB/STL/OBJ/GLTF files, - * which requires proper URLs rather than local file paths. + * Resolve local model paths before the converter applies its registry base URL. + * Keep assets as file references so loaders can read them on demand. */ -export const convertModelUrlsToFileUrls = (circuitJson: any[]): any[] => { +export const convertModelUrlsToFileUrls = ( + circuitJson: any[], + projectDir = process.cwd(), +): any[] => { const modelUrlKeys = [ "model_glb_url", "glb_model_url", @@ -30,12 +33,17 @@ export const convertModelUrlsToFileUrls = (circuitJson: any[]): any[] => { // Skip values that are already URLs (http://, https://, file://, etc.) if (value.match(/^[a-zA-Z]+:\/\//)) continue + const localPath = path.resolve(projectDir, value) + // Uninstalled package assets still need the registry URL resolver. + if (/^(\.\/)?node_modules\//.test(value) && !existsSync(localPath)) + continue + if (value.startsWith("/") || value.match(/^[a-zA-Z]:\\/)) { // Absolute path (Unix or Windows) updated[key] = pathToFileURL(value).href - } else if (value.startsWith(".")) { + } else if (value.startsWith(".") || existsSync(localPath)) { // Relative path (e.g. ./chip.glb) — resolve against cwd - updated[key] = pathToFileURL(path.resolve(process.cwd(), value)).href + updated[key] = pathToFileURL(localPath).href } } } diff --git a/lib/shared/export-snippet.ts b/lib/shared/export-snippet.ts index 21babed8a..ee2fef46c 100644 --- a/lib/shared/export-snippet.ts +++ b/lib/shared/export-snippet.ts @@ -37,6 +37,7 @@ import { convertCircuitJsonToSchematicPdf } from "./convert-circuit-json-to-sche import { convertToKicadLibrary } from "./convert-to-kicad-library" import { importFromUserLand } from "./importFromUserLand" import { isCircuitJsonFile } from "./is-circuit-json-file" +import { convertModelUrlsToFileUrls } from "cli/build/convert-model-urls-to-file-urls" const writeFileAsync = promisify(fs.writeFile) @@ -230,7 +231,7 @@ export const exportSnippet = async ({ case "gltf": outputContent = JSON.stringify( await convertCircuitJsonToGltf( - circuitJson, + convertModelUrlsToFileUrls(circuitJson), getCircuitJsonToGltfOptions({ format: "gltf" }), ), null, @@ -240,7 +241,7 @@ export const exportSnippet = async ({ case "glb": outputContent = Buffer.from( (await convertCircuitJsonToGltf( - circuitJson, + convertModelUrlsToFileUrls(circuitJson), getCircuitJsonToGltfOptions({ format: "glb" }), )) as ArrayBuffer, ) diff --git a/lib/shared/get-circuit-json-to-gltf-options.ts b/lib/shared/get-circuit-json-to-gltf-options.ts index eedada831..997c3ae61 100644 --- a/lib/shared/get-circuit-json-to-gltf-options.ts +++ b/lib/shared/get-circuit-json-to-gltf-options.ts @@ -3,6 +3,7 @@ import { getSessionToken, getSessionTokenFromNpmrc, } from "lib/cli-config" +import { nodeFilesystem } from "./node-filesystem" type CircuitJsonToGltfFormat = "gltf" | "glb" @@ -16,6 +17,7 @@ export const getCircuitJsonToGltfOptions = ({ return { format, projectBaseUrl: getRegistryApiUrl(), + fs: nodeFilesystem, ...(sessionToken ? { authHeaders: { Authorization: `Bearer ${sessionToken}` } } : {}), diff --git a/lib/shared/node-filesystem.ts b/lib/shared/node-filesystem.ts new file mode 100644 index 000000000..867c18b3f --- /dev/null +++ b/lib/shared/node-filesystem.ts @@ -0,0 +1,6 @@ +import { readFile } from "node:fs/promises" + +/** Filesystem capability passed to circuit-json-to-gltf for local CAD files. */ +export const nodeFilesystem = { + readFile: async (fileUrl: URL) => new Uint8Array(await readFile(fileUrl)), +} diff --git a/tests/cli/export/export-downloaded-model.test.ts b/tests/cli/export/export-downloaded-model.test.ts new file mode 100644 index 000000000..49b2a035f --- /dev/null +++ b/tests/cli/export/export-downloaded-model.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +// Same nested static-asset import structure produced by tsci import --download. +const obj = "v 0 0 0\nv 2 0 0\nv 0 2 0\nf 1 2 3\n" + +for (const format of ["glb", "gltf"] as const) { + test(`export ${format} includes geometry from a downloaded OBJ`, async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + const componentDir = path.join(tmpDir, "imports", "DownloadedPart") + await mkdir(componentDir, { recursive: true }) + await writeFile(path.join(componentDir, "part.obj"), obj) + await writeFile( + path.join(componentDir, "part.tsx"), + ` +import objPath from "./part.obj" +export const DownloadedPart = () => ( + +) +`, + ) + await writeFile( + path.join(tmpDir, "index.tsx"), + ` +import { DownloadedPart } from "./imports/DownloadedPart/part" +export default () => +`, + ) + const result = await runCommand(`tsci export index.tsx -f ${format}`) + expect(result.exitCode).toBe(0) + const output = await readFile(path.join(tmpDir, `index.${format}`)) + const gltf = JSON.parse( + format === "glb" + ? output.subarray(20, 20 + output.readUInt32LE(12)).toString() + : output.toString(), + ) + const component = gltf.nodes.find( + (node: { name?: string }) => node.name === "U1", + ) + expect(component).toBeDefined() + const primitives = gltf.meshes[component.mesh].primitives + const vertexCount = primitives.reduce( + (total: number, primitive: any) => + total + gltf.accessors[primitive.attributes.POSITION].count, + 0, + ) + expect(vertexCount).toBe(3) + expect(await readFile(path.join(componentDir, "part.obj"), "utf8")).toBe( + obj, + ) + }, 30000) +} diff --git a/tests/shared/convert-model-urls-to-file-urls.test.ts b/tests/shared/convert-model-urls-to-file-urls.test.ts new file mode 100644 index 000000000..0123364cc --- /dev/null +++ b/tests/shared/convert-model-urls-to-file-urls.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { convertModelUrlsToFileUrls } from "cli/build/convert-model-urls-to-file-urls" + +test("local models remain file references and Circuit JSON is not mutated", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "tsci-model-paths-")) + try { + const file = path.join(dir, "part with spaces.obj") + await writeFile(file, "v 0 0 0") + const urls = [ + "./part with spaces.obj", + "part with spaces.obj", + file, + pathToFileURL(file).href, + ] + const input = urls.map((url) => ({ + type: "cad_component", + model_obj_url: url, + })) + const output = convertModelUrlsToFileUrls(input, dir) + expect(output.map((element) => element.model_obj_url)).toEqual( + urls.map(() => pathToFileURL(file).href), + ) + expect(input.map((element) => element.model_obj_url)).toEqual(urls) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test("remote URLs and uninstalled registry assets retain their resolver", () => { + const input = [ + { model_obj_url: "https://example.com/part.obj" }, + { model_obj_url: "data:application/octet-stream;base64,dGVzdA==" }, + { model_obj_url: "./node_modules/@tsci/not-installed.part/part.obj" }, + ] + expect(convertModelUrlsToFileUrls(input)).toEqual(input) +}) diff --git a/tests/shared/node-filesystem.test.ts b/tests/shared/node-filesystem.test.ts new file mode 100644 index 000000000..a60bc342e --- /dev/null +++ b/tests/shared/node-filesystem.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { nodeFilesystem } from "lib/shared/node-filesystem" + +test("node filesystem reads exact bytes from a file URL", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "tsci-node-fs-")) + try { + const file = path.join(dir, "model #1%.obj") + const bytes = new Uint8Array([0, 127, 128, 255]) + await writeFile(file, bytes) + expect(await nodeFilesystem.readFile(pathToFileURL(file))).toEqual(bytes) + } finally { + await rm(dir, { recursive: true, force: true }) + } +})