diff --git a/cli/import/register.ts b/cli/import/register.ts index 3a328ad2..ffcb9ee5 100644 --- a/cli/import/register.ts +++ b/cli/import/register.ts @@ -17,6 +17,7 @@ export const registerImport = (program: Command) => { .option("--jlcpcb", "Search JLCPCB components") .option("--lcsc", "Alias for --jlcpcb") .option("--tscircuit", "Search tscircuit registry packages") + .option("--download", "Download 3D models locally") .action( async ( queryParts: string[], @@ -24,6 +25,7 @@ export const registerImport = (program: Command) => { jlcpcb?: boolean lcsc?: boolean tscircuit?: boolean + download?: boolean }, ) => { const query = getQueryFromParts(queryParts) @@ -94,7 +96,7 @@ export const registerImport = (program: Command) => { title: string value: | { type: "registry"; name: string } - | { type: "jlcpcb"; part: number } + | { type: "jlcpcb"; partNumber: number } selected?: boolean }> = [] @@ -109,7 +111,7 @@ export const registerImport = (program: Command) => { jlcResults?.forEach((comp, idx) => { choices.push({ title: `[jlcpcb] ${comp.mfr} (C${comp.lcsc}) - ${comp.description}`, - value: { type: "jlcpcb", part: comp.lcsc }, + value: { type: "jlcpcb", partNumber: comp.lcsc }, selected: !choices.length && idx === 0, }) }) @@ -144,13 +146,14 @@ export const registerImport = (program: Command) => { return process.exit(1) } } else { + const lcscId = `C${choice.partNumber}` const importSpinner = ora( - `Importing "C${choice.part}" from JLCPCB...`, + `Importing "${lcscId}" from JLCPCB...`, ).start() try { - const { filePath } = await importComponentFromJlcpcb( - `C${String(choice.part)}`, - ) + const { filePath } = await importComponentFromJlcpcb(lcscId, { + download: opts.download, + }) importSpinner.succeed(kleur.green(`Imported ${filePath}`)) } catch (error) { importSpinner.fail(kleur.red("Failed to import part")) diff --git a/lib/import/import-component-from-jlcpcb.ts b/lib/import/import-component-from-jlcpcb.ts index bd694ff1..12b31c89 100644 --- a/lib/import/import-component-from-jlcpcb.ts +++ b/lib/import/import-component-from-jlcpcb.ts @@ -1,20 +1,120 @@ -import { fetchEasyEDAComponent, convertRawEasyToTsx } from "easyeda/browser" +import { + fetchEasyEDAComponent, + convertRawEasyEdaToTs as convertRawEasyToTsx, + convertEasyEdaJsonToCircuitJson, +} from "easyeda" import fs from "node:fs/promises" import path from "node:path" +import { getCompletePlatformConfig } from "lib/shared/get-complete-platform-config" +export interface ImportOptions { + download?: boolean + projectDir?: string +} + +/** + * Imports a component from JLCPCB/EasyEDA, optionally downloading its 3D model. + */ export const importComponentFromJlcpcb = async ( jlcpcbPartNumber: string, - projectDir: string = process.cwd(), + options: ImportOptions | string = {}, ) => { + const projectDir = + typeof options === "string" ? options : options.projectDir || process.cwd() + const shouldDownload = + typeof options === "object" ? Boolean(options.download) : false + const component = await fetchEasyEDAComponent(jlcpcbPartNumber) - const tsx = await convertRawEasyToTsx(component) - const fileName = tsx.match(/export const (\w+) = .*/)?.[1] + let tsxContent = await convertRawEasyToTsx(component) + + const componentNameMatch = tsxContent.match(/export const (\w+) = .*/) + const fileName = componentNameMatch?.[1] if (!fileName) { throw new Error("Could not determine file name of converted component") } + const importsDir = path.join(projectDir, "imports") - await fs.mkdir(importsDir, { recursive: true }) - const filePath = path.join(importsDir, `${fileName}.tsx`) - await fs.writeFile(filePath, tsx) - return { filePath } + const componentDir = path.join(importsDir, fileName) + await fs.mkdir(componentDir, { recursive: true }) + + let modelFilePaths: string[] = [] + if (shouldDownload) { + const result = await downloadAndLocalize3dModel({ + tsxContent, + jlcpcbPartNumber, + componentDir, + component, + }) + tsxContent = result.tsxContent + modelFilePaths = result.modelFilePaths + } + + const filePath = path.join(componentDir, "index.tsx") + await fs.writeFile(filePath, tsxContent) + + return { filePath, modelFilePaths } +} + +/** + * Downloads the 3D models referenced in the component and updates the TSX to use local paths. + */ +async function downloadAndLocalize3dModel(params: { + tsxContent: string + jlcpcbPartNumber: string + componentDir: string + component: any +}): Promise<{ tsxContent: string; modelFilePaths: string[] }> { + let { tsxContent } = params + const { jlcpcbPartNumber, componentDir, component } = params + const modelFilePaths: string[] = [] + + const platformConfig = getCompletePlatformConfig() + const platformFetch = platformConfig.platformFetch ?? globalThis.fetch + + // Extract remote URLs from the circuit JSON (more robust than regex on TSX) + const circuitJson = convertEasyEdaJsonToCircuitJson(component, { + useModelCdn: true, + shouldRecenter: true, + }) + const remoteUrls: string[] = circuitJson + .filter((item: any) => item.type === "cad_component" && item.model_obj_url) + .map((item: any) => item.model_obj_url) + + // Fallback: if no model URLs found in circuitJson, try to extract from TSX + if (remoteUrls.length === 0) { + const objUrlMatch = tsxContent.match(/objUrl:\s*"([^"]+)"/) + if (objUrlMatch?.[1]) { + remoteUrls.push(objUrlMatch[1]) + } + } + + for (const remoteUrl of remoteUrls) { + try { + const response = await platformFetch(remoteUrl) + if (!response.ok) { + console.warn(`Failed to download 3D model from ${remoteUrl}`) + continue + } + + const modelFileName = `${jlcpcbPartNumber}.obj` + + const modelFilePath = path.join(componentDir, modelFileName) + const arrayBuffer = await response.arrayBuffer() + await fs.writeFile(modelFilePath, Buffer.from(arrayBuffer)) + modelFilePaths.push(modelFilePath) + + // Update TSX to use relative path (safer because we know the exact remote URL) + const localModelPath = `./${modelFileName}` + + // We replace the remote URL wherever it appears in the TSX content + // This works because the URL is unique and identifies the objUrl/modelUrl prop + tsxContent = tsxContent + .split(`"${remoteUrl}"`) + .join(`"${localModelPath}"`) + } catch (error) { + console.error("Error downloading 3D model:", error) + } + } + + return { tsxContent, modelFilePaths } }