Skip to content
Open
18 changes: 17 additions & 1 deletion lib/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ import { convertSceneToGLTF } from "./converters/scene-to-gltf"
const DEFAULT_BOARD_THICKNESS = 1.6
const DEFAULT_COMPONENT_HEIGHT = 2

function getComponentYPosition(
layer: string | undefined,
boardThickness: number,
componentHeight: number,
): number {
const isBottomLayer = layer === "bottom"

if (isBottomLayer) {
// Bottom layer components are placed below the board
return -(boardThickness / 2 + componentHeight / 2)
} else {
// Top layer components (default) are placed above the board
return boardThickness / 2 + componentHeight / 2
}
}

export async function convertCircuitJsonTo3D(
circuitJson: CircuitJson,
options: any = {},
Expand Down Expand Up @@ -78,7 +94,7 @@ export async function convertCircuitJsonTo3D(
boxes.push({
center: {
x: component.center.x,
y: boardThickness / 2 + compHeight / 2,
y: getComponentYPosition(component.layer, boardThickness, compHeight),
z: component.center.y,
},
size: {
Expand Down
59 changes: 52 additions & 7 deletions lib/converters/circuit-to-3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ function convertRotationFromCadRotation(rot: {
}
}

function getComponentYPosition(
layer: string | undefined,
boardThickness: number,
componentHeight: number,
): number {
const isBottomLayer = layer === "bottom"

if (isBottomLayer) {
// Bottom layer components are placed below the board with extra clearance for visibility
return -(boardThickness / 2 + componentHeight / 2 + 0.7)
} else {
// Top layer components (default) are placed above the board
return boardThickness / 2 + componentHeight / 2
}
}

export async function convertCircuitJsonTo3D(
circuitJson: CircuitJson,
options: CircuitTo3DOptions = {},
Expand Down Expand Up @@ -109,6 +125,7 @@ export async function convertCircuitJsonTo3D(

// Get the associated PCB component
const pcbComponent = db.pcb_component.get(cad.pcb_component_id)
const isBottomLayer = pcbComponent?.layer === "bottom"

// Determine size
const size = cad.size ?? {
Expand All @@ -122,7 +139,11 @@ export async function convertCircuitJsonTo3D(
? { x: cad.position.x, y: cad.position.z, z: cad.position.y }
: {
x: pcbComponent?.center.x ?? 0,
y: boardThickness / 2 + size.y / 2,
y: getComponentYPosition(
pcbComponent?.layer ?? "top",
boardThickness,
size.y,
),
z: pcbComponent?.center.y ?? 0,
}

Expand All @@ -141,21 +162,32 @@ export async function convertCircuitJsonTo3D(
meshType: meshType as any,
}

// Add rotation if specified
// Add rotation if specified, with special handling for bottom layer
if (cad.rotation) {
// For GLB/GLTF models, we need to remap rotation axes because the coordinate
// system has Y and Z swapped. Circuit JSON uses Z-up, but the transformed
// model uses Y-up.
if (model_glb_url || model_gltf_url) {
// Remap rotation: circuit Z -> model Y, circuit Y -> model Z
box.rotation = convertRotationFromCadRotation({
x: cad.rotation.x,
x: isBottomLayer ? cad.rotation.x + 180 : cad.rotation.x, // Flip bottom components
y: cad.rotation.z, // Circuit Z rotation becomes model Y rotation
z: cad.rotation.y, // Circuit Y rotation becomes model Z rotation
})
} else {
box.rotation = convertRotationFromCadRotation(cad.rotation)
box.rotation = convertRotationFromCadRotation({
x: isBottomLayer ? cad.rotation.x + 180 : cad.rotation.x, // Flip bottom components
y: cad.rotation.y,
z: cad.rotation.z,
})
}
} else if (isBottomLayer) {
// If no rotation specified but component is on bottom, flip it around X-axis
box.rotation = convertRotationFromCadRotation({
x: 180, // Flip upside down for bottom layer
y: 0,
z: 0,
})
}

// Try to load the mesh with default coordinate transform if none specified
Expand Down Expand Up @@ -196,10 +228,12 @@ export async function convertCircuitJsonTo3D(
defaultComponentHeight,
)

boxes.push({
const isBottomLayer = component.layer === "bottom"

const genericBox: Box3D = {
center: {
x: component.center.x,
y: boardThickness / 2 + compHeight / 2,
y: getComponentYPosition(component.layer, boardThickness, compHeight),
z: component.center.y,
},
size: {
Expand All @@ -210,7 +244,18 @@ export async function convertCircuitJsonTo3D(
color: componentColor,
label: sourceComponent?.name ?? "?",
labelColor: "white",
})
}

// Add rotation for bottom layer components to make them visually distinct
if (isBottomLayer) {
genericBox.rotation = convertRotationFromCadRotation({
x: 180, // Flip upside down for bottom layer
y: 0,
z: 0,
})
}

boxes.push(genericBox)
}

// Create a default camera positioned to view the board
Expand Down
16 changes: 10 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,25 @@
"react-cosmos": "^7.0.0",
"react-cosmos-plugin-vite": "^7.0.0",
"react-dom": "^19.1.1",
"tscircuit": "^0.0.701",
"tsup": "^8.5.0",
"vite": "^7.1.1",
"tscircuit": "^0.0.701"
"vite": "^7.1.1"
},
"peerDependencies": {
"typescript": "^5",
"@resvg/resvg-wasm": "2",
"@resvg/resvg-js": "2",
"circuit-to-svg": "*",
"@resvg/resvg-wasm": "2",
"@tscircuit/circuit-json-util": "*",
"circuit-json": "*"
"circuit-json": "*",
"circuit-to-svg": "*",
"typescript": "^5"
},
"peerDependenciesMeta": {
"@resvg/resvg-wasm": {
"optional": true
}
},
"dependencies": {
"@tscircuit/soup-util": "^0.0.41",
"graphics-debug": "^0.0.65"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
117 changes: 117 additions & 0 deletions tests/snapshot/bottom-layer-components-angled-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { test, expect } from "bun:test"
import { renderGLTFToPNGBufferFromGLBBuffer } from "poppygl"
import { convertCircuitJsonToGltf } from "../../lib/index"
import type { CircuitJson } from "circuit-json"

test("bottom-layer-components-angled-view-snapshot", async () => {
// Circuit demonstrating proper bottom layer component placement
const circuitJson = [
{
type: "pcb_board",
pcb_board_id: "board1",
center: { x: 0, y: 0 },
width: 60,
height: 40,
thickness: 1.6,
num_layers: 2,
material: "fr4" as const,
},
{
type: "pcb_component",
pcb_component_id: "comp_top1",
source_component_id: "src_top1",
center: { x: -20, y: -10 },
width: 12,
height: 8,
layer: "top" as const,
rotation: 0,
obstructs_within_bounds: true,
},
{
type: "pcb_component",
pcb_component_id: "comp_top2",
source_component_id: "src_top2",
center: { x: -20, y: 10 },
width: 10,
height: 6,
layer: "top" as const,
rotation: 0,
obstructs_within_bounds: true,
},
{
type: "pcb_component",
pcb_component_id: "comp_bottom1",
source_component_id: "src_bottom1",
center: { x: 20, y: -10 },
width: 14,
height: 10,
layer: "bottom" as const,
rotation: 0,
obstructs_within_bounds: true,
},
{
type: "pcb_component",
pcb_component_id: "comp_bottom2",
source_component_id: "src_bottom2",
center: { x: 20, y: 10 },
width: 16,
height: 12,
layer: "bottom" as const,
rotation: 0,
obstructs_within_bounds: true,
},
{
type: "source_component",
source_component_id: "src_top1",
name: "R1",
ftype: "simple_resistor" as const,
resistance: 10000,
display_value: "R1",
},
{
type: "source_component",
source_component_id: "src_top2",
name: "C1",
ftype: "simple_capacitor" as const,
capacitance: 100e-9,
display_value: "C1",
},
{
type: "source_component",
source_component_id: "src_bottom1",
name: "U1",
ftype: "simple_chip" as const,
display_value: "U1",
},
{
type: "source_component",
source_component_id: "src_bottom2",
name: "U2",
ftype: "simple_chip" as const,
display_value: "U2",
},
] as CircuitJson

// Convert circuit to GLTF (GLB format for rendering)
const glbResult = await convertCircuitJsonToGltf(circuitJson, {
format: "glb",
boardTextureResolution: 512,
includeModels: true,
})

expect(glbResult).toBeInstanceOf(ArrayBuffer)

// Optimal camera position to clearly show both top and bottom layer components
const cameraOptions = {
position: { x: 50, y: 35, z: 40 },
target: { x: 0, y: 0, z: 0 },
up: { x: 0, y: 1, z: 0 },
fov: 40,
width: 800,
height: 600,
}

expect(
renderGLTFToPNGBufferFromGLBBuffer(glbResult as ArrayBuffer, cameraOptions),
).toMatchPngSnapshot(import.meta.path)
})
Loading