diff --git a/Cargo.lock b/Cargo.lock index 59f534b7..f75c50c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6987,6 +6987,7 @@ dependencies = [ name = "vcad-gdsii" version = "0.9.4" dependencies = [ + "geo", "thiserror 2.0.18", "vcad-ir", ] diff --git a/crates/vcad-gdsii/Cargo.toml b/crates/vcad-gdsii/Cargo.toml index 572391bd..c27f16e3 100644 --- a/crates/vcad-gdsii/Cargo.toml +++ b/crates/vcad-gdsii/Cargo.toml @@ -10,8 +10,11 @@ publish = false [features] default = ["vcad-ir"] ## Enables the GDS-layer → vcad-ir document bridge (`bridge` module). -vcad-ir = ["dep:vcad-ir"] +vcad-ir = ["dep:vcad-ir", "dep:geo"] [dependencies] thiserror = { workspace = true } vcad-ir = { workspace = true, optional = true } +# 2D polygon booleans for merging each layer's polygons before extrusion +# (pure-Rust BooleanOps — same crate vcad-kernel-cam uses; WASM-safe). +geo = { version = "0.28", optional = true } diff --git a/crates/vcad-gdsii/examples/flat_import.rs b/crates/vcad-gdsii/examples/flat_import.rs new file mode 100644 index 00000000..726146fb --- /dev/null +++ b/crates/vcad-gdsii/examples/flat_import.rs @@ -0,0 +1,126 @@ +//! THROWAWAY: like sky130_import but emits one scene root per polygon +//! (no unions at all) — trades document tidiness for render speed. +//! Usage: flat_import [top] + +use vcad_gdsii::{flatten, read_library}; +use vcad_ir::{ + CsgOp, Document, MaterialDef, Node, NodeId, SceneEntry, SketchSegment2D, Vec2, Vec3, +}; + +fn main() { + let mut args = std::env::args().skip(1); + let gds_path = args.next().expect("arg 1: in.gds"); + let vcad_path = args.next().expect("arg 2: out.vcad"); + let top = args.next().expect("arg 3: top cell"); + + // Optional µm-space crop window: WINDOW="x0,y0,x1,y1" (µm) + let window: Option<[f64; 4]> = std::env::var("WINDOW").ok().map(|w| { + let v: Vec = w.split(',').map(|s| s.parse().expect("WINDOW")).collect(); + [v[0], v[1], v[2], v[3]] + }); + + let bytes = std::fs::read(&gds_path).expect("read gds"); + let lib = read_library(&bytes).expect("parse gds"); + let flat = flatten(&lib, &top).expect("flatten"); + let db_to_mm = lib.db_unit_in_meters * 1e6; // 1 µm = 1 mm view scale + + // (gds layer, z_bottom_mm, thickness_mm, name, rgb) + let stack: [(i16, f64, f64, &str, [f64; 3]); 5] = [ + (65, 0.00, 0.12, "diff", [0.85, 0.35, 0.25]), + (66, 0.30, 0.18, "poly", [0.30, 0.65, 0.35]), + (67, 0.94, 0.10, "li1", [0.60, 0.35, 0.75]), + (68, 1.38, 0.36, "met1", [0.30, 0.45, 0.85]), + (69, 2.00, 0.36, "met2", [0.80, 0.70, 0.25]), + ]; + + let mut doc = Document::new(); + let mut next_id: NodeId = 1; + for &(layer, z, t, name, color) in &stack { + let Some(lp) = flat.iter().find(|lp| lp.layer == layer) else { + continue; + }; + let key = format!("gds_{name}"); + doc.materials.insert( + key.clone(), + MaterialDef { + name: key.clone(), + color, + metallic: 0.3, + roughness: 0.6, + ..Default::default() + }, + ); + for polygon in &lp.polygons { + let mut polygon = polygon.clone(); + if let Some([x0, y0, x1, y1]) = window { + let inside = polygon.iter().any(|p| { + let (x, y) = (p[0] * db_to_mm, p[1] * db_to_mm); + x >= x0 && x <= x1 && y >= y0 && y <= y1 + }); + if !inside { + continue; + } + // Clamp crossing polygons to the window (cleaved-die edge); + // drop anything that degenerates to zero area. + for p in &mut polygon { + p[0] = p[0].clamp(x0 / db_to_mm, x1 / db_to_mm); + p[1] = p[1].clamp(y0 / db_to_mm, y1 / db_to_mm); + } + let area2: f64 = polygon + .iter() + .zip(polygon.iter().cycle().skip(1)) + .map(|(a, b)| a[0] * b[1] - b[0] * a[1]) + .sum(); + if area2.abs() < 1e-6 { + continue; + } + } + let polygon = &polygon; + let segments: Vec = polygon + .iter() + .zip(polygon.iter().cycle().skip(1)) + .map(|(a, b)| SketchSegment2D::Line { + start: Vec2::new(a[0] * db_to_mm, a[1] * db_to_mm), + end: Vec2::new(b[0] * db_to_mm, b[1] * db_to_mm), + }) + .collect(); + let sketch = next_id; + doc.nodes.insert( + sketch, + Node { + id: sketch, + name: None, + op: CsgOp::Sketch2D { + origin: Vec3::new(0.0, 0.0, z), + x_dir: Vec3::new(1.0, 0.0, 0.0), + y_dir: Vec3::new(0.0, 1.0, 0.0), + segments, + }, + }, + ); + let extrude = sketch + 1; + next_id += 2; + doc.nodes.insert( + extrude, + Node { + id: extrude, + name: None, + op: CsgOp::Extrude { + sketch, + direction: Vec3::new(0.0, 0.0, t), + twist_angle: None, + scale_end: None, + }, + }, + ); + doc.roots.push(SceneEntry { + root: extrude, + material: key.clone(), + visible: None, + }); + } + } + println!("roots: {}", doc.roots.len()); + std::fs::write(&vcad_path, doc.to_json().expect("json")).expect("write"); + println!("wrote {vcad_path}"); +} diff --git a/crates/vcad-gdsii/examples/sky130_import.rs b/crates/vcad-gdsii/examples/sky130_import.rs new file mode 100644 index 00000000..c756a309 --- /dev/null +++ b/crates/vcad-gdsii/examples/sky130_import.rs @@ -0,0 +1,98 @@ +//! THROWAWAY: import an OpenLane/LibreLane sky130 GDS, flatten the top +//! cell, print per-layer polygon counts, and emit a .vcad document. +//! +//! ```sh +//! cargo run -p vcad-gdsii --example sky130_import -- input.gds output.vcad [TOP] +//! ``` + +use vcad_gdsii::{flatten, read_library, to_vcad_document, DEFAULT_VIEW_SCALE}; + +fn main() { + let mut args = std::env::args().skip(1); + let gds_path = args + .next() + .expect("usage: sky130_import [top-cell]"); + let vcad_path = args + .next() + .expect("usage: sky130_import [top-cell]"); + let top_arg = args.next(); + + let bytes = std::fs::read(&gds_path).expect("read gds file"); + println!("read {} bytes from {gds_path}", bytes.len()); + + let lib = read_library(&bytes).expect("parse gds"); + println!( + "library `{}`: {} cells, db_unit = {} m", + lib.name, + lib.cells.len(), + lib.db_unit_in_meters + ); + + // Top cell: explicit arg, else the cell no other cell references. + let top = top_arg.unwrap_or_else(|| { + let referenced: std::collections::HashSet<&str> = lib + .cells + .iter() + .flat_map(|c| c.elements.iter()) + .filter_map(|e| match e { + vcad_gdsii::Element::Sref { sname, .. } => Some(sname.as_str()), + vcad_gdsii::Element::Aref { sname, .. } => Some(sname.as_str()), + _ => None, + }) + .collect(); + let tops: Vec<&str> = lib + .cells + .iter() + .map(|c| c.name.as_str()) + .filter(|n| !referenced.contains(n)) + .collect(); + println!("unreferenced (top) cells: {tops:?}"); + tops.first().expect("no top cell found").to_string() + }); + println!("flattening top cell `{top}`"); + + let flat = match flatten(&lib, &top) { + Ok(f) => f, + Err(e) => { + eprintln!("FLATTEN ERROR: {e}"); + eprintln!("debug: {e:?}"); + std::process::exit(1); + } + }; + println!("flattened layers (gds layer -> polygons / vertices):"); + let mut total = 0usize; + for lp in &flat { + let verts: usize = lp.polygons.iter().map(|p| p.len()).sum(); + total += lp.polygons.len(); + println!( + " layer {:>3}: {:>6} polygons, {:>7} vertices", + lp.layer, + lp.polygons.len(), + verts + ); + } + println!("total polygons: {total}"); + + // sky130-ish film stack: (gds layer, z_bottom_um, thickness_um, name). + // Z heights approximate the sky130A metal stack. + let stack = [ + (65, 0.00, 0.12, "diff"), + (66, 0.30, 0.18, "poly"), + (67, 0.94, 0.10, "li1"), + (68, 1.38, 0.36, "met1"), + (69, 2.00, 0.36, "met2"), + (70, 2.79, 0.85, "met3"), + (71, 4.02, 0.85, "met4"), + (72, 5.37, 1.26, "met5"), + ]; + let doc = match to_vcad_document(&lib, &top, &stack, DEFAULT_VIEW_SCALE) { + Ok(d) => d, + Err(e) => { + eprintln!("BRIDGE ERROR: {e}"); + eprintln!("debug: {e:?}"); + std::process::exit(1); + } + }; + std::fs::write(&vcad_path, doc.to_json().expect("json")).expect("write vcad"); + println!("wrote {vcad_path}"); +} diff --git a/crates/vcad-gdsii/src/bridge.rs b/crates/vcad-gdsii/src/bridge.rs index 0e914305..ff6eb0bc 100644 --- a/crates/vcad-gdsii/src/bridge.rs +++ b/crates/vcad-gdsii/src/bridge.rs @@ -10,6 +10,7 @@ //! view scale: `1 µm in the layout` becomes `view_scale / 1000` mm in the //! document. With [`DEFAULT_VIEW_SCALE`] (1000), 1 µm renders as 1 mm. +use geo::{BooleanOps, Coord, LineString, MultiPolygon, Polygon}; use vcad_ir::{ CsgOp, Document, MaterialDef, Node, NodeId, SceneEntry, SketchSegment2D, Vec2, Vec3, }; @@ -68,12 +69,6 @@ pub fn to_vcad_document( let mut doc = Document::new(); let mut next_id: NodeId = 1; - let mut alloc = |doc: &mut Document, name: Option, op: CsgOp| -> NodeId { - let id = next_id; - next_id += 1; - doc.nodes.insert(id, Node { id, name, op }); - id - }; for (stack_index, &(layer, z_bottom_um, thickness_um, name)) in layer_stack.iter().enumerate() { let Some(layer_polys) = flat.iter().find(|lp| lp.layer == layer) else { @@ -85,51 +80,53 @@ pub fn to_vcad_document( let z_mm = z_bottom_um * um_to_mm; let thickness_mm = thickness_um * um_to_mm; - let mut layer_root: Option = None; - - for polygon in &layer_polys.polygons { - let segments: Vec = polygon - .iter() - .zip(polygon.iter().cycle().skip(1)) - .map(|(a, b)| SketchSegment2D::Line { - start: Vec2::new(a[0] * db_to_mm, a[1] * db_to_mm), - end: Vec2::new(b[0] * db_to_mm, b[1] * db_to_mm), - }) - .collect(); - let sketch = alloc( - &mut doc, - None, - CsgOp::Sketch2D { - origin: Vec3::new(0.0, 0.0, z_mm), - x_dir: Vec3::new(1.0, 0.0, 0.0), - y_dir: Vec3::new(0.0, 1.0, 0.0), - segments, - }, - ); - let extrude = alloc( + + // Merge the layer's raw polygons in 2D before any extrusion. Raw GDS + // layers arrive as tens of thousands of abutting/overlapping rects; + // extruding them individually forces the 3D boolean pipeline to sew + // touching prisms one by one. After a 2D union each connected island + // is a single profile, islands are pairwise disjoint by construction + // (the cheap non-overlapping union path), and only interior holes + // ever reach a real 3D boolean (as a Difference per island). + let merged = union_polygons(&layer_polys.polygons); + + let mut islands: Vec = Vec::with_capacity(merged.0.len()); + for island in &merged.0 { + let outer = ring_solid( &mut doc, - None, - CsgOp::Extrude { - sketch, - direction: Vec3::new(0.0, 0.0, thickness_mm), - twist_angle: None, - scale_end: None, - }, + &mut next_id, + island.exterior(), + db_to_mm, + z_mm, + thickness_mm, ); - layer_root = Some(match layer_root { - None => extrude, - Some(left) => alloc( + let node = if island.interiors().is_empty() { + outer + } else { + let holes: Vec = island + .interiors() + .iter() + .map(|ring| { + ring_solid(&mut doc, &mut next_id, ring, db_to_mm, z_mm, thickness_mm) + }) + .collect(); + let holes_root = balanced_union(&mut doc, &mut next_id, holes); + alloc( &mut doc, - None, - CsgOp::Union { - left, - right: extrude, + &mut next_id, + CsgOp::Difference { + left: outer, + right: holes_root, }, - ), - }); + ) + }; + islands.push(node); + } + if islands.is_empty() { + continue; // every polygon degenerated away in the 2D union } - let root = layer_root.expect("layer with polygons always yields a root"); + let root = balanced_union(&mut doc, &mut next_id, islands); // Name the root node after the layer so it reads well in the tree. if let Some(node) = doc.nodes.get_mut(&root) { node.name = Some(name.to_string()); @@ -157,6 +154,100 @@ pub fn to_vcad_document( Ok(doc) } +/// Insert a node and return its id. +fn alloc(doc: &mut Document, next_id: &mut NodeId, op: CsgOp) -> NodeId { + let id = *next_id; + *next_id += 1; + doc.nodes.insert(id, Node { id, name: None, op }); + id +} + +/// Union all `polygons` (DB-unit vertex lists, implicit closing edge) into a +/// multipolygon of disjoint islands via a balanced pairwise 2D boolean fold. +fn union_polygons(polygons: &[Vec<[f64; 2]>]) -> MultiPolygon { + let mut level: Vec> = polygons + .iter() + .filter(|p| p.len() >= 3) + .map(|p| { + let coords: Vec> = p.iter().map(|v| Coord { x: v[0], y: v[1] }).collect(); + MultiPolygon::new(vec![Polygon::new(LineString::new(coords), vec![])]) + }) + .collect(); + while level.len() > 1 { + level = level + .chunks(2) + .map(|pair| { + if pair.len() == 2 { + pair[0].union(&pair[1]) + } else { + pair[0].clone() + } + }) + .collect(); + } + level.pop().unwrap_or_else(|| MultiPolygon::new(Vec::new())) +} + +/// Extrude one ring (already closed; geo duplicates the first point last) +/// into a sketch + extrude pair, returning the extrude node. +fn ring_solid( + doc: &mut Document, + next_id: &mut NodeId, + ring: &LineString, + db_to_mm: f64, + z_mm: f64, + thickness_mm: f64, +) -> NodeId { + let pts = &ring.0[..ring.0.len().saturating_sub(1)]; + let segments: Vec = pts + .iter() + .zip(pts.iter().cycle().skip(1)) + .map(|(a, b)| SketchSegment2D::Line { + start: Vec2::new(a.x * db_to_mm, a.y * db_to_mm), + end: Vec2::new(b.x * db_to_mm, b.y * db_to_mm), + }) + .collect(); + let sketch = alloc( + doc, + next_id, + CsgOp::Sketch2D { + origin: Vec3::new(0.0, 0.0, z_mm), + x_dir: Vec3::new(1.0, 0.0, 0.0), + y_dir: Vec3::new(0.0, 1.0, 0.0), + segments, + }, + ); + alloc( + doc, + next_id, + CsgOp::Extrude { + sketch, + direction: Vec3::new(0.0, 0.0, thickness_mm), + twist_angle: None, + scale_end: None, + }, + ) +} + +/// Union `nodes` into a balanced tree (depth ~log₂ n), not a left chain — +/// deep chains overflow the stack of recursive document consumers. +/// `nodes` must be non-empty. +fn balanced_union(doc: &mut Document, next_id: &mut NodeId, nodes: Vec) -> NodeId { + let mut level = nodes; + while level.len() > 1 { + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + let mut iter = level.into_iter(); + while let Some(left) = iter.next() { + match iter.next() { + Some(right) => next.push(alloc(doc, next_id, CsgOp::Union { left, right })), + None => next.push(left), + } + } + level = next; + } + level.pop().expect("balanced_union requires nodes") +} + #[cfg(test)] mod tests { use super::*; @@ -321,4 +412,100 @@ mod tests { let parsed = Document::from_json(&json).unwrap(); assert_eq!(doc, parsed); } + + #[test] + fn union_tree_is_balanced_not_a_chain() { + // 1000 instances on one layer: a left-fold chain would nest unions + // 999 deep and overflow recursive consumers on real dies; a balanced + // tree stays at ~log2(n) depth. + let mut unit = Cell::new("unit"); + unit.elements.push(Element::Boundary { + layer: 1, + datatype: 0, + xy: vec![(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)], + }); + let mut top = Cell::new("top"); + top.elements.push(Element::Aref { + sname: "unit".into(), + strans: Strans::default(), + cols: 100, + rows: 10, + xy: [(0, 0), (20_000, 0), (0, 2_000)], + }); + let mut lib = Library::new("depth_test"); + lib.cells = vec![unit, top]; + + let doc = + to_vcad_document(&lib, "top", &[(1, 0.0, 0.2, "l1")], DEFAULT_VIEW_SCALE).unwrap(); + + fn depth(doc: &Document, id: NodeId) -> usize { + match &doc.nodes[&id].op { + CsgOp::Union { left, right } => 1 + depth(doc, *left).max(depth(doc, *right)), + _ => 0, + } + } + let d = depth(&doc, doc.roots[0].root); + // ceil(log2(1000)) == 10; allow a little slack, forbid chains. + assert!(d <= 12, "union depth {d} — expected a balanced tree"); + } + + #[test] + fn overlapping_polygons_merge_to_one_island() { + // Two overlapping squares on one layer merge in 2D: one sketch, one + // extrude, no unions at all. + let mut cell = Cell::new("top"); + cell.elements.push(Element::Boundary { + layer: 1, + datatype: 0, + xy: vec![(0, 0), (1000, 0), (1000, 1000), (0, 1000), (0, 0)], + }); + cell.elements.push(Element::Boundary { + layer: 1, + datatype: 0, + xy: vec![(500, 0), (1500, 0), (1500, 1000), (500, 1000), (500, 0)], + }); + let mut lib = Library::new("merge_test"); + lib.cells = vec![cell]; + + let doc = + to_vcad_document(&lib, "top", &[(1, 0.0, 0.2, "l1")], DEFAULT_VIEW_SCALE).unwrap(); + let count = |pred: fn(&CsgOp) -> bool| doc.nodes.values().filter(|n| pred(&n.op)).count(); + assert_eq!(count(|op| matches!(op, CsgOp::Extrude { .. })), 1); + assert_eq!(count(|op| matches!(op, CsgOp::Union { .. })), 0); + } + + #[test] + fn abutting_ring_produces_hole_via_difference() { + // Four rects forming a closed picture frame: the 2D union yields one + // island with one interior ring -> outer extrude minus hole extrude. + let mut cell = Cell::new("top"); + let rects: [[(i32, i32); 5]; 4] = [ + [(0, 0), (3000, 0), (3000, 1000), (0, 1000), (0, 0)], // bottom + [(0, 2000), (3000, 2000), (3000, 3000), (0, 3000), (0, 2000)], // top + [(0, 0), (1000, 0), (1000, 3000), (0, 3000), (0, 0)], // left + [(2000, 0), (3000, 0), (3000, 3000), (2000, 3000), (2000, 0)], // right + ]; + for r in rects { + cell.elements.push(Element::Boundary { + layer: 1, + datatype: 0, + xy: r.to_vec(), + }); + } + let mut lib = Library::new("ring_test"); + lib.cells = vec![cell]; + + let doc = + to_vcad_document(&lib, "top", &[(1, 0.0, 0.2, "l1")], DEFAULT_VIEW_SCALE).unwrap(); + let count = |pred: fn(&CsgOp) -> bool| doc.nodes.values().filter(|n| pred(&n.op)).count(); + // outer + hole + assert_eq!(count(|op| matches!(op, CsgOp::Extrude { .. })), 2); + assert_eq!(count(|op| matches!(op, CsgOp::Difference { .. })), 1); + assert_eq!(count(|op| matches!(op, CsgOp::Union { .. })), 0); + // The scene root is the Difference (single island). + assert!(matches!( + doc.nodes[&doc.roots[0].root].op, + CsgOp::Difference { .. } + )); + } }