import type { CircuitJson } from "circuit-json" import { Repository, ApplicationContext, ApplicationProtocolDefinition, ProductContext, Product, ProductDefinitionContext, ProductDefinitionFormation, ProductDefinition, ProductDefinitionShape, Unknown, CartesianPoint, Direction, Axis2Placement3D, Plane, VertexPoint, EdgeCurve, EdgeLoop, OrientedEdge, FaceOuterBound, FaceBound, AdvancedFace, 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 { PCB_SUBSTRATE_COLOR_RGB, createStyleCache, createStyledItem, createStyledItems, } from "./step-style-utils" import { VERSION } from "./version" import { createLineEdge, createPillHoleGeometry } from "./pill-geometry" import { createCircularHoleGeometry } from "./circular-hole-geometry" import { createRectangularHoleGeometry } from "./rectangular-hole-geometry" import { circuitJsonToAssemblyStep } from "./assembly" import { selectBoardGeometry } from "./select-board-geometry" import { applyCutoutsToBoardOutline, createHoleGeometryFromSegments, getCutoutBoundarySegments, } from "./cutout-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 // Drill offset from the pad centre. Present only on the four pad variants of // pcb_plated_hole; absent on the plain circle and oval variants. hole_offset_x?: number hole_offset_y?: number // Drill rotation. Only rotated_pill_hole_with_rect_pad carries this name; the // oval and polygon pad variants use ccw_rotation instead. hole_ccw_rotation?: number } export interface CircuitJsonToStepOptions { /** Export one STEP file. Defaults to the complete assembly, including all PCBs and parts. Explicit "pcb" retains PCB-only behavior. */ exportMode?: "pcb" | "assembly" /** 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 /** Root product name (default: "Combined Assembly", or "PCB" in explicit PCB mode). */ productName?: string /** Include component meshes in PCB mode (default: false). Assembly mode includes all parts. */ includeComponents?: boolean /** Include external model meshes from model_*_url fields (default: false in PCB mode, true in assembly mode). */ 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 one STEP file containing a PCB or a named assembly. */ export async function circuitJsonToStep( circuitJson: CircuitJson, options: CircuitJsonToStepOptions = {}, ): Promise { const boards = circuitJson.filter((item) => item.type === "pcb_board") if (options.exportMode !== "pcb") { const pcbSteps: { id: string; name: string; stepText: string }[] = [] // A panel is one substrate, including the boards, rails and connecting tabs. // Its holes and cutouts cannot be scoped to individual board outlines. const panel = circuitJson.find((item) => item.type === "pcb_panel") for (const board of panel ? [] : boards) { const name = boards.length === 1 ? "PCB" : board.pcb_board_id pcbSteps.push({ id: board.pcb_board_id, name, stepText: await circuitJsonToStep( boards.length === 1 ? circuitJson : selectBoardGeometry(circuitJson, board), { ...options, exportMode: "pcb", productName: name, includeComponents: false, }, ), }) } if ( !panel && boards.length === 0 && options.boardWidth && options.boardHeight ) { pcbSteps.push({ id: "pcb", name: "PCB", stepText: await circuitJsonToStep(circuitJson, { ...options, exportMode: "pcb", productName: "PCB", includeComponents: false, }), }) } return circuitJsonToAssemblyStep(circuitJson, { productName: options.productName, pcbSteps, fsMap: options.fsMap, boardThickness: options.boardThickness ?? panel?.thickness ?? boards[0]?.thickness ?? 1.6, includeExternalMeshes: options.includeExternalMeshes ?? true, }) } 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", ) const cutouts = circuitJson.filter((item) => item.type === "pcb_cutout") // 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 boardOutline: { x: number; y: number }[] if (outline && Array.isArray(outline) && outline.length >= 3) { // Circuit JSON outline points are already in world coordinates. // 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.`, ) } boardOutline = cleanedOutline } else { // Fall back to rectangular shape centered at (boardCenterX, boardCenterY) const halfWidth = boardWidth / 2 const halfHeight = boardHeight / 2 boardOutline = [ { x: boardCenterX - halfWidth, y: boardCenterY - halfHeight }, { x: boardCenterX + halfWidth, y: boardCenterY - halfHeight }, { x: boardCenterX + halfWidth, y: boardCenterY + halfHeight }, { x: boardCenterX - halfWidth, y: boardCenterY + halfHeight }, ] } const { outline: cutBoardOutline, internalCutouts } = applyCutoutsToBoardOutline({ outline: boardOutline, cutouts }) const createOutlineVertices = (z: number) => cutBoardOutline.map((point) => repo.add( new VertexPoint( "", repo.add(new CartesianPoint("", point.x, point.y, z)), ), ), ) const bottomVertices = createOutlineVertices(-halfBoardThickness) const topVertices = createOutlineVertices(halfBoardThickness) // 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(createLineEdge({ repo, 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(createLineEdge({ repo, v1, v2 })) } // Vertical edges (connect bottom to top) for (let i = 0; i < bottomVertices.length; i++) { verticalEdges.push( createLineEdge({ repo, v1: bottomVertices[i]!, v2: 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, true))), ), ) function getHoleCoordinate( coordinate: number | { value: number } | undefined, ): number { if (typeof coordinate === "number") return coordinate return coordinate?.value ?? 0 } const sharedHoleGeometries: SharedHoleGeometry[] = [] for (const hole of holes) { const holeShape = hole.hole_shape ?? hole.shape const isRoundPillDrill = (holeShape === "pill" || holeShape === "rotated_pill" || (holeShape === "oval" && hole.type === "pcb_plated_hole")) && "hole_width" in hole && "hole_height" in hole && hole.hole_width === hole.hole_height if (holeShape === "circle" || isRoundPillDrill) { const circularGeometry = createCircularHoleGeometry({ repo, hole: isRoundPillDrill ? { ...hole, hole_diameter: hole.hole_width } : hole, zMin: -halfBoardThickness, zMax: halfBoardThickness, zDir, }) if (circularGeometry) { sharedHoleGeometries.push(circularGeometry) } } else if (hole.type === "pcb_hole" && hole.hole_shape === "rect") { sharedHoleGeometries.push( createRectangularHoleGeometry({ repo, hole, zMin: -halfBoardThickness, zMax: halfBoardThickness, }), ) } else if (holeShape === "rotated_pill" || holeShape === "pill") { sharedHoleGeometries.push( createPillHoleGeometry({ repo, hole, zMin: -halfBoardThickness, zMax: halfBoardThickness, zDir, }), ) } else if (holeShape === "oval" && hole.type === "pcb_plated_hole") { // pcb_plated_hole with shape "oval" has no hole_shape, so it reaches here // on `shape` alone. Gated to plated holes: the non-plated oval variant // falls through today and routing it would change pcb_hole output. sharedHoleGeometries.push( createPillHoleGeometry({ repo, hole, zMin: -halfBoardThickness, zMax: halfBoardThickness, zDir, }), ) } } // pcb_cutouts remove board material just like holes do for (const cutout of internalCutouts) { if (cutout.shape === "circle") { const circularGeometry = createCircularHoleGeometry({ repo, hole: { x: cutout.center.x, y: cutout.center.y, hole_diameter: cutout.radius * 2, }, zMin: -halfBoardThickness, zMax: halfBoardThickness, zDir, }) if (circularGeometry) { sharedHoleGeometries.push(circularGeometry) } continue } const segments = getCutoutBoundarySegments(cutout) if (segments?.length) { sharedHoleGeometries.push( createHoleGeometryFromSegments({ repo, segments, zMin: -halfBoardThickness, zMax: halfBoardThickness, }), ) } } // 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: PCB_SUBSTRATE_COLOR_RGB, 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 return repo.toPartFile({ name: productName }) } export { getCircuitJsonToGltfModule } from "./get-circuit-json-to-gltf-module"