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 })
+ }
+})