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
15 changes: 9 additions & 6 deletions cli/import/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ 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[],
opts: {
jlcpcb?: boolean
lcsc?: boolean
tscircuit?: boolean
download?: boolean
},
) => {
const query = getQueryFromParts(queryParts)
Expand Down Expand Up @@ -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
}> = []

Expand All @@ -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,
})
})
Expand Down Expand Up @@ -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"))
Expand Down
116 changes: 108 additions & 8 deletions lib/import/import-component-from-jlcpcb.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading