import type { CircuitJson } from "circuit-json" import { Repository, ApplicationContext, ApplicationProtocolDefinition, ProductContext, Product, ProductDefinitionContext, ProductDefinitionFormation, ProductDefinition, ProductDefinitionShape, Unknown, CartesianPoint, Direction, Axis2Placement3D, Plane, CylindricalSurface, VertexPoint, EdgeCurve, Line, Vector, EdgeLoop, OrientedEdge, FaceOuterBound, FaceBound, AdvancedFace, Circle, ClosedShell, ManifoldSolidBrep, ColourRgb, FillAreaStyleColour, FillAreaStyle, SurfaceStyleFillArea, SurfaceSideStyle, SurfaceStyleUsage, PresentationStyleAssignment, StyledItem, MechanicalDesignGeometricPresentationRepresentation, AdvancedBrepShapeRepresentation, ShapeRepresentation, ShapeDefinitionRepresentation, type Entity, type Ref, } from "stepts" import { generateComponentMeshes } from "./mesh-generation" import { mergeExternalStepModels } from "./step-model-merger" import { createStyleCache, createStyledItem, createStyledItems, } from "./step-style-utils" import { normalizeStepNumericExponents } from "./step-text-utils" import { VERSION } from "./version" import { createPillHoleGeometry } from "./pill-geometry" type Hole = Extract< CircuitJson[number], { type: "pcb_hole" | "pcb_plated_hole" } > type SharedHoleGeometry = { bottomLoop: Ref topLoop: Ref wallFaces: Ref[] } type RuntimeHole = Hole & { x?: number | { value: number } y?: number | { value: number } hole_shape?: string shape?: string hole_diameter?: number } export interface CircuitJsonToStepOptions { /** Board width in mm (optional if pcb_board is present) */ boardWidth?: number /** Board height in mm (optional if pcb_board is present) */ boardHeight?: number /** Board thickness in mm (default: 1.6mm or from pcb_board) */ boardThickness?: number /** Product name (default: "PCB") */ productName?: string /** Include component meshes (default: false) */ includeComponents?: boolean /** Include external model meshes from model_*_url fields (default: false). Only applicable when includeComponents is true. */ includeExternalMeshes?: boolean /** * Pre-loaded STEP file contents, keyed by URL/path. * If a URL is found here, the content is used directly instead of fetching. * Useful for tests that need to load local files. */ fsMap?: Record } /** * Converts circuit JSON to STEP format, creating holes in a PCB board */ export async function circuitJsonToStep( circuitJson: CircuitJson, options: CircuitJsonToStepOptions = {}, ): Promise { const repo = new Repository() // Extract pcb_board and holes from circuit JSON const pcbBoard = circuitJson.find((item) => item.type === "pcb_board") const holes = circuitJson.filter( (item): item is RuntimeHole => item.type === "pcb_hole" || item.type === "pcb_plated_hole", ) // Get dimensions from pcb_board or options const boardWidth = options.boardWidth ?? pcbBoard?.width const boardHeight = options.boardHeight ?? pcbBoard?.height const boardThickness = options.boardThickness ?? pcbBoard?.thickness ?? 1.6 const productName = options.productName ?? "PCB" // Get board center position (defaults to 0, 0 if not specified) const boardCenterX = pcbBoard?.center?.x ?? 0 const boardCenterY = pcbBoard?.center?.y ?? 0 const halfBoardThickness = boardThickness / 2 if (!boardWidth || !boardHeight) { throw new Error( "Board dimensions not found. Either provide boardWidth and boardHeight in options, or include a pcb_board in the circuit JSON with width and height properties.", ) } // Product structure (required for STEP validation) const appContext = repo.add( new ApplicationContext( "core data for automotive mechanical design processes", ), ) repo.add( new ApplicationProtocolDefinition( "international standard", "automotive_design", 2010, appContext, ), ) const productContext = repo.add( new ProductContext("", appContext, "mechanical"), ) const productDescription = `Generated by circuit-json-to-step v${VERSION}` const product = repo.add( new Product(productName, productName, productDescription, [productContext]), ) const productDefContext = repo.add( new ProductDefinitionContext("part definition", appContext, "design"), ) const productDefFormation = repo.add( new ProductDefinitionFormation("", "", product), ) const productDef = repo.add( new ProductDefinition("", "", productDefFormation, productDefContext), ) const productDefShape = repo.add( new ProductDefinitionShape("", "", productDef), ) // Representation context const lengthUnit = repo.add( new Unknown("", [ "( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) )", ]), ) const angleUnit = repo.add( new Unknown("", [ "( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) )", ]), ) const solidAngleUnit = repo.add( new Unknown("", [ "( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() )", ]), ) const uncertainty = repo.add( new Unknown("UNCERTAINTY_MEASURE_WITH_UNIT", [ `LENGTH_MEASURE(1.E-07)`, `${lengthUnit}`, `'distance_accuracy_value'`, `'Maximum Tolerance'`, ]), ) const geomContext = repo.add( new Unknown("", [ `( GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((${uncertainty})) GLOBAL_UNIT_ASSIGNED_CONTEXT((${lengthUnit},${angleUnit},${solidAngleUnit})) REPRESENTATION_CONTEXT('${productName}','3D') )`, ]), ) // Create board vertices based on outline or rectangular shape const outline = pcbBoard?.outline let bottomVertices: Ref[] let topVertices: Ref[] if (outline && Array.isArray(outline) && outline.length >= 3) { // Use custom outline (points are already relative to board center) // Filter out duplicate consecutive vertices (including first/last wrap-around) const cleanedOutline: { x: number; y: number }[] = [] for (let i = 0; i < outline.length; i++) { const current = outline[i]! const next = outline[(i + 1) % outline.length]! const dx = next.x - current.x const dy = next.y - current.y const dist = Math.sqrt(dx * dx + dy * dy) // Only add vertex if it's not a duplicate of the next one if (dist > 1e-6) { cleanedOutline.push(current) } } if (cleanedOutline.length < 3) { throw new Error( `Outline has too few unique vertices after removing duplicates (${cleanedOutline.length}). Need at least 3.`, ) } bottomVertices = cleanedOutline.map((point) => repo.add( new VertexPoint( "", repo.add( new CartesianPoint("", point.x, point.y, -halfBoardThickness), ), ), ), ) topVertices = cleanedOutline.map((point) => repo.add( new VertexPoint( "", repo.add( new CartesianPoint("", point.x, point.y, halfBoardThickness), ), ), ), ) } else { // Fall back to rectangular shape centered at (boardCenterX, boardCenterY) const halfWidth = boardWidth / 2 const halfHeight = boardHeight / 2 const corners = [ [ boardCenterX - halfWidth, boardCenterY - halfHeight, -halfBoardThickness, ], [ boardCenterX + halfWidth, boardCenterY - halfHeight, -halfBoardThickness, ], [ boardCenterX + halfWidth, boardCenterY + halfHeight, -halfBoardThickness, ], [ boardCenterX - halfWidth, boardCenterY + halfHeight, -halfBoardThickness, ], [boardCenterX - halfWidth, boardCenterY - halfHeight, halfBoardThickness], [boardCenterX + halfWidth, boardCenterY - halfHeight, halfBoardThickness], [boardCenterX + halfWidth, boardCenterY + halfHeight, halfBoardThickness], [boardCenterX - halfWidth, boardCenterY + halfHeight, halfBoardThickness], ] const vertices = corners.map(([x, y, z]) => repo.add( new VertexPoint("", repo.add(new CartesianPoint("", x!, y!, z!))), ), ) bottomVertices = [vertices[0]!, vertices[1]!, vertices[2]!, vertices[3]!] topVertices = [vertices[4]!, vertices[5]!, vertices[6]!, vertices[7]!] } // Helper to create edge between vertices function createEdge( v1: Ref, v2: Ref, ): Ref { const p1 = v1.resolve(repo).pnt.resolve(repo) const p2 = v2.resolve(repo).pnt.resolve(repo) const dx = p2.x - p1.x const dy = p2.y - p1.y const dz = p2.z - p1.z const length = Math.sqrt(dx * dx + dy * dy + dz * dz) // Handle zero-length edges (duplicate vertices) if (length < 1e-10) { // Use arbitrary direction for degenerate edge const dir = repo.add(new Direction("", 1, 0, 0)) const vec = repo.add(new Vector("", dir, 1e-10)) const line = repo.add(new Line("", v1.resolve(repo).pnt, vec)) return repo.add(new EdgeCurve("", v1, v2, line, true)) } // Direction must be normalized (unit vector) const dir = repo.add( new Direction("", dx / length, dy / length, dz / length), ) // Vector magnitude is the actual edge length const vec = repo.add(new Vector("", dir, length)) const line = repo.add(new Line("", v1.resolve(repo).pnt, vec)) return repo.add(new EdgeCurve("", v1, v2, line, true)) } // Create board edges const bottomEdges: Ref[] = [] const topEdges: Ref[] = [] const verticalEdges: Ref[] = [] // Bottom edges (connect vertices in a loop) for (let i = 0; i < bottomVertices.length; i++) { const v1 = bottomVertices[i]! const v2 = bottomVertices[(i + 1) % bottomVertices.length]! bottomEdges.push(createEdge(v1, v2)) } // Top edges (connect vertices in a loop) for (let i = 0; i < topVertices.length; i++) { const v1 = topVertices[i]! const v2 = topVertices[(i + 1) % topVertices.length]! topEdges.push(createEdge(v1, v2)) } // Vertical edges (connect bottom to top) for (let i = 0; i < bottomVertices.length; i++) { verticalEdges.push(createEdge(bottomVertices[i]!, topVertices[i]!)) } const origin = repo.add(new CartesianPoint("", 0, 0, -halfBoardThickness)) const xDir = repo.add(new Direction("", 1, 0, 0)) const zDir = repo.add(new Direction("", 0, 0, 1)) // Bottom face (z=-boardThickness/2, normal pointing down) const bottomFrame = repo.add( new Axis2Placement3D( "", origin, repo.add(new Direction("", 0, 0, -1)), xDir, ), ) const bottomPlane = repo.add(new Plane("", bottomFrame)) const bottomLoop = repo.add( new EdgeLoop( "", bottomEdges.map((edge) => repo.add(new OrientedEdge("", edge, true))), ), ) // Top face (z=boardThickness/2, normal pointing up) const topOrigin = repo.add(new CartesianPoint("", 0, 0, halfBoardThickness)) const topFrame = repo.add(new Axis2Placement3D("", topOrigin, zDir, xDir)) const topPlane = repo.add(new Plane("", topFrame)) const topLoop = repo.add( new EdgeLoop( "", topEdges.map((edge) => repo.add(new OrientedEdge("", edge, false))), ), ) function getHoleCoordinate( coordinate: number | { value: number } | undefined, ): number { if (typeof coordinate === "number") return coordinate return coordinate?.value ?? 0 } function createCircularHoleGeometry(hole: RuntimeHole): SharedHoleGeometry { const holeX = getHoleCoordinate(hole.x) const holeY = getHoleCoordinate(hole.y) const radius = (hole.hole_diameter ?? 0) / 2 const bottomHoleCenter = repo.add( new CartesianPoint("", holeX, holeY, -halfBoardThickness), ) const bottomHoleVertex = repo.add( new VertexPoint( "", repo.add( new CartesianPoint("", holeX + radius, holeY, -halfBoardThickness), ), ), ) const bottomHolePlacement = repo.add( new Axis2Placement3D( "", bottomHoleCenter, repo.add(new Direction("", 0, 0, -1)), xDir, ), ) const bottomHoleCircle = repo.add( new Circle("", bottomHolePlacement, radius), ) const bottomHoleEdge = repo.add( new EdgeCurve( "", bottomHoleVertex, bottomHoleVertex, bottomHoleCircle, true, ), ) const topHoleCenter = repo.add( new CartesianPoint("", holeX, holeY, halfBoardThickness), ) const topHoleVertex = repo.add( new VertexPoint( "", repo.add( new CartesianPoint("", holeX + radius, holeY, halfBoardThickness), ), ), ) const topHolePlacement = repo.add( new Axis2Placement3D("", topHoleCenter, zDir, xDir), ) const topHoleCircle = repo.add(new Circle("", topHolePlacement, radius)) const topHoleEdge = repo.add( new EdgeCurve("", topHoleVertex, topHoleVertex, topHoleCircle, true), ) const bottomLoop = repo.add( new EdgeLoop("", [repo.add(new OrientedEdge("", bottomHoleEdge, false))]), ) const topLoop = repo.add( new EdgeLoop("", [repo.add(new OrientedEdge("", topHoleEdge, true))]), ) const wallLoop = repo.add( new EdgeLoop("", [ repo.add(new OrientedEdge("", bottomHoleEdge, true)), repo.add(new OrientedEdge("", topHoleEdge, false)), ]), ) const holeCylinderPlacement = repo.add( new Axis2Placement3D("", bottomHoleCenter, zDir, xDir), ) const holeCylinderSurface = repo.add( new CylindricalSurface("", holeCylinderPlacement, radius), ) const wallFace = repo.add( new AdvancedFace( "", [repo.add(new FaceOuterBound("", wallLoop, true))], holeCylinderSurface, false, ), ) return { bottomLoop, topLoop, wallFaces: [wallFace] } } const sharedHoleGeometries: SharedHoleGeometry[] = [] for (const hole of holes) { const holeShape = hole.hole_shape ?? hole.shape if (holeShape === "circle") { sharedHoleGeometries.push(createCircularHoleGeometry(hole)) } else if (holeShape === "rotated_pill" || holeShape === "pill") { sharedHoleGeometries.push( createPillHoleGeometry( repo, hole, -halfBoardThickness, halfBoardThickness, zDir, ), ) } } // Create holes in bottom and top faces from the shared boundary loops. const bottomHoleLoops: Ref[] = [] const topHoleLoops: Ref[] = [] for (const holeGeometry of sharedHoleGeometries) { bottomHoleLoops.push( repo.add(new FaceBound("", holeGeometry.bottomLoop, true)), ) topHoleLoops.push(repo.add(new FaceBound("", holeGeometry.topLoop, true))) } const bottomFace = repo.add( new AdvancedFace( "", [repo.add(new FaceOuterBound("", bottomLoop, true)), ...bottomHoleLoops], bottomPlane, true, ), ) const topFace = repo.add( new AdvancedFace( "", [repo.add(new FaceOuterBound("", topLoop, true)), ...topHoleLoops], topPlane, true, ), ) const holeCylindricalFaces: Ref[] = [] for (const holeGeometry of sharedHoleGeometries) { holeCylindricalFaces.push(...holeGeometry.wallFaces) } // Create side faces (one for each edge of the outline) const sideFaces: Ref[] = [] for (let i = 0; i < bottomEdges.length; i++) { const nextI = (i + 1) % bottomEdges.length // Get points for this side face const bottomV1Pnt = bottomVertices[i]!.resolve(repo).pnt const bottomV2Pnt = bottomVertices[nextI]!.resolve(repo).pnt const bottomV1 = bottomV1Pnt.resolve(repo) const bottomV2 = bottomV2Pnt.resolve(repo) // Calculate edge direction and outward normal const edgeDir = { x: bottomV2.x - bottomV1.x, y: bottomV2.y - bottomV1.y, z: -halfBoardThickness, } // Normal is perpendicular (rotate 90 degrees clockwise in XY plane for outward facing) const normalDir = repo.add(new Direction("", edgeDir.y, -edgeDir.x, 0)) // Reference direction along the edge const refDir = repo.add(new Direction("", edgeDir.x, edgeDir.y, 0)) const sideFrame = repo.add( new Axis2Placement3D("", bottomV1Pnt, normalDir, refDir), ) const sidePlane = repo.add(new Plane("", sideFrame)) const sideLoop = repo.add( new EdgeLoop("", [ repo.add(new OrientedEdge("", bottomEdges[i]!, true)), repo.add(new OrientedEdge("", verticalEdges[nextI]!, true)), repo.add(new OrientedEdge("", topEdges[i]!, false)), repo.add(new OrientedEdge("", verticalEdges[i]!, false)), ]), ) const sideFace = repo.add( new AdvancedFace( "", [repo.add(new FaceOuterBound("", sideLoop, true))], sidePlane, true, ), ) sideFaces.push(sideFace) } // Collect all faces const allFaces = [bottomFace, topFace, ...sideFaces, ...holeCylindricalFaces] const styleCache = createStyleCache() const boardStyledItems = createStyledItems(repo, { itemRefs: allFaces, rgb: [0.2, 0.6, 0.2], styleCache, }) // Create closed shell and solid const shell = repo.add(new ClosedShell("", allFaces)) const solid = repo.add(new ManifoldSolidBrep(productName, shell)) // Array to hold all solids (board + optional components) const allSolids: Ref[] = [solid] const componentStyledItems: Ref[] = [] const solidsWithIntrinsicFaceStyles = new Set() let handledComponentIds = new Set() let handledPcbComponentIds = new Set() if (options.includeComponents && options.includeExternalMeshes) { const mergeResult = await mergeExternalStepModels({ repo, circuitJson, boardThickness, fsMap: options.fsMap, }) handledComponentIds = mergeResult.handledComponentIds handledPcbComponentIds = mergeResult.handledPcbComponentIds allSolids.push(...mergeResult.solids) mergeResult.solids.forEach((solidRef) => { solidsWithIntrinsicFaceStyles.add(String(solidRef.id)) }) } // Generate component mesh fallback if requested // Only call mesh generation if there are components that need it if (options.includeComponents) { // Build set of pcb_component_ids covered by cad_components with model_step_url const pcbComponentIdsWithStepUrl = new Set() for (const item of circuitJson) { if ( item.type === "cad_component" && item.model_step_url && item.pcb_component_id ) { pcbComponentIdsWithStepUrl.add(item.pcb_component_id) } } // Check if mesh generation is needed: // 1. cad_component without model_step_url (not already handled, not covered by another cad_component with STEP) // 2. pcb_component without corresponding cad_component with model_step_url const hasComponentsNeedingMesh = circuitJson.some((item) => { if (item.type === "cad_component") { // Skip if already handled by STEP merging if ( item.cad_component_id && handledComponentIds.has(item.cad_component_id) ) { return false } // Skip if this cad_component's pcb is covered by another cad_component with STEP URL if ( item.pcb_component_id && pcbComponentIdsWithStepUrl.has(item.pcb_component_id) ) { return false } // Needs mesh if no model_step_url return !item.model_step_url } if (item.type === "pcb_component") { // Skip if already handled if ( item.pcb_component_id && handledPcbComponentIds.has(item.pcb_component_id) ) { return false } // Skip if covered by a cad_component with model_step_url if ( item.pcb_component_id && pcbComponentIdsWithStepUrl.has(item.pcb_component_id) ) { return false } // This pcb_component needs mesh generation return true } return false }) if (hasComponentsNeedingMesh) { const componentSolids = await generateComponentMeshes({ repo, circuitJson, boardThickness, includeExternalMeshes: options.includeExternalMeshes, excludeCadComponentIds: handledComponentIds, excludePcbComponentIds: handledPcbComponentIds, pcbComponentIdsWithStepUrl, }) for (const componentSolid of componentSolids) { allSolids.push(componentSolid.solid) componentStyledItems.push(...componentSolid.styledItems) if (componentSolid.usesIntrinsicFaceStyles) { solidsWithIntrinsicFaceStyles.add(String(componentSolid.solid.id)) } else if (componentSolid.styleTargets.length > 0) { componentStyledItems.push( ...createStyledItems(repo, { itemRefs: componentSolid.styleTargets, rgb: [0.75, 0.75, 0.75], styleCache, }), ) solidsWithIntrinsicFaceStyles.add(String(componentSolid.solid.id)) } } } } // Add presentation/styling for all solids const styledItems: Ref[] = [ ...boardStyledItems, ...componentStyledItems, ] allSolids.forEach((itemRef, index) => { const isBoard = index === 0 if (isBoard || solidsWithIntrinsicFaceStyles.has(String(itemRef.id))) { return } const styledItem = createStyledItem(repo, { itemRef, rgb: [0.75, 0.75, 0.75], styleCache, name: "", }) styledItems.push(styledItem) }) repo.add( new MechanicalDesignGeometricPresentationRepresentation( "", styledItems, geomContext, ), ) const hasMappedItems = allSolids.some( (itemRef) => itemRef.resolve(repo).type === "MAPPED_ITEM", ) const shapeRep = repo.add( hasMappedItems ? new ShapeRepresentation(productName, allSolids, geomContext) : new AdvancedBrepShapeRepresentation( productName, allSolids, geomContext, ), ) repo.add(new ShapeDefinitionRepresentation(productDefShape, shapeRep)) // Generate and return STEP file text const stepText = repo.toPartFile({ name: productName }) return normalizeStepNumericExponents(stepText) } export { getCircuitJsonToGltfModule } from "./get-circuit-json-to-gltf-module"