diff --git a/.gitignore b/.gitignore
index 7c07181..a2725d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,4 @@ debug-output
# Snapshot diff files
*.diff.png
+.vercel
\ No newline at end of file
diff --git a/lib/mesh-generation.ts b/lib/mesh-generation.ts
index ea1cce3..1df41fe 100644
--- a/lib/mesh-generation.ts
+++ b/lib/mesh-generation.ts
@@ -1,7 +1,6 @@
import type { CircuitJson } from "circuit-json"
-import type { Triangle as GLTFTriangle } from "circuit-json-to-gltf"
-import { convertCircuitJsonTo3D } from "circuit-json-to-gltf"
import type { Ref } from "stepts"
+import type { Triangle as GLTFTriangle } from "circuit-json-to-gltf"
import type { Repository } from "stepts"
import {
AdvancedFace,
@@ -315,6 +314,13 @@ export async function generateComponentMeshes(
return e
})
+ // Dynamically import circuit-json-to-gltf to avoid bundling native dependencies
+ // Use a variable to prevent the bundler from statically analyzing the import
+ const gltfModule = "circuit-json-to-gltf"
+ const { convertCircuitJsonTo3D } = await import(
+ /* @vite-ignore */ gltfModule
+ )
+
// Convert circuit JSON to 3D scene
const scene3d = await convertCircuitJsonTo3D(filteredCircuitJson, {
boardThickness,
diff --git a/package.json b/package.json
index 82b59ca..e6acb76 100644
--- a/package.json
+++ b/package.json
@@ -9,10 +9,14 @@
"test:update-snapshots": "BUN_UPDATE_SNAPSHOTS=1 bun test",
"build": "tsup-node ./lib/index.ts --format esm --dts",
"format": "biome format --write .",
- "format:check": "biome format ."
+ "format:check": "biome format .",
+ "start": "bun site/index.html",
+ "build:site": "bun build site/index.html --outdir=site-export"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
+ "@resvg/resvg-js": "^2.6.2",
+ "@resvg/resvg-wasm": "^2.6.2",
"@types/bun": "latest",
"circuit-json": "^0.0.286",
"looks-same": "^10.0.1",
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..505ae66
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,400 @@
+
+
+
+
+
+
+ Circuit JSON to STEP Converter
+
+
+
+
+
Circuit JSON to STEP Converter
+
Convert your Circuit JSON files to STEP format for 3D CAD software
+
+
+
📁
+
Drag & Drop your Circuit JSON file here
+
or click to browse
+
+
+
+
+
+
+
+
Options
+
+
+
+
+
+
+
+
+
+
+
+
External STEP Models
+
Your circuit JSON references external STEP models. Upload them here to include them in the conversion.
+
+
+
📦
+
Drag & Drop STEP files here
+
or click to browse (.step, .stp)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/main.tsx b/site/main.tsx
new file mode 100644
index 0000000..e7d9c19
--- /dev/null
+++ b/site/main.tsx
@@ -0,0 +1,390 @@
+import { circuitJsonToStep } from "../lib/index"
+
+// Get DOM elements
+const uploadArea = document.getElementById("uploadArea")!
+const fileInput = document.getElementById("fileInput")! as HTMLInputElement
+const fileInfo = document.getElementById("fileInfo")!
+const fileName = document.getElementById("fileName")!
+const fileSize = document.getElementById("fileSize")!
+const convertBtn = document.getElementById("convertBtn")! as HTMLButtonElement
+const clearBtn = document.getElementById("clearBtn")!
+const status = document.getElementById("status")!
+const includeComponentsCheckbox = document.getElementById(
+ "includeComponents",
+)! as HTMLInputElement
+const includeExternalMeshesCheckbox = document.getElementById(
+ "includeExternalMeshes",
+)! as HTMLInputElement
+
+// STEP models upload elements
+const stepModelsSection = document.getElementById("stepModelsSection")!
+const stepModelsList = document.getElementById("stepModelsList")!
+const stepUploadArea = document.getElementById("stepUploadArea")!
+const stepFileInput = document.getElementById(
+ "stepFileInput",
+)! as HTMLInputElement
+const stepModelsStatus = document.getElementById("stepModelsStatus")!
+
+let currentFile: File | null = null
+let circuitJson: any = null
+
+// Map of URL/path -> STEP file content
+let stepFilesMap: Record = {}
+// List of required STEP model URLs/paths from circuit JSON
+let requiredStepModels: string[] = []
+
+// Handle click on upload area
+uploadArea.addEventListener("click", () => {
+ fileInput.click()
+})
+
+// Handle file selection
+fileInput.addEventListener("change", (e) => {
+ const target = e.target as HTMLInputElement
+ if (target.files && target.files.length > 0 && target.files[0]) {
+ handleFile(target.files[0])
+ }
+})
+
+// Handle drag over
+uploadArea.addEventListener("dragover", (e) => {
+ e.preventDefault()
+ uploadArea.classList.add("dragover")
+})
+
+// Handle drag leave
+uploadArea.addEventListener("dragleave", () => {
+ uploadArea.classList.remove("dragover")
+})
+
+// Handle drop
+uploadArea.addEventListener("drop", (e) => {
+ e.preventDefault()
+ uploadArea.classList.remove("dragover")
+
+ if (
+ e.dataTransfer &&
+ e.dataTransfer.files.length > 0 &&
+ e.dataTransfer.files[0]
+ ) {
+ handleFile(e.dataTransfer.files[0])
+ }
+})
+
+// Handle includeComponents checkbox change
+includeComponentsCheckbox.addEventListener("change", () => {
+ if (!includeComponentsCheckbox.checked) {
+ includeExternalMeshesCheckbox.checked = false
+ includeExternalMeshesCheckbox.disabled = true
+ stepModelsSection.style.display = "none"
+ } else {
+ includeExternalMeshesCheckbox.disabled = false
+ }
+})
+
+// Handle includeExternalMeshes checkbox change
+includeExternalMeshesCheckbox.addEventListener("change", () => {
+ if (includeExternalMeshesCheckbox.checked && requiredStepModels.length > 0) {
+ stepModelsSection.style.display = "block"
+ updateStepModelsList()
+ } else {
+ stepModelsSection.style.display = "none"
+ }
+})
+
+// Initialize external meshes checkbox state
+includeExternalMeshesCheckbox.disabled = true
+
+// STEP file upload handlers
+stepUploadArea.addEventListener("click", () => {
+ stepFileInput.click()
+})
+
+stepFileInput.addEventListener("change", (e) => {
+ const target = e.target as HTMLInputElement
+ if (target.files) {
+ handleStepFiles(Array.from(target.files))
+ }
+})
+
+stepUploadArea.addEventListener("dragover", (e) => {
+ e.preventDefault()
+ stepUploadArea.classList.add("dragover")
+})
+
+stepUploadArea.addEventListener("dragleave", () => {
+ stepUploadArea.classList.remove("dragover")
+})
+
+stepUploadArea.addEventListener("drop", (e) => {
+ e.preventDefault()
+ stepUploadArea.classList.remove("dragover")
+ if (e.dataTransfer?.files) {
+ handleStepFiles(Array.from(e.dataTransfer.files))
+ }
+})
+
+// Handle uploaded STEP files
+async function handleStepFiles(files: File[]) {
+ for (const file of files) {
+ const content = await file.text()
+ const filename = file.name.toLowerCase()
+
+ // Try to match with required models by filename
+ for (const modelPath of requiredStepModels) {
+ const modelFilename = modelPath.split(/[/\\]/).pop()?.toLowerCase() || ""
+ if (
+ modelFilename === filename ||
+ modelFilename.replace(/\.step$|\.stp$/, "") ===
+ filename.replace(/\.step$|\.stp$/, "")
+ ) {
+ stepFilesMap[modelPath] = content
+ }
+ }
+ }
+ updateStepModelsList()
+}
+
+// Extract STEP model URLs from circuit JSON
+function extractStepModelUrls(json: any[]): string[] {
+ const urls: string[] = []
+ for (const item of json) {
+ if (item?.type === "cad_component" && item.model_step_url) {
+ if (!urls.includes(item.model_step_url)) {
+ urls.push(item.model_step_url)
+ }
+ }
+ }
+ return urls
+}
+
+// Update the STEP models list UI
+function updateStepModelsList() {
+ stepModelsList.innerHTML = ""
+ let loadedCount = 0
+
+ for (const modelPath of requiredStepModels) {
+ const filename = modelPath.split(/[/\\]/).pop() || modelPath
+ const isLoaded = modelPath in stepFilesMap
+ if (isLoaded) loadedCount++
+
+ const item = document.createElement("div")
+ item.className = `step-model-item${isLoaded ? " loaded" : ""}`
+
+ const nameSpan = document.createElement("span")
+ nameSpan.className = "step-model-name"
+ nameSpan.textContent = filename
+ nameSpan.title = modelPath
+
+ const statusSpan = document.createElement("span")
+ statusSpan.className = `step-model-status ${isLoaded ? "loaded" : "pending"}`
+ statusSpan.textContent = isLoaded ? "Uploaded" : "Pending"
+
+ item.appendChild(nameSpan)
+ item.appendChild(statusSpan)
+ stepModelsList.appendChild(item)
+ }
+
+ stepModelsStatus.textContent = `${loadedCount} of ${requiredStepModels.length} models uploaded`
+}
+
+// Handle file
+async function handleFile(file: File) {
+ currentFile = file
+
+ // Show file info
+ fileName.textContent = file.name
+ fileSize.textContent = `${(file.size / 1024).toFixed(2)} KB`
+ fileInfo.classList.add("visible")
+
+ // Read and parse the file
+ try {
+ showStatus("Reading file...", "processing")
+ const text = await file.text()
+ circuitJson = JSON.parse(text)
+
+ // Extract STEP model URLs
+ requiredStepModels = extractStepModelUrls(circuitJson)
+ stepFilesMap = {}
+
+ // Update STEP models section visibility
+ if (
+ includeExternalMeshesCheckbox.checked &&
+ requiredStepModels.length > 0
+ ) {
+ stepModelsSection.style.display = "block"
+ updateStepModelsList()
+ }
+
+ // Enable convert button
+ convertBtn.disabled = false
+ const modelInfo =
+ requiredStepModels.length > 0
+ ? ` Found ${requiredStepModels.length} external STEP model(s).`
+ : ""
+ showStatus(
+ `File loaded successfully!${modelInfo} Ready to convert.`,
+ "success",
+ )
+ } catch (error) {
+ showStatus(
+ `Error reading file: ${error instanceof Error ? error.message : String(error)}`,
+ "error",
+ )
+ convertBtn.disabled = true
+ circuitJson = null
+ requiredStepModels = []
+ stepFilesMap = {}
+ }
+}
+
+// Convert and download
+convertBtn.addEventListener("click", async () => {
+ if (!circuitJson) return
+
+ try {
+ showStatus("Converting to STEP format...", "processing")
+ convertBtn.disabled = true
+
+ // Allow UI to update
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Get base filename without extension
+ const baseName = currentFile!.name.replace(/\.json$/i, "")
+
+ // Get options from checkboxes
+ const includeComponents = includeComponentsCheckbox.checked
+ const includeExternalMeshes = includeExternalMeshesCheckbox.checked
+
+ // Capture console warnings during conversion
+ const warnings: string[] = []
+ const originalWarn = console.warn
+ console.warn = (...args: any[]) => {
+ const msg = args.map((a) => String(a)).join(" ")
+ if (msg.includes("Failed to merge STEP model")) {
+ // Extract just the filename from the path/URL
+ const pathMatch =
+ msg.match(/from ([^:]+:.*?)(?=:|$)/) || msg.match(/from (\S+)/)
+ if (pathMatch) {
+ const fullPath = pathMatch[1]
+ // Get just the filename
+ const filename = fullPath.split(/[/\\]/).pop() || fullPath
+ warnings.push(`Could not load: ${filename}`)
+ }
+ }
+ originalWarn.apply(console, args)
+ }
+
+ // Convert to STEP
+ const stepContent = await circuitJsonToStep(circuitJson, {
+ includeComponents,
+ includeExternalMeshes: includeComponents && includeExternalMeshes,
+ fsMap: Object.keys(stepFilesMap).length > 0 ? stepFilesMap : undefined,
+ })
+
+ // Restore console.warn
+ console.warn = originalWarn
+
+ // Download the STEP file
+ downloadFile(`${baseName}.step`, stepContent)
+
+ if (warnings.length > 0) {
+ showStatusWithWarnings(warnings)
+ } else {
+ showStatus("Conversion complete! File downloaded.", "success")
+ }
+ convertBtn.disabled = false
+ } catch (error) {
+ showStatus(
+ `Error during conversion: ${error instanceof Error ? error.message : String(error)}`,
+ "error",
+ )
+ convertBtn.disabled = false
+ console.error(error)
+ }
+})
+
+// Clear button
+clearBtn.addEventListener("click", () => {
+ currentFile = null
+ circuitJson = null
+ fileInput.value = ""
+ fileInfo.classList.remove("visible")
+ convertBtn.disabled = true
+ hideStatus()
+ // Reset STEP models
+ requiredStepModels = []
+ stepFilesMap = {}
+ stepModelsSection.style.display = "none"
+ stepModelsList.innerHTML = ""
+ stepFileInput.value = ""
+})
+
+// Helper function to download a file
+function downloadFile(filename: string, content: string) {
+ const blob = new Blob([content], { type: "application/step" })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}
+
+// Helper function to show status
+function showStatus(
+ message: string,
+ type: "success" | "error" | "processing" | "warning",
+) {
+ status.textContent = message
+ status.className = `status visible ${type}`
+}
+
+// Helper function to show status with expandable warnings
+function showStatusWithWarnings(warnings: string[]) {
+ const maxInitial = 3
+ const hasMore = warnings.length > maxInitial
+
+ status.innerHTML = ""
+ status.className = "status visible warning"
+
+ const header = document.createTextNode(
+ "Conversion complete with warnings (file downloaded):\n",
+ )
+ status.appendChild(header)
+
+ const initialWarnings = warnings.slice(0, maxInitial)
+ const remainingWarnings = warnings.slice(maxInitial)
+
+ const warningsText = document.createTextNode(initialWarnings.join("\n"))
+ status.appendChild(warningsText)
+
+ if (hasMore) {
+ const moreContainer = document.createElement("span")
+ moreContainer.id = "more-warnings-container"
+
+ const hiddenWarnings = document.createElement("span")
+ hiddenWarnings.id = "hidden-warnings"
+ hiddenWarnings.style.display = "none"
+ hiddenWarnings.textContent = "\n" + remainingWarnings.join("\n")
+
+ const showMoreLink = document.createElement("span")
+ showMoreLink.className = "show-more"
+ showMoreLink.textContent = `\n...and ${remainingWarnings.length} more`
+ showMoreLink.onclick = () => {
+ hiddenWarnings.style.display = "inline"
+ showMoreLink.style.display = "none"
+ }
+
+ moreContainer.appendChild(showMoreLink)
+ moreContainer.appendChild(hiddenWarnings)
+ status.appendChild(moreContainer)
+ }
+}
+
+// Helper function to hide status
+function hideStatus() {
+ status.classList.remove("visible")
+}
diff --git a/tsconfig.json b/tsconfig.json
index f01ef2e..7474645 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -26,5 +26,5 @@
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
- "exclude": ["circuit-json", "stepts"]
+ "exclude": ["circuit-json", "stepts", "site"]
}