Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/vcad-app/src/document_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ mod tests {
// both near-identical shells render and z-fight ("the pork-chop
// sawtooth").
let sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D {
holes: None,
origin: vcad_ir::Vec3::new(0.0, 0.0, 0.0),
x_dir: vcad_ir::Vec3::new(1.0, 0.0, 0.0),
y_dir: vcad_ir::Vec3::new(0.0, 1.0, 0.0),
Expand Down
4 changes: 4 additions & 0 deletions crates/vcad-app/src/materializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,7 @@ fn default_sketch() -> CsgOp {
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
segments: Vec::new(),
holes: None,
}
}

Expand Down Expand Up @@ -1722,6 +1723,7 @@ mod tests {

// Sketch JSON the AI would pass (a closed kidney-ish profile).
let sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D {
holes: None,
origin: Vec3::new(0.0, 0.0, 0.0),
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
Expand Down Expand Up @@ -1885,6 +1887,7 @@ mod tests {
// sketch validator ever sees the degenerate profile.
let mut crdt = CrdtDocument::new(ReplicaId(1));
let empty_sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D {
holes: None,
origin: Vec3::new(0.0, 0.0, 0.0),
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
Expand Down Expand Up @@ -1938,6 +1941,7 @@ mod tests {
fn test_materialize_revolve() {
let mut crdt = CrdtDocument::new(ReplicaId(1));
let sketch_json = serde_json::to_string(&vcad_ir::CsgOp::Sketch2D {
holes: None,
origin: Vec3::new(0.0, 0.0, 0.0),
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
Expand Down
1 change: 1 addition & 0 deletions crates/vcad-app/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,7 @@ mod tests {
id: sketch_id,
name: None,
op: CsgOp::Sketch2D {
holes: None,
origin: Vec3::new(0.0, 0.0, 0.0),
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
Expand Down
1 change: 1 addition & 0 deletions crates/vcad-cli/src/tui/sketch_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ impl SketchModeState {
x_dir: Vec3::new(x[0], x[1], x[2]),
y_dir: Vec3::new(y[0], y[1], y[2]),
segments,
holes: None,
})
}
}
Expand Down
8 changes: 8 additions & 0 deletions crates/vcad-eval/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,11 @@ pub fn ir_sketch_to_profile(
let segments: Vec<SketchSegment> = segments.iter().map(convert_segment).collect();
SketchProfile::new(to_point3(origin), to_vec3(x_dir), to_vec3(y_dir), segments)
}

/// Convert IR hole loops into kernel segment loops.
pub fn ir_holes_to_segments(holes: &[Vec<SketchSegment2D>]) -> Vec<Vec<SketchSegment>> {
holes
.iter()
.map(|hole| hole.iter().map(convert_segment).collect())
.collect()
}
70 changes: 53 additions & 17 deletions crates/vcad-eval/src/evaluate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,14 +612,24 @@ fn evaluate_op_timed(
}

// Handle Sketch2D
let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?;
let (s_origin, s_x_dir, s_y_dir, segments, holes) = extract_sketch(&sketch_node.op)?;
let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments)
.map_err(EvalError::Sketch)?;

let has_twist = twist_angle.is_some_and(|t| t.abs() > 1e-12);
let has_scale = scale_end.is_some_and(|s| (s - 1.0).abs() > 1e-12);

let solid = if has_twist || has_scale {
let solid = if !holes.is_empty() {
if has_twist || has_scale {
return Err(EvalError::Sketch(
vcad_kernel_sketch::SketchError::HolesUnsupported(
"extrude with twist or taper",
),
));
}
let hole_loops = crate::convert::ir_holes_to_segments(holes);
Solid::extrude_with_holes(profile, &hole_loops, dir).map_err(EvalError::Sketch)?
} else if has_twist || has_scale {
Solid::extrude_with_options(
profile,
dir,
Expand All @@ -642,7 +652,8 @@ fn evaluate_op_timed(
} => {
let sketch_node = nodes.get(sketch).ok_or(EvalError::MissingNode(*sketch))?;

let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?;
let (s_origin, s_x_dir, s_y_dir, segments, holes) = extract_sketch(&sketch_node.op)?;
reject_holes(holes, "revolve")?;
let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments)
.map_err(EvalError::Sketch)?;

Expand All @@ -669,7 +680,8 @@ fn evaluate_op_timed(
} => {
let sketch_node = nodes.get(sketch).ok_or(EvalError::MissingNode(*sketch))?;

let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?;
let (s_origin, s_x_dir, s_y_dir, segments, holes) = extract_sketch(&sketch_node.op)?;
reject_holes(holes, "sweep")?;
let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments)
.map_err(EvalError::Sketch)?;

Expand Down Expand Up @@ -707,7 +719,9 @@ fn evaluate_op_timed(
let sketch_node = nodes
.get(sketch_id)
.ok_or(EvalError::MissingNode(*sketch_id))?;
let (s_origin, s_x_dir, s_y_dir, segments) = extract_sketch(&sketch_node.op)?;
let (s_origin, s_x_dir, s_y_dir, segments, holes) =
extract_sketch(&sketch_node.op)?;
reject_holes(holes, "loft")?;
let profile = ir_sketch_to_profile(s_origin, s_x_dir, s_y_dir, segments)
.map_err(EvalError::Sketch)?;
profiles.push(profile);
Expand Down Expand Up @@ -1067,25 +1081,47 @@ fn is_transform_op(op: &CsgOp) -> bool {
)
}

/// Error out when a sketch with interior holes reaches an operation that
/// doesn't support them (only extrude does today).
fn reject_holes(
holes: &[Vec<vcad_ir::SketchSegment2D>],
op_name: &'static str,
) -> Result<(), EvalError> {
if holes.is_empty() {
Ok(())
} else {
Err(EvalError::Sketch(
vcad_kernel_sketch::SketchError::HolesUnsupported(op_name),
))
}
}

/// Borrowed fields of a `Sketch2D`: origin, x-dir, y-dir, outer segments,
/// and interior hole loops (empty when absent).
type SketchFields<'a> = (
&'a vcad_ir::Vec3,
&'a vcad_ir::Vec3,
&'a vcad_ir::Vec3,
&'a [vcad_ir::SketchSegment2D],
&'a [Vec<vcad_ir::SketchSegment2D>],
);

/// Extract sketch fields from a CsgOp, returning error if not a Sketch2D.
fn extract_sketch(
op: &CsgOp,
) -> Result<
(
&vcad_ir::Vec3,
&vcad_ir::Vec3,
&vcad_ir::Vec3,
&[vcad_ir::SketchSegment2D],
),
EvalError,
> {
fn extract_sketch(op: &CsgOp) -> Result<SketchFields<'_>, EvalError> {
match op {
CsgOp::Sketch2D {
origin,
x_dir,
y_dir,
segments,
} => Ok((origin, x_dir, y_dir, segments)),
holes,
} => Ok((
origin,
x_dir,
y_dir,
segments,
holes.as_deref().unwrap_or(&[]),
)),
_ => Err(EvalError::InvalidSketchRef),
}
}
Expand Down
145 changes: 145 additions & 0 deletions crates/vcad-eval/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ mod tests {
origin: Vec3::new(0.0, 0.0, 0.0),
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
holes: None,
segments: vec![
SketchSegment2D::Line {
start: Vec2::new(0.0, 0.0),
Expand Down Expand Up @@ -411,6 +412,149 @@ mod tests {
assert!(!scene.parts[0].mesh.positions.is_empty());
}

fn mesh_volume(mesh: &EvaluatedMesh) -> f64 {
let v = &mesh.positions;
let mut vol = 0.0;
for tri in mesh.indices.chunks(3) {
let i = [
tri[0] as usize * 3,
tri[1] as usize * 3,
tri[2] as usize * 3,
];
let p: Vec<[f64; 3]> = i
.iter()
.map(|&k| [v[k] as f64, v[k + 1] as f64, v[k + 2] as f64])
.collect();
vol += p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
- p[1][0] * (p[0][1] * p[2][2] - p[2][1] * p[0][2])
+ p[2][0] * (p[0][1] * p[1][2] - p[1][1] * p[0][2]);
}
(vol / 6.0).abs()
}

/// Closed CCW rectangle loop as IR line segments.
fn rect_segments(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<SketchSegment2D> {
let p = [
Vec2::new(x0, y0),
Vec2::new(x1, y0),
Vec2::new(x1, y1),
Vec2::new(x0, y1),
];
(0..4)
.map(|i| SketchSegment2D::Line {
start: p[i],
end: p[(i + 1) % 4],
})
.collect()
}

fn holed_sketch_doc(holes: Option<Vec<Vec<SketchSegment2D>>>) -> Document {
let mut doc = Document::new();
doc.nodes.insert(
1,
Node {
id: 1,
name: None,
op: CsgOp::Sketch2D {
origin: Vec3::new(0.0, 0.0, 0.0),
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
segments: rect_segments(0.0, 0.0, 20.0, 10.0),
holes,
},
},
);
doc.nodes.insert(
2,
Node {
id: 2,
name: None,
op: CsgOp::Extrude {
sketch: 1,
direction: Vec3::new(0.0, 0.0, 5.0),
twist_angle: None,
scale_end: None,
},
},
);
doc.roots.push(SceneEntry {
root: 2,
material: "default".to_string(),
visible: None,
});
doc
}

#[test]
fn evaluate_sketch_extrude_with_holes() {
let holes = vec![
rect_segments(2.0, 2.0, 6.0, 8.0),
rect_segments(12.0, 3.0, 17.0, 7.0),
];
let scene =
evaluate_document(&holed_sketch_doc(Some(holes)), &EvalOptions::default()).unwrap();
assert_eq!(scene.parts.len(), 1);
assert!(scene.failures.is_empty(), "failures: {:?}", scene.failures);

// Volume = (20·10 − 4·6 − 5·4) · 5
let expected = (200.0 - 24.0 - 20.0) * 5.0;
let vol = mesh_volume(&scene.parts[0].mesh);
assert!(
(vol - expected).abs() < 1e-3 * expected,
"expected {expected}, got {vol}"
);
}

#[test]
fn sketch2d_json_without_holes_still_parses() {
// Serde back-compat: documents written before the `holes` field
// existed must load unchanged.
let json = r#"{
"type": "Sketch2D",
"origin": {"x": 0.0, "y": 0.0, "z": 0.0},
"x_dir": {"x": 1.0, "y": 0.0, "z": 0.0},
"y_dir": {"x": 0.0, "y": 1.0, "z": 0.0},
"segments": [
{"type": "Line", "start": {"x": 0.0, "y": 0.0}, "end": {"x": 1.0, "y": 0.0}},
{"type": "Line", "start": {"x": 1.0, "y": 0.0}, "end": {"x": 1.0, "y": 1.0}},
{"type": "Line", "start": {"x": 1.0, "y": 1.0}, "end": {"x": 0.0, "y": 0.0}}
]
}"#;
let op: CsgOp = serde_json::from_str(json).unwrap();
match &op {
CsgOp::Sketch2D {
holes, segments, ..
} => {
assert!(holes.is_none());
assert_eq!(segments.len(), 3);
}
other => panic!("expected Sketch2D, got {other:?}"),
}
// And a hole-free sketch round-trips without emitting the field.
let out = serde_json::to_string(&op).unwrap();
assert!(
!out.contains("holes"),
"hole-free sketch serialized holes: {out}"
);
}

#[test]
fn revolve_rejects_holed_sketch() {
let mut doc = holed_sketch_doc(Some(vec![rect_segments(2.0, 2.0, 6.0, 8.0)]));
doc.nodes.get_mut(&2).unwrap().op = CsgOp::Revolve {
sketch: 1,
axis_origin: Vec3::new(-1.0, 0.0, 0.0),
axis_dir: Vec3::new(0.0, 1.0, 0.0),
angle_deg: 360.0,
};
let scene = evaluate_document(&doc, &EvalOptions::default()).unwrap();
// The failure is recorded per-root rather than aborting evaluation.
assert!(
!scene.failures.is_empty(),
"revolve of a holed sketch should fail"
);
}

#[test]
fn evaluate_pcb_board_is_centered_solid() {
use vcad_ir::ecad::{BoardOutline, DesignRules, LayerStackup, NetClassRules, Pcb};
Expand Down Expand Up @@ -509,6 +653,7 @@ mod tests {
origin: Vec3::new(0.0, 0.0, 0.0),
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
holes: None,
segments: vec![
SketchSegment2D::Line {
start: Vec2::new(5.0, 0.0),
Expand Down
1 change: 1 addition & 0 deletions crates/vcad-gdsii/examples/flat_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ fn main() {
x_dir: Vec3::new(1.0, 0.0, 0.0),
y_dir: Vec3::new(0.0, 1.0, 0.0),
segments,
holes: None,
},
},
);
Expand Down
Loading