diff --git a/lib/mesh-generation.ts b/lib/mesh-generation.ts index 1df41fe..da966ac 100644 --- a/lib/mesh-generation.ts +++ b/lib/mesh-generation.ts @@ -1,6 +1,6 @@ import type { CircuitJson } from "circuit-json" import type { Ref } from "stepts" -import type { Triangle as GLTFTriangle } from "circuit-json-to-gltf" +import type { Triangle as GLTFTriangle, Box3D } from "circuit-json-to-gltf" import type { Repository } from "stepts" import { AdvancedFace, @@ -37,7 +37,249 @@ export interface MeshGenerationOptions { } /** - * Generates triangles for a box mesh + * Creates a proper B-Rep box solid in STEP format. + * The box is defined by 8 vertices, 12 edges, and 6 rectangular faces, + * forming a valid manifold closed shell. + * + * Coordinates are in STEP convention (Z=up). The box parameter uses + * GLTF convention (Y=up), so we swap Y/Z during conversion. + */ +function createBRepBoxSolid( + repo: Repository, + box: { + center: { x: number; y: number; z: number } + size: { x: number; y: number; z: number } + }, + label?: string, +): Ref { + // Transform from GLTF (Y=up) to STEP (Z=up) + const center = { x: box.center.x, y: box.center.z, z: box.center.y } + const size = { x: box.size.x, y: box.size.z, z: box.size.y } + + const halfX = size.x / 2 + const halfY = size.y / 2 + const halfZ = size.z / 2 + + // 8 corners of the box + // Bottom face (z = center.z - halfZ) + // 0: (-x, -y, -z) 1: (+x, -y, -z) 2: (+x, +y, -z) 3: (-x, +y, -z) + // Top face (z = center.z + halfZ) + // 4: (-x, -y, +z) 5: (+x, -y, +z) 6: (+x, +y, +z) 7: (-x, +y, +z) + const cornerCoords = [ + [center.x - halfX, center.y - halfY, center.z - halfZ], + [center.x + halfX, center.y - halfY, center.z - halfZ], + [center.x + halfX, center.y + halfY, center.z - halfZ], + [center.x - halfX, center.y + halfY, center.z - halfZ], + [center.x - halfX, center.y - halfY, center.z + halfZ], + [center.x + halfX, center.y - halfY, center.z + halfZ], + [center.x + halfX, center.y + halfY, center.z + halfZ], + [center.x - halfX, center.y + halfY, center.z + halfZ], + ] + + const vertices = cornerCoords.map(([x, y, z]) => + repo.add(new VertexPoint("", repo.add(new CartesianPoint("", x!, y!, z!)))), + ) + + // Helper to create an edge between two 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) + const dir = repo.add( + new Direction("", dx / length, dy / length, dz / 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)) + } + + // 12 edges of the box + // Bottom face edges (0-1, 1-2, 2-3, 3-0) + const bottomEdges = [ + createEdge(vertices[0]!, vertices[1]!), + createEdge(vertices[1]!, vertices[2]!), + createEdge(vertices[2]!, vertices[3]!), + createEdge(vertices[3]!, vertices[0]!), + ] + // Top face edges (4-5, 5-6, 6-7, 7-4) + const topEdges = [ + createEdge(vertices[4]!, vertices[5]!), + createEdge(vertices[5]!, vertices[6]!), + createEdge(vertices[6]!, vertices[7]!), + createEdge(vertices[7]!, vertices[4]!), + ] + // Vertical edges (0-4, 1-5, 2-6, 3-7) + const vertEdges = [ + createEdge(vertices[0]!, vertices[4]!), + createEdge(vertices[1]!, vertices[5]!), + createEdge(vertices[2]!, vertices[6]!), + createEdge(vertices[3]!, vertices[7]!), + ] + + // Helper to create a planar face + function createFace( + edges: { edge: Ref; forward: boolean }[], + normalX: number, + normalY: number, + normalZ: number, + originVertex: Ref, + refDirX: number, + refDirY: number, + refDirZ: number, + ): Ref { + const orientedEdges = edges.map((e) => + repo.add(new OrientedEdge("", e.edge, e.forward)), + ) + const loop = repo.add(new EdgeLoop("", orientedEdges)) + const normalDir = repo.add(new Direction("", normalX, normalY, normalZ)) + const refDir = repo.add(new Direction("", refDirX, refDirY, refDirZ)) + const placement = repo.add( + new Axis2Placement3D( + "", + originVertex.resolve(repo).pnt, + normalDir, + refDir, + ), + ) + const plane = repo.add(new Plane("", placement)) + return repo.add( + new AdvancedFace( + "", + [repo.add(new FaceOuterBound("", loop, true))], + plane, + true, + ), + ) + } + + // Bottom face (z = -halfZ, normal pointing down) + // Loop: 0->1->2->3->0 (viewed from below, counterclockwise = clockwise from above) + const bottomFace = createFace( + [ + { edge: bottomEdges[0]!, forward: true }, + { edge: bottomEdges[1]!, forward: true }, + { edge: bottomEdges[2]!, forward: true }, + { edge: bottomEdges[3]!, forward: true }, + ], + 0, + 0, + -1, + vertices[0]!, + 1, + 0, + 0, + ) + + // Top face (z = +halfZ, normal pointing up) + // Loop: 4->7->6->5->4 (viewed from above, counterclockwise) + const topFace = createFace( + [ + { edge: topEdges[3]!, forward: false }, + { edge: topEdges[2]!, forward: false }, + { edge: topEdges[1]!, forward: false }, + { edge: topEdges[0]!, forward: false }, + ], + 0, + 0, + 1, + vertices[4]!, + 1, + 0, + 0, + ) + + // Front face (y = -halfY, normal pointing -Y) + // Loop: 0->4->5->1->0 + const frontFace = createFace( + [ + { edge: vertEdges[0]!, forward: true }, + { edge: topEdges[0]!, forward: true }, + { edge: vertEdges[1]!, forward: false }, + { edge: bottomEdges[0]!, forward: false }, + ], + 0, + -1, + 0, + vertices[0]!, + 1, + 0, + 0, + ) + + // Back face (y = +halfY, normal pointing +Y) + // Loop: 2->6->7->3->2 + const backFace = createFace( + [ + { edge: vertEdges[2]!, forward: true }, + { edge: topEdges[2]!, forward: true }, + { edge: vertEdges[3]!, forward: false }, + { edge: bottomEdges[2]!, forward: false }, + ], + 0, + 1, + 0, + vertices[2]!, + -1, + 0, + 0, + ) + + // Left face (x = -halfX, normal pointing -X) + // Loop: 3->7->4->0->3 + const leftFace = createFace( + [ + { edge: vertEdges[3]!, forward: true }, + { edge: topEdges[3]!, forward: true }, + { edge: vertEdges[0]!, forward: false }, + { edge: bottomEdges[3]!, forward: false }, + ], + -1, + 0, + 0, + vertices[3]!, + 0, + -1, + 0, + ) + + // Right face (x = +halfX, normal pointing +X) + // Loop: 1->5->6->2->1 + const rightFace = createFace( + [ + { edge: vertEdges[1]!, forward: true }, + { edge: topEdges[1]!, forward: true }, + { edge: vertEdges[2]!, forward: false }, + { edge: bottomEdges[1]!, forward: false }, + ], + 1, + 0, + 0, + vertices[1]!, + 0, + 1, + 0, + ) + + const allFaces = [ + bottomFace, + topFace, + frontFace, + backFace, + leftFace, + rightFace, + ] + const shell = repo.add(new ClosedShell("", allFaces)) + return repo.add(new ManifoldSolidBrep(label ?? "Component", shell)) +} + +/** + * Generates triangles for a box mesh (used for boxes with custom meshes) */ function createBoxTriangles(box: { center: { x: number; y: number; z: number } @@ -327,47 +569,45 @@ export async function generateComponentMeshes( renderBoardTextures: false, }) - // Extract or generate triangles from component boxes - const allTriangles: GLTFTriangle[] = [] + // Process each box individually for (const box of scene3d.boxes) { if (box.mesh && "triangles" in box.mesh) { - allTriangles.push(...box.mesh.triangles) + // Box has a custom mesh - use triangle-based approach + const meshTriangles = box.mesh.triangles + if (meshTriangles.length > 0) { + // Transform triangles from GLTF XZ plane (Y=up) to STEP XY plane (Z=up) + const transformedTriangles = meshTriangles.map( + (tri: GLTFTriangle) => ({ + vertices: tri.vertices.map((v) => ({ + x: v.x, + y: v.z, // GLTF Z becomes STEP Y + z: v.y, // GLTF Y becomes STEP Z + })), + normal: { + x: tri.normal.x, + y: tri.normal.z, // GLTF Z becomes STEP Y + z: tri.normal.y, // GLTF Y becomes STEP Z + }, + }), + ) + const componentFaces = createStepFacesFromTriangles( + repo, + transformedTriangles as any, + ) + const componentShell = repo.add( + new ClosedShell("", componentFaces as any), + ) + const componentSolid = repo.add( + new ManifoldSolidBrep(box.label ?? "Component", componentShell), + ) + solids.push(componentSolid) + } } else { - // Generate simple box mesh for this component - const boxTriangles = createBoxTriangles(box) - allTriangles.push(...boxTriangles) + // Simple box - create proper B-Rep solid with 6 rectangular faces + const boxSolid = createBRepBoxSolid(repo, box, box.label) + solids.push(boxSolid) } } - - // 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) => ({ - vertices: tri.vertices.map((v) => ({ - x: v.x, - y: v.z, // GLTF Z becomes STEP Y - z: v.y, // GLTF Y becomes STEP Z - })), - normal: { - x: tri.normal.x, - y: tri.normal.z, // GLTF Z becomes STEP Y - z: tri.normal.y, // GLTF Y becomes STEP Z - }, - })) - const componentFaces = createStepFacesFromTriangles( - repo, - transformedTriangles as any, - ) - - // Create closed shell and solid for components - const componentShell = repo.add( - new ClosedShell("", componentFaces as any), - ) - const componentSolid = repo.add( - new ManifoldSolidBrep("Components", componentShell), - ) - solids.push(componentSolid) - } } catch (error) { console.warn("Failed to generate component mesh:", error) // Continue without components if generation fails diff --git a/test/basics/basics04/__snapshots__/basics04.snap.png b/test/basics/basics04/__snapshots__/basics04.snap.png index b703933..1ec7db2 100644 Binary files a/test/basics/basics04/__snapshots__/basics04.snap.png and b/test/basics/basics04/__snapshots__/basics04.snap.png differ diff --git a/test/basics/basics07/__snapshots__/basics07.snap.png b/test/basics/basics07/__snapshots__/basics07.snap.png new file mode 100644 index 0000000..fd73b7d Binary files /dev/null and b/test/basics/basics07/__snapshots__/basics07.snap.png differ diff --git a/test/basics/basics07/basics07.json b/test/basics/basics07/basics07.json new file mode 100644 index 0000000..95c11af --- /dev/null +++ b/test/basics/basics07/basics07.json @@ -0,0 +1,61 @@ +[ + { + "type": "pcb_board", + "pcb_board_id": "pcb_board_1", + "width": 20, + "height": 15, + "thickness": 1.6, + "center": { "x": 10, "y": 7.5 } + }, + { + "type": "source_component", + "source_component_id": "source_component_1", + "name": "R1", + "supplier_part_numbers": {}, + "ftype": "simple_resistor" + }, + { + "type": "pcb_component", + "pcb_component_id": "pcb_component_1", + "source_component_id": "source_component_1", + "center": { "x": 5, "y": 5 }, + "width": 3, + "height": 1.5, + "layer": "top", + "rotation": 0 + }, + { + "type": "source_component", + "source_component_id": "source_component_2", + "name": "R2", + "supplier_part_numbers": {}, + "ftype": "simple_resistor" + }, + { + "type": "pcb_component", + "pcb_component_id": "pcb_component_2", + "source_component_id": "source_component_2", + "center": { "x": 15, "y": 5 }, + "width": 3, + "height": 1.5, + "layer": "top", + "rotation": 0 + }, + { + "type": "source_component", + "source_component_id": "source_component_3", + "name": "R3", + "supplier_part_numbers": {}, + "ftype": "simple_resistor" + }, + { + "type": "pcb_component", + "pcb_component_id": "pcb_component_3", + "source_component_id": "source_component_3", + "center": { "x": 10, "y": 10 }, + "width": 3, + "height": 1.5, + "layer": "top", + "rotation": 0 + } +] diff --git a/test/basics/basics07/basics07.test.ts b/test/basics/basics07/basics07.test.ts new file mode 100644 index 0000000..91447b6 --- /dev/null +++ b/test/basics/basics07/basics07.test.ts @@ -0,0 +1,79 @@ +import { test, expect } from "bun:test" +import { circuitJsonToStep } from "../../../lib/index" +import { importStepWithOcct } from "../../utils/occt/importer" +import circuitJson from "./basics07.json" + +test("basics07: resistor rectangles appear as proper box solids in STEP output", async () => { + const stepText = await circuitJsonToStep(circuitJson as any, { + includeComponents: true, + productName: "TestPCB_Resistors", + }) + + // Verify STEP format + expect(stepText).toContain("ISO-10303-21") + expect(stepText).toContain("END-ISO-10303-21") + + // Verify we have 4 solids: 1 board + 3 resistor boxes + const solidCount = (stepText.match(/MANIFOLD_SOLID_BREP/g) || []).length + expect(solidCount).toBe(4) + + // Write STEP file to debug-output + const outputPath = "debug-output/basics07.step" + await Bun.write(outputPath, stepText) + + // Validate STEP file can be imported with occt-import-js + const occtResult = await importStepWithOcct(stepText) + expect(occtResult.success).toBe(true) + + // Filter out empty meshes + const nonEmptyMeshes = occtResult.meshes.filter( + (m) => m.attributes.position.array.length > 0, + ) + + // Should have at least 4 non-empty meshes (board + 3 resistors) + expect(nonEmptyMeshes.length).toBeGreaterThanOrEqual(4) + + // Compute bounding boxes for each mesh + const meshBounds = nonEmptyMeshes.map((mesh) => { + const pos = mesh.attributes.position.array + let minX = Infinity + let minY = Infinity + let minZ = Infinity + let maxX = -Infinity + let maxY = -Infinity + let maxZ = -Infinity + for (let i = 0; i < pos.length; i += 3) { + minX = Math.min(minX, pos[i]!) + maxX = Math.max(maxX, pos[i]!) + minY = Math.min(minY, pos[i + 1]!) + maxY = Math.max(maxY, pos[i + 1]!) + minZ = Math.min(minZ, pos[i + 2]!) + maxZ = Math.max(maxZ, pos[i + 2]!) + } + return { + width: maxX - minX, + height: maxY - minY, + depth: maxZ - minZ, + } + }) + + // Find the board mesh (largest by width) + const boardMesh = meshBounds.find( + (b) => Math.abs(b.width - 20) < 1 && Math.abs(b.height - 15) < 1, + ) + expect(boardMesh).toBeDefined() + + // Find resistor meshes (should be roughly 3x1.5x1.5 boxes) + const resistorMeshes = meshBounds.filter( + (b) => Math.abs(b.width - 3) < 0.5 && Math.abs(b.height - 1.5) < 0.5, + ) + // All 3 resistors should appear as proper box meshes + expect(resistorMeshes.length).toBe(3) + + console.log( + ` - Board: ${boardMesh!.width.toFixed(1)}x${boardMesh!.height.toFixed(1)}`, + ) + console.log(` - Resistor boxes found: ${resistorMeshes.length}`) + + await expect(stepText).toMatchStepSnapshot(import.meta.path, "basics07") +}, 30000) diff --git a/test/repros/repro01/__snapshots__/repro01.snap.png b/test/repros/repro01/__snapshots__/repro01.snap.png index 285a4c2..c6a0ebc 100644 Binary files a/test/repros/repro01/__snapshots__/repro01.snap.png and b/test/repros/repro01/__snapshots__/repro01.snap.png differ