Skip to content

Load 3d #162

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
120 changes: 51 additions & 69 deletions src/convert-circuit-json-to-3d-svg.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AnyCircuitElement } from "circuit-json"
import { su } from "@tscircuit/soup-util"
import type { AnyCircuitElement } from "circuit-json"
import Debug from "debug"
import * as THREE from "three"
import { SVGRenderer } from "three/examples/jsm/renderers/SVGRenderer.js"
Expand All @@ -10,79 +10,55 @@ import { renderComponent } from "./utils/render-component"
interface CircuitToSvgOptions {
width?: number
height?: number
viewAngle?: "top" | "isometric" | "front" | "side"
backgroundColor?: string
padding?: number
zoom?: number
camera?: {
position: {
x: number
y: number
z: number
}
lookAt?: {
x: number
y: number
z: number
}
}
}

const log = Debug("tscircuit:3d-viewer:convert-circuit-json-to-3d-svg")
const RENDER_ORDER = {
BOTTOM_COPPER: -30,
BOARD: -20,
PLATED_HOLE: -10,
TOP_COPPER: 0,
}

export async function convertCircuitJsonTo3dSvg(
circuitJson: AnyCircuitElement[],
options: CircuitToSvgOptions = {},
): Promise<string> {
const {
width = 800,
height = 600,
height = 800,
backgroundColor = "#ffffff",
padding = 20,
zoom = 1.5,
} = options

// Initialize scene and renderer
// Initialize scene and renderer with high precision but normal size
const scene = new THREE.Scene()
scene.up.set(0, 0, 1)
const renderer = new SVGRenderer()
renderer.setSize(width, height)
renderer.setClearColor(new THREE.Color(backgroundColor), 1)
renderer.setQuality("hight")
renderer.setPrecision(1)

// Create camera with explicit type and parameters
const camera = new THREE.OrthographicCamera()

// Set camera properties
// Create perspective camera with optimal settings
const fov = 45
const aspect = width / height
const frustumSize = 100
const halfFrustumSize = frustumSize / 2 / zoom

// Set camera properties
camera.left = -halfFrustumSize * aspect
camera.right = halfFrustumSize * aspect
camera.top = halfFrustumSize
camera.bottom = -halfFrustumSize
camera.near = -1000
camera.far = 1000
const near = 0.1
const far = 50
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far)

// Set camera position
const position = options.camera?.position ?? { x: 0, y: 0, z: 100 }
camera.position.set(position.x, position.y, position.z)
// Position camera
camera.position.set(-5, -5, 5)
camera.up.set(0, 0, 1)
camera.lookAt(new THREE.Vector3(0, 0, 0))

// Set camera up vector
camera.up.set(0, 1, 0)

// Set camera look at
const lookAt = options.camera?.lookAt ?? { x: 0, y: 0, z: 0 }
camera.lookAt(new THREE.Vector3(lookAt.x, lookAt.y, lookAt.z))

// Important: Update the camera matrix
camera.updateProjectionMatrix()

// Add lighting
// Match CadViewerContainer lighting exactly
const ambientLight = new THREE.AmbientLight(0xffffff, Math.PI / 2)
scene.add(ambientLight)
const pointLight = new THREE.PointLight(0xffffff, Math.PI / 4)
pointLight.position.set(-10, -10, 10)

const pointLight = new THREE.PointLight(0xffffff, Math.PI / 4, 0)
pointLight.position.set(10, 10, 10)
scene.add(pointLight)

// Add components
Expand All @@ -92,9 +68,9 @@ export async function convertCircuitJsonTo3dSvg(
}

// Add board geometry after components
const boardGeom = createBoardGeomFromSoup(circuitJson)
if (boardGeom) {
for (const geom of boardGeom) {
const boardGeoms = createBoardGeomFromSoup(circuitJson)
if (boardGeoms.length > 0) {
for (const geom of boardGeoms) {
const geometry = createGeometryFromPolygons(geom.polygons)
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(
Expand All @@ -105,32 +81,38 @@ export async function convertCircuitJsonTo3dSvg(
metalness: 0.1,
roughness: 0.8,
opacity: 0.9,
transparent: true,
side: THREE.DoubleSide,
side: THREE.FrontSide,
})

const mesh = new THREE.Mesh(geometry, material)

switch (geom.type) {
case "board":
mesh.renderOrder = RENDER_ORDER.BOARD
break
case "via":
case "plated_hole":
mesh.renderOrder = RENDER_ORDER.PLATED_HOLE
break
case "pad":
mesh.renderOrder = RENDER_ORDER.TOP_COPPER
break
}
scene.add(mesh)
}
}

// Add grid
const gridHelper = new THREE.GridHelper(100, 100)
gridHelper.rotation.x = Math.PI / 2
scene.add(gridHelper)
// First get the board dimensions
const board = circuitJson.find((c) => c.type === "pcb_board")

// Center and scale scene
const box = new THREE.Box3().setFromObject(scene)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const boardWidth = board?.width || 10
const boardHeight = board?.height || 10

scene.position.sub(center)
// Scale scene to fit view
const maxDim = Math.max(boardWidth, boardHeight)
const scale = (1.0 - padding / 100) / maxDim
scene.scale.multiplyScalar(scale * 5)

const maxDim = Math.max(size.x, size.y, size.z)
if (maxDim > 0) {
const scale = (1.0 - padding / 100) / maxDim
scene.scale.multiplyScalar(scale * 100)
}
// Before rendering, ensure camera is updated
camera.updateProjectionMatrix()

// Render and return SVG with additional checks
Expand Down
28 changes: 19 additions & 9 deletions src/soup-to-3d/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,24 @@ import { M, colors } from "../geoms/constants"
import { extrudeLinear } from "@jscad/modeling/src/operations/extrusions"
import { expand } from "@jscad/modeling/src/operations/expansions"
import { createBoardWithOutline } from "src/geoms/create-board-with-outline"
import { Vec2 } from "@jscad/modeling/src/maths/types"
import type { Vec2 } from "@jscad/modeling/src/maths/types"
import { createSilkscreenTextGeoms } from "src/geoms/create-geoms-for-silkscreen-text"
import { PcbSilkscreenText } from "circuit-json"
import type { PcbSilkscreenText } from "circuit-json"

interface BoardGeom extends Geom3 {
type: "board" | "via" | "plated_hole" | "trace" | "pad" | "silkscreen"
layer?: "top" | "bottom"
}

export const createBoardGeomFromSoup = (
/**
* @deprecated Use circuitJson instead.
*/
soup: AnyCircuitElement[],
circuitJson?: AnyCircuitElement[],
): Geom3[] => {
): BoardGeom[] => {
circuitJson ??= soup

if (!circuitJson) {
throw new Error("circuitJson is required but was not provided")
}
Expand Down Expand Up @@ -282,10 +289,13 @@ export const createBoardGeomFromSoup = (
boardGeom = colorize(colors.fr4Green, boardGeom)

return [
boardGeom,
...platedHoleGeoms,
...padGeoms,
...traceGeoms,
...silkscreenGeoms,
]
{
...boardGeom,
type: "board",
},
...platedHoleGeoms.map((g) => ({ ...g, type: "plated_hole" })),
...padGeoms.map((g) => ({ ...g, type: "pad" })),
...traceGeoms.map((g) => ({ ...g, type: "trace" })),
...silkscreenGeoms.map((g) => ({ ...g, type: "silkscreen" })),
] as BoardGeom[]
}
96 changes: 87 additions & 9 deletions src/utils/load-model.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,102 @@
import * as THREE from "three"
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js"
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"

export async function load3DModel(url: string): Promise<THREE.Object3D | null> {
if (url.endsWith(".stl")) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch model: ${response.statusText}`)
}

// Try to get filename from Content-Disposition header
const disposition = response.headers.get("content-disposition")
let filename = ""
if (disposition?.includes("filename=")) {
const filenameMatch = disposition.match(/filename=([^;]+)/)
filename = filenameMatch ? filenameMatch[1] : ""
}

const arrayBuffer = await response.arrayBuffer()

// Check file extension from filename or url
const isSTL =
filename.toLowerCase().endsWith(".stl") ||
url.toLowerCase().endsWith(".stl")

if (isSTL) {
const loader = new STLLoader()
const geometry = await loader.loadAsync(url)
const geometry = loader.parse(arrayBuffer)

const material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.5,
roughness: 0.5,
side: THREE.DoubleSide,
})

return new THREE.Mesh(geometry, material)
}
} else {
// Handle OBJ file
const text = new TextDecoder().decode(arrayBuffer)

if (url.endsWith(".obj")) {
const loader = new OBJLoader()
return await loader.loadAsync(url)
}
// Extract material definitions and obj content
const mtlContent = text.match(/newmtl[\s\S]*?endmtl/g)?.join("\n")
const objContent = text.replace(/newmtl[\s\S]*?endmtl/g, "")

console.error("Unsupported file format or failed to load 3D model.")
return null
if (mtlContent) {
// Parse materials using MTLLoader
const mtlLoader = new MTLLoader()
mtlLoader.setMaterialOptions({
invertTrProperty: true,
})

// Process material content - convert colors to grayscale as in the reference
const processedMtlContent = mtlContent
.replace(/d 0\./g, "d 1.")
.replace(/Kd\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/g, "Kd $2 $2 $2")

const materials = mtlLoader.parse(processedMtlContent, "")

// Parse OBJ with materials
const loader = new OBJLoader()
loader.setMaterials(materials)
const object = loader.parse(objContent)

// Process geometries
object.traverse((child) => {
if (child instanceof THREE.Mesh) {
if (!child.geometry.attributes.normal) {
child.geometry.computeVertexNormals()
}
child.geometry.center()
}
})

return object
} else {
// If no materials found, use default material
const loader = new OBJLoader()
const object = loader.parse(text)

const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.5,
roughness: 0.5,
side: THREE.DoubleSide,
})

object.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = defaultMaterial
if (!child.geometry.attributes.normal) {
child.geometry.computeVertexNormals()
}
child.geometry.center()
}
})

return object
}
}
}
Loading
Loading