From 93341b569cb1435477f9fa7a11b7fd841d45f8b1 Mon Sep 17 00:00:00 2001 From: Omair Afzal Date: Sun, 15 Feb 2026 02:43:49 +0500 Subject: [PATCH 1/3] fix: create separate STEP solids per component to fix missing rectangles Previously all component triangles were merged into a single ClosedShell, which violates STEP's requirement that each ManifoldSolidBrep has a closed, watertight boundary. Viewers silently drop the invalid merged solid. Now each component box gets its own ClosedShell and ManifoldSolidBrep, so resistor/capacitor blocks render correctly in STEP viewers. Also defaults includeComponents to true since most users expect component geometry in their exports. Fixes #6 --- lib/index.ts | 7 ++++--- lib/mesh-generation.ts | 35 +++++++++++++++++++---------------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index b1e627f..eabfb2b 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -55,7 +55,7 @@ export interface CircuitJsonToStepOptions { boardThickness?: number /** Product name (default: "PCB") */ productName?: string - /** Include component meshes (default: false) */ + /** Include component meshes (default: true) */ includeComponents?: boolean /** Include external model meshes from model_*_url fields (default: false). Only applicable when includeComponents is true. */ includeExternalMeshes?: boolean @@ -86,6 +86,7 @@ export async function circuitJsonToStep( const boardWidth = options.boardWidth ?? pcbBoard?.width const boardHeight = options.boardHeight ?? pcbBoard?.height const boardThickness = options.boardThickness ?? pcbBoard?.thickness ?? 1.6 + const includeComponents = options.includeComponents ?? true const productName = options.productName ?? "PCB" // Get board center position (defaults to 0, 0 if not specified) @@ -568,7 +569,7 @@ export async function circuitJsonToStep( let handledComponentIds = new Set() let handledPcbComponentIds = new Set() - if (options.includeComponents && options.includeExternalMeshes) { + if (includeComponents && options.includeExternalMeshes) { const mergeResult = await mergeExternalStepModels({ repo, circuitJson, @@ -582,7 +583,7 @@ export async function circuitJsonToStep( // Generate component mesh fallback if requested // Only call mesh generation if there are components that need it - if (options.includeComponents) { + if (includeComponents) { // Build set of pcb_component_ids covered by cad_components with model_step_url const pcbComponentIdsWithStepUrl = new Set() for (const item of circuitJson) { diff --git a/lib/mesh-generation.ts b/lib/mesh-generation.ts index 1df41fe..b44e338 100644 --- a/lib/mesh-generation.ts +++ b/lib/mesh-generation.ts @@ -327,22 +327,24 @@ export async function generateComponentMeshes( renderBoardTextures: false, }) - // Extract or generate triangles from component boxes - const allTriangles: GLTFTriangle[] = [] - for (const box of scene3d.boxes) { + // Process each component box as a separate STEP solid. + // STEP requires each ManifoldSolidBrep to have a closed, watertight + // boundary — merging triangles from multiple disconnected boxes into + // one ClosedShell produces an invalid solid that viewers silently drop. + for (let i = 0; i < scene3d.boxes.length; i++) { + const box = scene3d.boxes[i] + + let boxTriangles: GLTFTriangle[] if (box.mesh && "triangles" in box.mesh) { - allTriangles.push(...box.mesh.triangles) + boxTriangles = box.mesh.triangles } else { - // Generate simple box mesh for this component - const boxTriangles = createBoxTriangles(box) - allTriangles.push(...boxTriangles) + boxTriangles = createBoxTriangles(box) } - } - // Create STEP faces from triangles if we have any - if (allTriangles.length > 0) { - // Transform triangles from GLTF XZ plane (Y=up) to STEP XY plane (Z=up) - const transformedTriangles = allTriangles.map((tri) => ({ + if (boxTriangles.length === 0) continue + + // Transform from GLTF coordinate system (Y-up) to STEP (Z-up) + const transformedTriangles = boxTriangles.map((tri) => ({ vertices: tri.vertices.map((v) => ({ x: v.x, y: v.z, // GLTF Z becomes STEP Y @@ -350,21 +352,22 @@ export async function generateComponentMeshes( })), normal: { x: tri.normal.x, - y: tri.normal.z, // GLTF Z becomes STEP Y - z: tri.normal.y, // GLTF Y becomes STEP Z + y: tri.normal.z, + z: tri.normal.y, }, })) + const componentFaces = createStepFacesFromTriangles( repo, transformedTriangles as any, ) - // Create closed shell and solid for components const componentShell = repo.add( new ClosedShell("", componentFaces as any), ) + const componentName = (box as any).name || `Component_${i + 1}` const componentSolid = repo.add( - new ManifoldSolidBrep("Components", componentShell), + new ManifoldSolidBrep(componentName, componentShell), ) solids.push(componentSolid) } From de3e44b98f75cd617e01eb7bbda75b6c71cd2d6f Mon Sep 17 00:00:00 2001 From: Omair Afzal Date: Sun, 15 Feb 2026 23:32:17 +0500 Subject: [PATCH 2/3] add snapshot test for separate ManifoldSolidBrep per component --- .../__snapshots__/separate-solids.snap.png | Bin 0 -> 3454 bytes .../separate-solids/separate-solids.test.ts | 50 ++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 test/basics/separate-solids/__snapshots__/separate-solids.snap.png create mode 100644 test/basics/separate-solids/separate-solids.test.ts diff --git a/test/basics/separate-solids/__snapshots__/separate-solids.snap.png b/test/basics/separate-solids/__snapshots__/separate-solids.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..b70393395bad15c735c5adec48782b856869789f GIT binary patch literal 3454 zcmeHJYfw{H5Z)vNs6Zp5LW4?L`Qaa;)|5hr5PSi}8G%w#8F`8dYMp|J&D93DzG@jH z6sOamR;jg$q@$ra@{kZOKU%HG*j6zr(A+6Li^i)K6HNxY_vWSr#F_S2|B#uGIeYfo zZ+Fjk_E3?Mln^i>WC8#LB)+>S6+rYlfH2+H8!dZ3-dcn|!o)@M(+I)6kg{LVE0SH^ z@90=R?WZYXYUz~waXEBK&X3iFUG|0ghb!92>Bp{qR+AT3lUel2WN*bDDMlFojCbIF z>Hs~z3Y0B2)#FNC|2ZSEX_Hg{LsxvMZ38xi=1!E(rPJ&6f#ArChtNz--KNyD>uB1!~@wjM+8i~zH^lq9XY~>`SVGsgqrXY@R$+*m64j7IZGc%THhL; z%P^$!RH;?ecz*26sYyo-fk0pCdZC(O5k}YTs_MKsSIE4YkD#DE0)X_DIFxd0aG;QJ zBaFl{r`ad>@QWK`dXH(OXU@`h`clz651p9S?F&r92%0g0#A25F)M#!$NFIpNX)g|L z4gl?TIVg_}4DLmN9K&ynWgr5W=q6w;AGdsHzKwo=`h854^wuDUjO9>%DTks2}M^ zrQYDc4O68}CCZ_&nU3%bAR;+C1{*CedMbC%L?eYeUjdLBg@}@nSm*QF}rg<)E$QG0cs8 zNdLwVudV`WQjd^II_%yPEq zra&-%#jaLc?XLY?$y@TlQEXVt9;Nn8Eb9cH7PTJ<7anz^L9^NqBpCK1_E38(q!7AH zU1bPCT{nu!dJnm(BM1&8p)gA9Qg(Qd^&hAY;qc7eB8Shv<T^GWcqddm+3trLyPxUid?Kkk5S!)HPB*l%HjT=6E!PNUbLa5{;(|7T8 znfZrJ5&r#yPs(nwQr9+O8WrwhnK#%G9&=R)8tgV#qoe@u^+7&M<#s8wY~aLC&Mx!= zZ6KeDqnN!)Y3|;WvzO!6dVZ@VLjlY@cVjhc{6P6NAFOu=64QK_nKk)9SEO;`QQ;(F zg~zEXJr5zRJVaY72dbQx6`h4M!m8#fCL%L{tn*YHiz9_N$zxo*r9jIX4DYGKz;q2q zE0%R(I?kEc4Xv5+5QN2=_IZ4(G5${etmoweDOt*fS9Rb`Z3#w-ReZY2J?L1^J2GDQ zlGfql$Tp1HhSSitm=gst4kx#v7kn8iMP$#aCD`~v@v2u55ii^DfM;g0Qb&huv$yu9O|@eaJS1N3*! Y$iu=N<|oU`C$dh7aY>7gFUZ~c7fC^Sn*aa+ literal 0 HcmV?d00001 diff --git a/test/basics/separate-solids/separate-solids.test.ts b/test/basics/separate-solids/separate-solids.test.ts new file mode 100644 index 0000000..57eed81 --- /dev/null +++ b/test/basics/separate-solids/separate-solids.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "bun:test" +import { circuitJsonToStep } from "../../../lib/index" +import { importStepWithOcct } from "../../utils/occt/importer" +import circuitJson from "../basics04/basics04.json" + +test("separate-solids: each component gets its own ManifoldSolidBrep", async () => { + const stepText = await circuitJsonToStep(circuitJson as any, { + includeComponents: true, + productName: "SeparateSolidsTest", + }) + + // Verify STEP format + expect(stepText).toContain("ISO-10303-21") + expect(stepText).toContain("END-ISO-10303-21") + + // Count ManifoldSolidBrep entries — should be at least 3: + // 1 for the board + 1 per component (2 components in basics04) + const solidMatches = stepText.match(/MANIFOLD_SOLID_BREP\s*\(\s*'([^']*)'/g) || [] + const solidNames = solidMatches.map((m) => { + const nameMatch = m.match(/MANIFOLD_SOLID_BREP\s*\(\s*'([^']*)'/) + return nameMatch?.[1] ?? "" + }) + + console.log(` Solids found (${solidNames.length}):`) + for (const name of solidNames) { + console.log(` - ${name}`) + } + + // Board solid + at least 2 separate component solids + expect(solidNames.length).toBeGreaterThanOrEqual(3) + + // Each component should have its own named solid (not a single merged "Components") + expect(solidNames.some((n) => n === "Components")).toBe(false) + + // Validate STEP file with occt-import-js + const occtResult = await importStepWithOcct(stepText) + expect(occtResult.success).toBe(true) + // Should have multiple meshes (one per solid) + expect(occtResult.meshes.length).toBeGreaterThanOrEqual(3) + + console.log(` occt meshes: ${occtResult.meshes.length}`) + + // Write debug output + await Bun.write("debug-output/separate-solids.step", stepText) + + await expect(stepText).toMatchStepSnapshot( + import.meta.path, + "separate-solids", + ) +}, 30000) From 488adfc40ada04d1a496d84f480daab106b3615a Mon Sep 17 00:00:00 2001 From: Omair Afzal Date: Mon, 16 Feb 2026 05:57:30 +0500 Subject: [PATCH 3/3] fix: run biome format to satisfy format-check CI --- test/basics/separate-solids/separate-solids.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/basics/separate-solids/separate-solids.test.ts b/test/basics/separate-solids/separate-solids.test.ts index 57eed81..d966d4e 100644 --- a/test/basics/separate-solids/separate-solids.test.ts +++ b/test/basics/separate-solids/separate-solids.test.ts @@ -15,7 +15,8 @@ test("separate-solids: each component gets its own ManifoldSolidBrep", async () // Count ManifoldSolidBrep entries — should be at least 3: // 1 for the board + 1 per component (2 components in basics04) - const solidMatches = stepText.match(/MANIFOLD_SOLID_BREP\s*\(\s*'([^']*)'/g) || [] + const solidMatches = + stepText.match(/MANIFOLD_SOLID_BREP\s*\(\s*'([^']*)'/g) || [] const solidNames = solidMatches.map((m) => { const nameMatch = m.match(/MANIFOLD_SOLID_BREP\s*\(\s*'([^']*)'/) return nameMatch?.[1] ?? ""