Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions cli/build/convert-model-urls-to-file-urls.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions lib/shared/export-snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -230,7 +231,7 @@ export const exportSnippet = async ({
case "gltf":
outputContent = JSON.stringify(
await convertCircuitJsonToGltf(
circuitJson,
convertModelUrlsToFileUrls(circuitJson),
getCircuitJsonToGltfOptions({ format: "gltf" }),
),
null,
Expand All @@ -240,7 +241,7 @@ export const exportSnippet = async ({
case "glb":
outputContent = Buffer.from(
(await convertCircuitJsonToGltf(
circuitJson,
convertModelUrlsToFileUrls(circuitJson),
getCircuitJsonToGltfOptions({ format: "glb" }),
)) as ArrayBuffer,
)
Expand Down
2 changes: 2 additions & 0 deletions lib/shared/get-circuit-json-to-gltf-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getSessionToken,
getSessionTokenFromNpmrc,
} from "lib/cli-config"
import { nodeFilesystem } from "./node-filesystem"

type CircuitJsonToGltfFormat = "gltf" | "glb"

Expand All @@ -16,6 +17,7 @@ export const getCircuitJsonToGltfOptions = ({
return {
format,
projectBaseUrl: getRegistryApiUrl(),
fs: nodeFilesystem,
...(sessionToken
? { authHeaders: { Authorization: `Bearer ${sessionToken}` } }
: {}),
Expand Down
6 changes: 6 additions & 0 deletions lib/shared/node-filesystem.ts
Original file line number Diff line number Diff line change
@@ -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)),
}
54 changes: 54 additions & 0 deletions tests/cli/export/export-downloaded-model.test.ts
Original file line number Diff line number Diff line change
@@ -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 = () => (
<chip name="U1" footprint="soic8" cadModel={{ objUrl: objPath }} />
)
`,
)
await writeFile(
path.join(tmpDir, "index.tsx"),
`
import { DownloadedPart } from "./imports/DownloadedPart/part"
export default () => <board width="20mm" height="20mm"><DownloadedPart /></board>
`,
)
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)
}
40 changes: 40 additions & 0 deletions tests/shared/convert-model-urls-to-file-urls.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
18 changes: 18 additions & 0 deletions tests/shared/node-filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
Loading