forked from 1b-agent/fieldcraft-upstream
mesh: mesh.plan.apply builds rings and fillet/chamfer blend layers as one projected block layout (#402)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
361ac2e7b3
commit
d29334a7a7
5 files changed
+858
-36
No files matched your search
+221
-19
@@ -34,6 +34,8 @@ use fieldcraft_geometry::{Curve, Design, FaceSurface, FeatureNode, Shape};
|
||||
use fieldcraft_model::{CutRole, Datum, MeshPlan, Partition, PlanCut, PlanFeature, PlanMesher, PlanRing, PlanUnmapped, RingKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
mod blocks;
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::schema::ProblemKind;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
@@ -134,6 +136,7 @@ fn template_of(f: &FeatureNode) -> Option<&'static str> {
|
||||
("combine", _) => "imprint",
|
||||
("pattern" | "mirror", _) => "copy",
|
||||
("split", _) => "partition",
|
||||
("fillet" | "chamfer", _) => "blend",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -141,7 +144,6 @@ fn template_of(f: &FeatureNode) -> Option<&'static str> {
|
||||
/// Why a feature with no template is not mapped.
|
||||
fn unmapped_reason(f: &FeatureNode) -> String {
|
||||
match f.kind.as_str() {
|
||||
"fillet" | "chamfer" => "a blend layer along its edges at its stored size is step 3 of the provenance plan (#402); no template yet".into(),
|
||||
"revolve" => "revolving the profile mesh about the stored axis is step 3 of the provenance plan (#402); no template yet".into(),
|
||||
"sweep" | "loft" => {
|
||||
"sweeping the profile along the stored path or between the sections is step 3 of the provenance plan (#402); no template yet".into()
|
||||
@@ -165,6 +167,8 @@ fn compatible(f: &FeatureNode, d: V3, shape: &Shape, surfaces: &[Option<FaceSurf
|
||||
"box" => AXES.iter().any(|a| parallel(*a, d)),
|
||||
// Copies and combines bring the features they repeat or join, which are judged themselves.
|
||||
"pattern" | "mirror" | "combine" | "split" => true,
|
||||
// A blend is a layer of blocks along the body's sides, whatever its edges' direction.
|
||||
"fillet" | "chamfer" => true,
|
||||
_ => faces_prismatic(shape, surfaces, &f.faces, d),
|
||||
}
|
||||
}
|
||||
@@ -584,10 +588,10 @@ fn plan_rings(
|
||||
let along = dot(off, d);
|
||||
dot(off, off) - along * along < 1e-12
|
||||
};
|
||||
if out.iter().any(|r| same(r) && r.radius >= c.radius) {
|
||||
// Concentric circles each get a ring (a boss and its hole: squares inside squares).
|
||||
if out.iter().any(|r| same(r) && (r.radius - c.radius).abs() < 1e-9 * c.radius.max(1.0)) {
|
||||
continue;
|
||||
}
|
||||
out.retain(|r| !same(r));
|
||||
let frame = match c.kind {
|
||||
RingKind::Butterfly => 0.5 * c.radius,
|
||||
RingKind::OGrid => in_plane_axes(d)
|
||||
@@ -760,6 +764,12 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
("combine", _) => format!("the cross-sections of {} imprinted on the target", f.tools.join(", ")),
|
||||
("pattern" | "mirror", _) => format!("the instance meshed and repeated by {} transforms", f.transforms.len()),
|
||||
("split", _) => "the pieces meshed as cells of the split".into(),
|
||||
("fillet", _) => {
|
||||
format!("a blend layer of blocks along its edges, {:.4} mm deep, projected onto the fillet faces", v("radius").unwrap_or_default())
|
||||
}
|
||||
("chamfer", _) => {
|
||||
format!("a blend layer of blocks along its edges, {:.4} mm deep, projected onto the chamfer faces", v("distance").unwrap_or_default())
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
circle_list.extend(placed(circles(f, d), f, d, shape, &surfaces));
|
||||
@@ -852,7 +862,7 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
}
|
||||
let mesher = if cuts.is_empty() { PlanMesher::Sweep } else { PlanMesher::Cells };
|
||||
let copies = copies_of(design, body, &plan_features)?;
|
||||
let plan = MeshPlan {
|
||||
let mut plan = MeshPlan {
|
||||
body: body.into(),
|
||||
size: h,
|
||||
tolerance: Some(tolerance),
|
||||
@@ -868,9 +878,50 @@ pub(crate) fn build(design: &Design, body: &str, size: Option<f64>, layers: u32,
|
||||
notes,
|
||||
};
|
||||
plan.check()?;
|
||||
// Rings and blends: one block topology over the whole body when it fits (no cuts needed);
|
||||
// else the frame cuts and swept cells, with the reason.
|
||||
match layout_of(design, &plan)? {
|
||||
Some(Ok(layout)) => {
|
||||
plan.mesher = PlanMesher::Project;
|
||||
plan.cuts.clear();
|
||||
plan.notes.push(layout.summary());
|
||||
}
|
||||
Some(Err(why)) => plan.notes.push(format!("no projection layout ({why}): the frame cuts box the rings and the cells are swept")),
|
||||
None => {}
|
||||
}
|
||||
plan.check()?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// The block layout of `plan` for the projection mesher, when the plan has rings or blends
|
||||
/// (`None` when it has neither: the sweep meshes it); `Err` inside says why the body does not
|
||||
/// take one.
|
||||
fn layout_of(design: &Design, plan: &MeshPlan) -> Result<Option<std::result::Result<blocks::Layout, String>>> {
|
||||
let blends: Vec<&PlanFeature> = plan.features.iter().filter(|f| f.template == "blend").collect();
|
||||
if plan.rings.is_empty() && blends.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(direction) = plan.direction else { return Ok(Some(Err("no sweep direction".into()))) };
|
||||
let shape = design.source_body(&plan.body).ok_or_else(|| EngineError::Params(format!("no body `{}` in the design", plan.body)))?;
|
||||
let surfaces = shape.face_surfaces()?;
|
||||
let m = shape.measure()?;
|
||||
let diagonal = (0..3).map(|k| (m.bbox_max[k] - m.bbox_min[k]).powi(2)).sum::<f64>().sqrt();
|
||||
let mesh = shape.tessellate(plan.tolerance.unwrap_or(1e-3 * diagonal).max(1e-5 * diagonal))?;
|
||||
let blend_faces = blends.iter().flat_map(|f| &f.faces).filter_map(|n| shape.face_index(n)).collect();
|
||||
let shell = if plan.features.iter().any(|f| f.kind == "shell") {
|
||||
design
|
||||
.features(Some(&plan.body))?
|
||||
.into_iter()
|
||||
.flat_map(|b| b.features)
|
||||
.filter(|f| f.kind == "shell")
|
||||
.find_map(|f| f.values.get("thickness").copied())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let input = blocks::Input { shape, surfaces: &surfaces, mesh: &mesh, direction, rings: &plan.rings, blends: blend_faces, shell, size: plan.size };
|
||||
Ok(Some(blocks::layout(&input)))
|
||||
}
|
||||
|
||||
fn plan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let body: String = super::req(p, "body")?;
|
||||
let size: Option<f64> = super::opt(p, "size")?;
|
||||
@@ -996,6 +1047,9 @@ fn apply_one(s: &mut Session, name: &str, plan: &MeshPlan) -> Result<Value> {
|
||||
1e-3 * (0..3).map(|k| (b.bbox_max[k] - b.bbox_min[k]).powi(2)).sum::<f64>().sqrt()
|
||||
}
|
||||
};
|
||||
if plan.mesher == PlanMesher::Project {
|
||||
return apply_projection(s, name, plan, tolerance);
|
||||
}
|
||||
let cuts: Vec<&PlanCut> = plan.cuts.iter().collect();
|
||||
let (cleared, made, skipped) = cut(s, name, &plan.body, &cuts, tolerance)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
@@ -1054,6 +1108,61 @@ fn apply_one(s: &mut Session, name: &str, plan: &MeshPlan) -> Result<Value> {
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Carry out a plan whose mesher is projection blocking: its earlier partitions removed, one block
|
||||
/// layout over the whole body (rings, blend layer) projected and smoothed, the body's mesh
|
||||
/// replaced. No fallback: a layout that does not fit, or a mesh below the projection's quality
|
||||
/// floor, is the error.
|
||||
fn apply_projection(s: &mut Session, name: &str, plan: &MeshPlan, tolerance: f64) -> Result<Value> {
|
||||
let (cleared, _, _) = cut(s, name, &plan.body, &[], tolerance)?;
|
||||
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
|
||||
let layout = match layout_of(design, plan)? {
|
||||
Some(Ok(l)) => l,
|
||||
Some(Err(why)) => return Err(EngineError::Params(format!("mesh plan `{name}`: no projection layout for body `{}`: {why}", plan.body))),
|
||||
None => return Err(EngineError::Params(format!("mesh plan `{name}`: projection blocking needs rings or blends; set the mesher to sweep"))),
|
||||
};
|
||||
let body = super::project::open_body(s, &plan.body, Some(tolerance), &json!({}))?;
|
||||
let merge = super::project::merge_distance(None, body.diagonal())?;
|
||||
let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, ..Default::default() };
|
||||
let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge)
|
||||
.map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")))?;
|
||||
if let Some(m) = mesh.as_object_mut() {
|
||||
// The block listing is long; the plan reports the layout instead.
|
||||
m.remove("topology");
|
||||
}
|
||||
let mut reply = json!({
|
||||
"name": name,
|
||||
"body": plan.body,
|
||||
"mesher": "project",
|
||||
"layout": layout.summary(),
|
||||
"blend_layers": layout.layers.iter().map(|(side, depth, what)| json!({"side": side, "depth": depth, "for": what})).collect::<Vec<_>>(),
|
||||
"cuts": [],
|
||||
"skipped_cuts": [],
|
||||
"cells": [plan.body],
|
||||
"size": plan.size,
|
||||
"tolerance": tolerance,
|
||||
"blocks": mesh["blocks"],
|
||||
"elements": mesh["elements"],
|
||||
"min_scaled_jacobian": mesh["min_scaled_jacobian"],
|
||||
"all_hex": true,
|
||||
// One block topology, one mesh: every block face shared node to node.
|
||||
"conforming": true,
|
||||
"mapped": plan.features.iter().map(|f| json!({"id": f.id, "name": f.name, "type": f.kind, "template": f.template})).collect::<Vec<_>>(),
|
||||
"pending": [],
|
||||
"unmapped": plan.unmapped,
|
||||
"mesh": mesh,
|
||||
});
|
||||
reply = cleared.report_if_any(s, reply);
|
||||
if let Some(m) = reply.get_mut("mesh").and_then(Value::as_object_mut) {
|
||||
let theirs = json!({"replaced": m.remove("replaced"), "stale_sets": m.remove("stale_sets")});
|
||||
m.remove("results_cleared");
|
||||
take_replaced(&mut reply, &theirs);
|
||||
}
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.remove("results_cleared");
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// Replace plan `name`'s partitions of `body` with `cuts` (named `<name>-cut<k>` after their
|
||||
/// place in the plan). A cut the kernel cannot make (or that divides nothing left), or that
|
||||
/// leaves a cell whose boundary no mesher takes at `tolerance` (a plane the tessellation does
|
||||
@@ -1215,7 +1324,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plate_with_holes_is_a_block_with_o_grids_framed_by_cuts() {
|
||||
fn a_plate_with_holes_is_one_block_layout_with_an_o_grid_round_each_hole() {
|
||||
let (_d, mut s) = open(json!([
|
||||
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 6, "body_name": "Plate"}},
|
||||
{"command": "solid.hole", "params": {"position": [15, 20, 6], "diameter": 8, "depth": 6}},
|
||||
@@ -1224,7 +1333,6 @@ mod tests {
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "Plate"})).unwrap();
|
||||
assert_eq!(templates(&plan), pairs(&[("box", "block"), ("hole", "o_grid"), ("hole", "o_grid")]));
|
||||
assert_eq!(plan["direction"], json!([0.0, 0.0, 1.0]));
|
||||
assert_eq!(plan["mesher"], "cells");
|
||||
// Two O-grid rings, each projecting onto its hole's walls, framed short of the other hole.
|
||||
let rings = plan["rings"].as_array().unwrap();
|
||||
assert_eq!(rings.len(), 2, "{plan}");
|
||||
@@ -1233,16 +1341,18 @@ mod tests {
|
||||
assert!(!r["faces"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert!(r["frame"].as_f64().unwrap() > 4.0);
|
||||
}
|
||||
// The cut between the holes, and the frames above and below them.
|
||||
assert!(plan["cuts"].as_array().unwrap().iter().any(|c| c["point"][0] == 30.0 && c["normal"] == json!([1.0, 0.0, 0.0])), "{plan}");
|
||||
// One block layout over the whole plate: no partition cuts.
|
||||
assert_eq!((plan["mesher"].as_str(), plan.get("cuts")), (Some("project"), None), "{plan}");
|
||||
let r = apply(&mut s, &plan, 0.5);
|
||||
assert!(r["cells"].as_array().unwrap().len() > 1, "{r}");
|
||||
assert_eq!(r["pending"].as_array().unwrap().len(), 2, "{r}");
|
||||
assert_eq!(r["cells"], json!(["Plate"]), "{r}");
|
||||
assert_eq!(r["pending"], json!([]), "{r}");
|
||||
// The ring nodes lie on the hole walls: the holes are round.
|
||||
assert!(r["mesh"]["max_gap"].as_f64().unwrap() < 1e-6, "{r}");
|
||||
volume_matches(&mut s);
|
||||
// Saved: applied again by name it replaces its cuts and mesh.
|
||||
// Saved: applied again by name it replaces its mesh.
|
||||
let again = s.execute("mesh.plan.apply", &json!({"plan": "Plan-Plate"})).unwrap();
|
||||
assert_eq!(again["cuts"], r["cuts"]);
|
||||
assert_eq!(s.model.partitions().len(), r["cuts"].as_array().unwrap().len());
|
||||
assert_eq!(again["elements"], r["elements"]);
|
||||
assert!(s.model.partitions().is_empty());
|
||||
assert!(!again["replaced"].as_array().unwrap().is_empty(), "{again}");
|
||||
}
|
||||
|
||||
@@ -1266,7 +1376,7 @@ mod tests {
|
||||
let (_d, mut s) = open(json!([{"command": "solid.cylinder", "params": {"diameter": 20, "height": 30, "body_name": "Cyl"}}]));
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "Cyl"})).unwrap();
|
||||
assert_eq!(templates(&plan), pairs(&[("cylinder", "butterfly")]));
|
||||
assert_eq!((plan["mesher"].as_str(), plan.get("cuts")), (Some("sweep"), None));
|
||||
assert_eq!((plan["mesher"].as_str(), plan.get("cuts")), (Some("project"), None));
|
||||
assert_eq!(plan["rings"][0]["kind"], "butterfly");
|
||||
assert_eq!(plan["rings"][0]["frame"], 5.0);
|
||||
apply(&mut s, &plan, 0.7);
|
||||
@@ -1325,16 +1435,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillets_are_unmapped_with_the_reason_and_hostile_plans_are_refused() {
|
||||
fn a_fillet_is_a_blend_layer_and_hostile_plans_are_refused() {
|
||||
let (_d, mut s) = open(json!([
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "body_name": "B"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[20, 10, 20]], "radius": 2}},
|
||||
]));
|
||||
let plan = s.execute("mesh.plan", &json!({"body": "B"})).unwrap();
|
||||
let unmapped = plan["unmapped"].as_array().unwrap();
|
||||
assert_eq!(unmapped.len(), 1, "{plan}");
|
||||
assert_eq!(unmapped[0]["type"], "fillet");
|
||||
assert!(unmapped[0]["reason"].as_str().unwrap().contains("step 3"), "{plan}");
|
||||
assert_eq!(templates(&plan), pairs(&[("box", "block"), ("fillet", "blend")]));
|
||||
assert_eq!(plan["mesher"], "project", "{plan}");
|
||||
let r = apply(&mut s, &plan, 0.5);
|
||||
// The layer along the filleted edge, on the two sides it runs between, at its radius.
|
||||
let layers = r["blend_layers"].as_array().unwrap();
|
||||
assert_eq!(layers.len(), 2, "{r}");
|
||||
assert!(layers.iter().all(|l| (l["depth"].as_f64().unwrap() - 2.0).abs() < 1e-6), "{r}");
|
||||
volume_matches(&mut s);
|
||||
let mut bad = plan.clone();
|
||||
bad["size"] = json!(-1.0);
|
||||
assert!(s.execute("mesh.plan.apply", &json!({"plan": bad})).is_err());
|
||||
@@ -1379,3 +1493,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Acceptance of the projection layouts (#402 step 3): parts built the way enclosures are, each
|
||||
/// meshed all-hex from its plan alone, conforming, holes round.
|
||||
#[cfg(test)]
|
||||
mod acceptance {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_support::*;
|
||||
|
||||
#[test]
|
||||
fn a_plate_with_four_holes_has_round_o_grid_rings() {
|
||||
let (_d, mut s) = open(json!([
|
||||
{"command": "solid.box", "params": {"length": 80, "width": 60, "height": 8, "body_name": "Plate"}},
|
||||
{"command": "solid.hole", "params": {"position": [15, 15, 8], "diameter": 8, "depth": 8}},
|
||||
{"command": "solid.hole", "params": {"position": [65, 15, 8], "diameter": 8, "depth": 8}},
|
||||
{"command": "solid.hole", "params": {"position": [15, 45, 8], "diameter": 8, "depth": 8}},
|
||||
{"command": "solid.hole", "params": {"position": [65, 45, 8], "diameter": 8, "depth": 8}},
|
||||
]));
|
||||
let r = plan_and_apply(&mut s, "Plate");
|
||||
assert!(r["layout"].as_str().unwrap().contains("4 circle(s) ringed"), "{r}");
|
||||
accept(&mut s, &r, 0.5, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filleted_box_meshes_structured_round_its_corners() {
|
||||
let (_d, mut s) = open(json!([
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "body_name": "Box"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10], [40, 0, 10], [40, 30, 10], [0, 30, 10]], "radius": 4}},
|
||||
]));
|
||||
let r = plan_and_apply(&mut s, "Box");
|
||||
assert_eq!(r["blend_layers"].as_array().unwrap().len(), 4, "{r}");
|
||||
accept(&mut s, &r, 0.5, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_filleted_on_every_edge_takes_a_blend_layer_on_every_side() {
|
||||
let recipe: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/corpus/rounded-box/recipe.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let (_d, mut s) = open(recipe["commands"].clone());
|
||||
let r = plan_and_apply(&mut s, "Box");
|
||||
assert_eq!(r["blend_layers"].as_array().unwrap().len(), 6, "{r}");
|
||||
accept(&mut s, &r, 0.5, 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the plan tests share.
|
||||
#[cfg(test)]
|
||||
mod tests_support {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
pub(super) fn open(commands: Value) -> (crate::tests::TempDir, Session) {
|
||||
let bytes = fieldcraft_geometry::script_design(&json!({"commands": commands}).to_string()).unwrap();
|
||||
let (dir, path) = crate::tests::design_file(&bytes);
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path})).unwrap();
|
||||
(dir, s)
|
||||
}
|
||||
|
||||
/// Plan `body` (it must take the projection layout) and apply the plan.
|
||||
pub(super) fn plan_and_apply(s: &mut Session, body: &str) -> Value {
|
||||
let plan = s.execute("mesh.plan", &json!({"body": body})).unwrap();
|
||||
assert_eq!(plan["mesher"], "project", "{plan}");
|
||||
assert!(plan["unmapped"].as_array().is_none_or(Vec::is_empty), "{plan}");
|
||||
s.execute("mesh.plan.apply", &json!({"plan": plan["name"]})).unwrap()
|
||||
}
|
||||
|
||||
/// All hex, one conforming mesh on the geometry, the volume the body's, no element below
|
||||
/// `min_sj` or above `max_edge_ratio`, no free faces inside.
|
||||
pub(super) fn accept(s: &mut Session, r: &Value, min_sj: f64, max_edge_ratio: f64) {
|
||||
assert_eq!((r["all_hex"].as_bool(), r["conforming"].as_bool()), (Some(true), Some(true)), "{r}");
|
||||
let q = s.execute("mesh.quality", &json!({})).unwrap();
|
||||
let sj = q["metrics"]["scaled_jacobian"]["min"].as_f64().unwrap();
|
||||
let er = q["metrics"]["edge_ratio"]["max"].as_f64().unwrap();
|
||||
assert!(sj >= min_sj, "min scaled Jacobian {sj}: {q}");
|
||||
assert!(er <= max_edge_ratio, "max edge ratio {er}: {q}");
|
||||
assert!(r["mesh"]["max_gap"].as_f64().unwrap() < 0.05, "boundary nodes off the geometry: {r}");
|
||||
let bodies = s.execute("geometry.bodies", &json!({})).unwrap();
|
||||
let geometry: f64 = bodies.as_array().unwrap().iter().map(|b| b["volume"].as_f64().unwrap()).sum();
|
||||
let mesh = q["volume"].as_f64().unwrap();
|
||||
assert!((mesh - geometry).abs() < 0.01 * geometry, "mesh volume {mesh} vs {geometry}");
|
||||
let dup = s.execute("mesh.check.duplicates", &json!({})).unwrap();
|
||||
assert_eq!((dup["counts"]["coincident"].as_u64(), dup["counts"]["cracks"].as_u64()), (Some(0), Some(0)), "{dup}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user