mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-06 20:02:16 +00:00
* claim: #2722 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Sketch on the sides of a thin extrude (#2722) A thin wall's sides lie off the sketch curves (half the thickness either side, say), so no curve was under them: its side faces had no role, no exact history name, and Create Sketch refused them as "can't keep an attachment". The roles of a thin wall's sides are now `wall:<curve>:left|right` (the side of the curve it runs beside, in the direction the curve is drawn), and its open ends `wall:<curve>:start|end`. They follow the thickness and side settings and survive a save. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -526,6 +526,89 @@ impl ExactSketchCurves<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSketchCurves<'_> {
|
||||
/// The roles of a thin wall's side that runs parallel to some of the wall's `curves`, off
|
||||
/// them by one of the wall's `distances`: `wall:<curve>:left` (or `:right`) for the side of
|
||||
/// that curve, left or right of the direction it is drawn in. A thin wall's sides lie off the
|
||||
/// sketch (by half its thickness, say), so [`find_all_along`](Self::find_all_along) finds no
|
||||
/// curve under them. Every curve the side runs along and overlaps names it (the first its
|
||||
/// name, the others aliases), in the sketch's order; empty when it is parallel to none, or
|
||||
/// its distance is not one of the wall's.
|
||||
pub(crate) fn find_offset(&self, generated: &Seg2, curves: &[String], distances: [f64; 2]) -> Vec<String> {
|
||||
let wanted = |offset: f64, scale: f64| {
|
||||
let tolerance = 1e-7 * scale.max(1.);
|
||||
offset.abs() > tolerance && distances.iter().any(|d| (d - offset.abs()).abs() <= tolerance)
|
||||
};
|
||||
let side = |offset: f64| if offset > 0. { "left" } else { "right" };
|
||||
let tau = std::f64::consts::TAU;
|
||||
let mut roles = Vec::new();
|
||||
for (id, _, originals) in &self.segments {
|
||||
if !curves.iter().any(|c| c == id) {
|
||||
continue;
|
||||
}
|
||||
let found = originals.iter().find_map(|original| match (*original, *generated) {
|
||||
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
||||
let direction = b - a;
|
||||
let length = direction.len();
|
||||
let scale = length.max((d - c).len());
|
||||
if length <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let (left_c, left_d) = (direction.cross(c - a) / length, direction.cross(d - a) / length);
|
||||
if (left_c - left_d).abs() > 1e-9 * scale.max(1.) || !wanted(left_c, scale) {
|
||||
return None;
|
||||
}
|
||||
let (tc, td) = ((c - a).dot(direction) / direction.len2(), (d - a).dot(direction) / direction.len2());
|
||||
(tc.min(td).max(0.) < tc.max(td).min(1.) - 1e-9).then(|| side(left_c))
|
||||
}
|
||||
(Seg2::Arc { center: oc, radius: or, start: os, sweep: ow }, Seg2::Arc { center: gc, radius: gr, start: gs, sweep: gw }) => {
|
||||
let scale = or.max(gr);
|
||||
if oc.dist(gc) > 1e-9 * scale.max(1.) || !wanted(gr - or, scale) || ow == 0. || gw == 0. {
|
||||
return None;
|
||||
}
|
||||
// Drawn counter-clockwise, the curve's left is towards its centre.
|
||||
let left = if ow > 0. { gr < or } else { gr > or };
|
||||
let inside = |start: f64, sweep: f64, angle: f64| {
|
||||
let low = if sweep >= 0. { start } else { start + sweep };
|
||||
(angle - low).rem_euclid(tau) <= sweep.abs() + 1e-9
|
||||
};
|
||||
let overlaps = inside(os, ow, gs)
|
||||
|| inside(os, ow, gs + gw)
|
||||
|| inside(os, ow, gs + gw / 2.)
|
||||
|| inside(gs, gw, os)
|
||||
|| inside(gs, gw, os + ow / 2.)
|
||||
|| ow.abs() >= tau - 1e-9
|
||||
|| gw.abs() >= tau - 1e-9;
|
||||
overlaps.then_some(if left { "left" } else { "right" })
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
// The end of an open chain: a straight cut across the curve, through its end point.
|
||||
let cap = || {
|
||||
let Seg2::Line { a: c, b: d } = *generated else { return None };
|
||||
let width = (d - c).len();
|
||||
if width <= 1e-12 || (width - (distances[0] + distances[1])).abs() > 1e-7 * width.max(1.) {
|
||||
return None;
|
||||
}
|
||||
originals.iter().find_map(|original| {
|
||||
[("start", original.start(), original.start_tangent()), ("end", original.end(), original.end_tangent())].into_iter().find_map(
|
||||
|(end, point, tangent)| {
|
||||
let across = (d - c) * (1. / width);
|
||||
let on_line = across.cross(point - c).abs() <= 1e-7 * width.max(1.);
|
||||
let along = (point - c).dot(across);
|
||||
(tangent.dot(across).abs() <= 1e-7 && on_line && (-1e-7..=width + 1e-7).contains(&along)).then_some(end)
|
||||
},
|
||||
)
|
||||
})
|
||||
};
|
||||
if let Some(side) = found.or_else(cap) {
|
||||
roles.push(format!("wall:{id}:{side}"));
|
||||
}
|
||||
}
|
||||
roles
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
match (original, generated) {
|
||||
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
||||
|
||||
+39
-15
@@ -4522,11 +4522,12 @@ fn whole_face_tool(
|
||||
sketch: Some(ss),
|
||||
region: Some(region),
|
||||
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
|
||||
thin: None,
|
||||
};
|
||||
match operation {
|
||||
// Out of the body from the face.
|
||||
Operation::Join if (up && at_lo) || (!up && at_hi) => {
|
||||
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?;
|
||||
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss), None).ok()?;
|
||||
let [built] = built.as_slice() else { return None };
|
||||
let exact = kernel::face_prism(&target.body, face, hi - lo, true, &built.body).ok()?;
|
||||
kernel::can_join_touching_faces(&target.body, &exact).then(|| (roles(r).tool(exact, Some(&built.body)), (lo, hi)))
|
||||
@@ -4534,7 +4535,7 @@ fn whole_face_tool(
|
||||
// Into the body from the face, its walls touching it at most.
|
||||
Operation::Cut if ((up && at_hi) || (!up && at_lo)) && !kernel::cut_takes_only_the_face(&target.body, face, hi - lo) => {
|
||||
// Some walls go into the body: those that only touch it are moved off.
|
||||
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss)).ok()?;
|
||||
let built = centered_extrude(&ss.plane, r, lo, hi, Some(ss), None).ok()?;
|
||||
let [built] = built.as_slice() else { return None };
|
||||
let (tool, off) = kernel::cut_tool(&target.body, face, hi - lo, Some(&built.body))?;
|
||||
let span = if up { (lo, hi + off) } else { (lo - off, hi) };
|
||||
@@ -4550,7 +4551,7 @@ fn whole_face_tool(
|
||||
let segs = (0..4).filter_map(|i| Some(line(*corners.get(i)?, *corners.get((i + 1) % 4)?))).collect();
|
||||
let slab = Region2 { outer: solidcraft_geom::Loop2 { segs }, holes: vec![] };
|
||||
let (l, h) = if up { (lo, hi + reach) } else { (lo - reach, hi) };
|
||||
let built = centered_extrude(&ss.plane, &slab, l, h, None).ok()?;
|
||||
let built = centered_extrude(&ss.plane, &slab, l, h, None, None).ok()?;
|
||||
let [built] = built.as_slice() else { return None };
|
||||
#[cfg(test)]
|
||||
whole_face_tests::slab_taken();
|
||||
@@ -4561,7 +4562,14 @@ fn whole_face_tool(
|
||||
}
|
||||
|
||||
/// Construct planar caps locally before placing the completed tool.
|
||||
fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketch: Option<&SolvedSketch>) -> Result<Vec<Traced>> {
|
||||
fn centered_extrude(
|
||||
plane: &Plane,
|
||||
region: &Region2,
|
||||
start: f64,
|
||||
end: f64,
|
||||
sketch: Option<&SolvedSketch>,
|
||||
thin: Option<&crate::lineage::ThinWallRoles>,
|
||||
) -> Result<Vec<Traced>> {
|
||||
if !plane.origin.is_finite()
|
||||
|| !plane.x.is_finite()
|
||||
|| !plane.y.is_finite()
|
||||
@@ -4577,6 +4585,7 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
sketch,
|
||||
region: Some(region),
|
||||
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
|
||||
thin,
|
||||
};
|
||||
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
|
||||
// wire can tilt an otherwise exact planar cap through numeric cancellation.
|
||||
@@ -4601,11 +4610,8 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The regions a thin extrude's wall covers in its sketch.
|
||||
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
|
||||
if thin.curves.is_empty() || thin.curves.len() > 1000 {
|
||||
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
|
||||
}
|
||||
/// How far a thin extrude's wall reaches either side of its curves: (inside, outside).
|
||||
fn thin_distances(vals: &BTreeMap<String, Value>, thin: &crate::ThinWall) -> Result<(f64, f64)> {
|
||||
let t = val(vals, &thin.thickness, Kind::Length)?;
|
||||
let (inside, outside) = match thin.side {
|
||||
crate::ThinSide::Center => (t / 2.0, t / 2.0),
|
||||
@@ -4619,6 +4625,15 @@ pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, th
|
||||
if !(inside >= 0.0 && outside >= 0.0 && inside + outside > 0.0) {
|
||||
return Err(DocError::Invalid("the wall thickness must be positive".into()));
|
||||
}
|
||||
Ok((inside, outside))
|
||||
}
|
||||
|
||||
/// The regions a thin extrude's wall covers in its sketch.
|
||||
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
|
||||
if thin.curves.is_empty() || thin.curves.len() > 1000 {
|
||||
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
|
||||
}
|
||||
let (inside, outside) = thin_distances(vals, thin)?;
|
||||
let curves = thin
|
||||
.curves
|
||||
.iter()
|
||||
@@ -4653,6 +4668,14 @@ fn feature_tools_with_constructor(
|
||||
Some(thin) => thin_regions(vals, &ss, thin)?,
|
||||
None => solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?),
|
||||
};
|
||||
// A thin wall's sides are named by the curves they run beside.
|
||||
let thin_roles = match thin {
|
||||
Some(thin) => {
|
||||
let (inside, outside) = thin_distances(vals, thin)?;
|
||||
Some(crate::lineage::ThinWallRoles { curves: thin.curves.clone(), distances: [inside, outside] })
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// From / To Object: where the face or point lies along the normal, measured over the
|
||||
// middle of the profiles. The points are in this feature's component frame, so the
|
||||
// model is seen from there: a target in another, moved component where it is shown.
|
||||
@@ -4727,7 +4750,8 @@ fn feature_tools_with_constructor(
|
||||
// sides standing on a face are curved where they meet it (along text, say),
|
||||
// and the boolean finds no result there. The sides keep their slope, so
|
||||
// the profile stays the section at the start.
|
||||
let roles = crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() };
|
||||
let roles =
|
||||
crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone(), thin: thin_roles.as_ref() };
|
||||
// A start that is a whole face of the target (a foot under a part's base) is sewn on
|
||||
// along it, as it lies: run on, the sides would leave the face through its edges.
|
||||
if on_face
|
||||
@@ -4850,7 +4874,7 @@ fn feature_tools_with_constructor(
|
||||
{
|
||||
let exact = regions
|
||||
.iter()
|
||||
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss)))
|
||||
.map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss), thin_roles.as_ref()))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -4899,7 +4923,7 @@ fn feature_tools_with_constructor(
|
||||
continue;
|
||||
}
|
||||
if *operation == Operation::Join {
|
||||
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
|
||||
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss), thin_roles.as_ref())?;
|
||||
if exact.iter().all(|tool| {
|
||||
st.bodies
|
||||
.iter()
|
||||
@@ -4933,14 +4957,14 @@ fn feature_tools_with_constructor(
|
||||
let mut r = filled(grown(&|pieces| {
|
||||
surroundings.as_ref().is_some_and(|sur| sur.harmless(&ss.plane, pieces, -sur.skin, l2 + sur.skin, h2 - sur.skin))
|
||||
}));
|
||||
let mut built = centered_extrude(&ss.plane, &r, l2, h2, Some(&ss));
|
||||
let mut built = centered_extrude(&ss.plane, &r, l2, h2, Some(&ss), thin_roles.as_ref());
|
||||
if built.is_err() {
|
||||
// A profile the kernel cannot build as drawn (a square some hundredths of a
|
||||
// micrometre across, away from the origin, defeats truck's plane fit) grows
|
||||
// wherever the samples find room, as it used to.
|
||||
let loose = filled(grown(&|_| true));
|
||||
if loose != r {
|
||||
built = centered_extrude(&ss.plane, &loose, l2, h2, Some(&ss));
|
||||
built = centered_extrude(&ss.plane, &loose, l2, h2, Some(&ss), thin_roles.as_ref());
|
||||
r = loose;
|
||||
}
|
||||
}
|
||||
@@ -8168,7 +8192,7 @@ fn split_by_plane_traced(input: &Traced, plane: &Plane) -> Result<Vec<Traced>> {
|
||||
// The box's cap in the plane: its start cap on the positive side, its end cap on the other.
|
||||
let section = Some("section".to_string());
|
||||
let caps = if positive { [section, None] } else { [None, section] };
|
||||
let tool = crate::lineage::ProfileRoles { sketch: None, region: None, caps }.tool(tool, None);
|
||||
let tool = crate::lineage::ProfileRoles { sketch: None, region: None, caps, thin: None }.tool(tool, None);
|
||||
parts.push(Traced::step(part, &[input, &tool]));
|
||||
}
|
||||
Ok(parts)
|
||||
|
||||
@@ -193,6 +193,16 @@ pub(crate) struct ProfileRoles<'a> {
|
||||
pub region: Option<&'a Region2>,
|
||||
/// The start and end caps' roles (`None`: not known).
|
||||
pub caps: [Option<String>; 2],
|
||||
/// A thin wall: its sides lie off the sketch curves, and are named by the curve they run
|
||||
/// beside.
|
||||
pub thin: Option<&'a ThinWallRoles>,
|
||||
}
|
||||
|
||||
/// What names the sides of a thin extrude's wall: the curves it follows and how far its sides lie
|
||||
/// from them (the thickness either side).
|
||||
pub(crate) struct ThinWallRoles {
|
||||
pub curves: Vec<String>,
|
||||
pub distances: [f64; 2],
|
||||
}
|
||||
|
||||
impl<'a> ProfileRoles<'a> {
|
||||
@@ -210,7 +220,12 @@ impl<'a> ProfileRoles<'a> {
|
||||
// Every curve the segment is drawn on (one, or several coincident or collinear ones).
|
||||
let curve = |segment: usize| -> Vec<String> {
|
||||
let (Some(curves), Some(segment)) = (curves.as_ref(), segments.get(segment)) else { return Vec::new() };
|
||||
curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect()
|
||||
let drawn: Vec<String> = curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect();
|
||||
match self.thin {
|
||||
// A thin wall's side beside its curve (the other side may lie on it).
|
||||
Some(thin) if drawn.is_empty() => curves.find_offset(segment, &thin.curves, thin.distances),
|
||||
_ => drawn,
|
||||
}
|
||||
};
|
||||
let roles = history
|
||||
.faces
|
||||
@@ -242,7 +257,7 @@ impl<'a> ProfileRoles<'a> {
|
||||
/// A body swept from a profile (revolve, sweep, loft): its sides named by the sketch curves,
|
||||
/// its caps `start` and `end`.
|
||||
pub fn swept(sketch: Option<&'a SolvedSketch>, region: Option<&'a Region2>) -> ProfileRoles<'a> {
|
||||
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())] }
|
||||
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())], thin: None }
|
||||
}
|
||||
|
||||
/// A tool built from the profile (and then moved, from `built`, when given), its faces named
|
||||
|
||||
@@ -282,3 +282,99 @@ fn no_errors(s: &Session, context: &str) {
|
||||
s.model.results.iter().filter_map(|r| r.error.as_ref()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// How far a face's plane lies from the origin along its own normal.
|
||||
fn plane_offset(plane: &solidcraft_geom::Plane) -> f64 {
|
||||
plane.normal().dot(plane.origin)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_walls_of_an_open_thin_extrude_take_sketches_that_follow_their_thickness() {
|
||||
// Three lines make an open wall; its sides lie off the sketch lines (a hair either side of
|
||||
// each), so no sketch curve is under them. Each side still has a name its history can tell
|
||||
// apart, so a sketch on it attaches and follows the wall when the thickness changes.
|
||||
for side in ["center", "inside", "outside"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
|
||||
let lines = run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 30], [0, 30]], "closed": false}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let curves = lines["created"]["curves"].clone();
|
||||
let thin = |t: &str| json!({"feature": "Wall", "set": {"thin": {"curves": curves, "thickness": t, "side": side}}});
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Outline", "thin": true, "curves": curves, "thickness": "2", "side": side, "distance": 20, "name": "Wall"}),
|
||||
);
|
||||
no_errors(&s, side);
|
||||
let picks = flat_side_faces(&s);
|
||||
assert!(picks.len() >= 6, "{side}: {picks:?}");
|
||||
let mut moved_by_one = 0;
|
||||
for (k, (label, at)) in picks.iter().enumerate() {
|
||||
let name = format!("S{k}");
|
||||
let mut t = s.scratch();
|
||||
sketch_on(&mut t, *at, &name);
|
||||
let (plane, _) = attached(&t, &name);
|
||||
// Twice as thick, then back: the sketch stays on its face.
|
||||
run(&mut t, "timeline.edit", thin("4"));
|
||||
no_errors(&t, &format!("{side} {label}"));
|
||||
let (thick, _) = attached(&t, &name);
|
||||
if side == "center" {
|
||||
// A side of a centred wall is half the thickness off its line: 1 -> 2 mm. The
|
||||
// wall's two ends stay where they are.
|
||||
let moved = (plane_offset(&thick).abs() - plane_offset(&plane).abs()).abs();
|
||||
assert!(moved < 1e-6 || (moved - 1.0).abs() < 1e-6, "{label}: moved {moved}");
|
||||
moved_by_one += usize::from(moved > 0.5);
|
||||
}
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&t.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
no_errors(&reopened, side);
|
||||
same_plane(&attached(&reopened, &name).0, &thick);
|
||||
}
|
||||
if side == "center" {
|
||||
assert_eq!(moved_by_one, 6, "two sides of each of the three lines move with the thickness");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point inside each flat, vertical face of the first body: (what it is, point).
|
||||
fn flat_side_faces(s: &Session) -> Vec<(String, [f64; 3])> {
|
||||
let world = s.world_state();
|
||||
let mut out = Vec::new();
|
||||
let Some(body) = world.bodies.first() else { return out };
|
||||
let mesh = body.mesh();
|
||||
let mut best: std::collections::BTreeMap<usize, (solidcraft_geom::Vec3, f64)> = std::collections::BTreeMap::new();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if let Some([a, b, c]) = mesh.tri(triangle) {
|
||||
let area = (b - a).cross(c - a).len();
|
||||
let entry = best.entry(*face as usize).or_insert(((a + b + c) * (1. / 3.), -1.));
|
||||
if area > entry.1 {
|
||||
*entry = ((a + b + c) * (1. / 3.), area);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, (point, _)) in best {
|
||||
if let Some(plane) = body.body.planar_face(index)
|
||||
&& plane.normal().z.abs() < 1e-6
|
||||
{
|
||||
out.push((format!("face {index} normal {:?}", plane.normal()), [point.x, point.y, point.z]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_round_sides_of_a_thin_ring_are_named_by_the_circle_they_run_beside() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 5, "name": "Ring"}));
|
||||
let world = s.world_state();
|
||||
let names = solidcraft_doc::naming::face_names(world.bodies.first().unwrap());
|
||||
// Each round side is two half cylinders, told apart by their numbers.
|
||||
let walls: Vec<_> = names.iter().filter(|name| name.contains(":wall:")).collect();
|
||||
assert_eq!(walls.len(), 4, "{names:?}");
|
||||
for side in ["left", "right"] {
|
||||
assert_eq!(walls.iter().filter(|name| name.contains(&format!(":wall:c1:{side}#"))).count(), 2, "{walls:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user