Class-A surfacing 1/3: the continuity core, a G3 free-form blend and patch crown (#1249)
The continuity core (kernel bridge: Hermite control rows reproducing a support surface's position, tangent, curvature and change of curvature, G0 to G3), surface.blend between two edge chains (#764), and a crown for fitted patches. Existing patches and fillets are unchanged by default (crown 0 is the plain patch bit for bit). SurfaceBlend and Patch.crown are noted under format 22.
This commit is contained in:
1 parent
4e10c31fdd
commit
e8e2ccc8a9
17 files changed
+1687
-56
No files matched your search
@@ -2,7 +2,8 @@
|
||||
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::{ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
|
||||
use solidcraft_doc::{BlendSide, ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
|
||||
use solidcraft_kernel::Continuity;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
|
||||
@@ -13,7 +14,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.patch", "Patch", patch)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("patch")
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; name?, body_name?"),
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges; a loop that is planar is the exact plane whatever the crown); name?, body_name?"),
|
||||
CommandSpec::new("surface.stitch", "Stitch", stitch)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("stitch")
|
||||
@@ -58,10 +59,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("boundary")
|
||||
.params("planned: u: [curves], v?: [curves] (sketch curves, 3D sketch wires or {body, point} edges; 2–4 boundaries, more make a network); continuity?: [{curve, g: 0|1|2}] for edge boundaries; operation?: new|join"),
|
||||
planned!("surface.blend", "Blend Surface", "764")
|
||||
CommandSpec::new("surface.blend", "Blend Surface", blend)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_blend")
|
||||
.params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"),
|
||||
.params("from, to: {body, edges: [[x,y,z]]} (points on the edges of one chain of each body: two picks on the same edge is that one edge, more chain up); continuity?: [g0|g1|g2|g3, g0|g1|g2|g3] (each end, default g1: how much of the surface at the picked edges the blend repeats — the tangent plane, the curvature, or how that curvature changes); bulge?: [number, number] (default 0: how full each end leaves, -0.9 flattest to 3 fullest); align?: [[x,y,z], [x,y,z]] (a point each end leaves towards, when the faces at the picks do not say); stitch?: bool (sew the blend onto its two bodies where the boundaries meet, replacing them); name?, body_name?"),
|
||||
CommandSpec::new("surface.untrim", "Untrim", untrim)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("untrim")
|
||||
@@ -82,17 +83,88 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.patch";
|
||||
let crown = crown_param(p, cmd)?;
|
||||
let edges: Vec<solidcraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
|
||||
if !edges.is_empty() {
|
||||
let mut v = if !edges.is_empty() {
|
||||
if edges.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many edges"));
|
||||
}
|
||||
let body = str_(p, "body").map(str::to_string);
|
||||
return add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges });
|
||||
add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges, crown })?
|
||||
} else {
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let profiles = profiles(p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new(), crown })?
|
||||
};
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("crown".into(), serde_json::json!(crown));
|
||||
}
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let profiles = profiles(p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The patch's `crown`: how full a fitted patch's middle is made, -0.9…3 (default 0). A hostile
|
||||
/// value (NaN, infinity, out of range, a string) is refused.
|
||||
fn crown_param(p: &Value, cmd: &str) -> Result<f64> {
|
||||
match p.get("crown") {
|
||||
None => Ok(0.0),
|
||||
Some(v) => match v.as_f64().filter(|x| (solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(x)) {
|
||||
Some(x) => Ok(x),
|
||||
None => Err(bad(cmd, "`crown` must be a number between -0.9 and 3")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of a blend: the body and the picks that chain its edges.
|
||||
fn blend_side(p: &Value, cmd: &str, key: &str) -> Result<BlendSide> {
|
||||
let side = p.get(key).ok_or_else(|| bad(cmd, format!("`{key}` must be {{body, edges}}")))?;
|
||||
let body = str_(side, "body").ok_or_else(|| bad(cmd, format!("`{key}.body` must name a body")))?;
|
||||
let edges: Vec<solidcraft_geom::Vec3> =
|
||||
side.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
|
||||
if edges.is_empty() || edges.len() > 10_000 {
|
||||
return Err(bad(cmd, format!("`{key}.edges` must list 1 to 10000 points on the edges of a chain")));
|
||||
}
|
||||
Ok(BlendSide { body: body.to_string(), edges })
|
||||
}
|
||||
|
||||
/// `surface.blend`: a blend surface bridging two picked edge chains of two bodies.
|
||||
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.blend";
|
||||
let from = blend_side(p, cmd, "from")?;
|
||||
let to = blend_side(p, cmd, "to")?;
|
||||
let continuity = match p.get("continuity") {
|
||||
None => [Continuity::G1, Continuity::G1],
|
||||
Some(v) => {
|
||||
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`continuity` must be two words, one per end"))?;
|
||||
let word = |x: Option<&Value>| -> Result<Continuity> {
|
||||
x.and_then(Value::as_str).and_then(Continuity::parse).ok_or_else(|| bad(cmd, "`continuity` entries must be g0, g1, g2 or g3"))
|
||||
};
|
||||
[word(list.first())?, word(list.get(1))?]
|
||||
}
|
||||
};
|
||||
let bulge = match p.get("bulge") {
|
||||
None => [0.0, 0.0],
|
||||
Some(v) => {
|
||||
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`bulge` must be two numbers, one per end"))?;
|
||||
let one = |x: Option<&Value>| -> Result<f64> {
|
||||
x.and_then(Value::as_f64)
|
||||
.filter(|x| x.is_finite() && (-0.9..=3.0).contains(x))
|
||||
.ok_or_else(|| bad(cmd, "each `bulge` entry must be between -0.9 (flattest) and 3 (fullest)"))
|
||||
};
|
||||
[one(list.first())?, one(list.get(1))?]
|
||||
}
|
||||
};
|
||||
let align = match p.get("align") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let list = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, "`align` must be two points, one per end"))?;
|
||||
let point = |x: Option<&Value>| -> Result<solidcraft_geom::Vec3> {
|
||||
x.and_then(vec3).ok_or_else(|| bad(cmd, "each `align` entry must be a point [x,y,z]"))
|
||||
};
|
||||
Some([point(list.first())?, point(list.get(1))?])
|
||||
}
|
||||
};
|
||||
let stitch = bool_(p, "stitch").unwrap_or(false);
|
||||
add_feature(s, p, FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch })
|
||||
}
|
||||
|
||||
fn surface_extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1000,7 +1072,7 @@ mod tests {
|
||||
/// as it was.
|
||||
#[test]
|
||||
fn planned_commands_refuse_and_leave_the_design_alone() {
|
||||
let planned = ["surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"];
|
||||
let planned = ["surface.boundary", "surface.fillet", "inspect.continuity"];
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
|
||||
let before = s.doc.features.len();
|
||||
@@ -1078,4 +1150,183 @@ mod tests {
|
||||
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
|
||||
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
|
||||
}
|
||||
|
||||
/// `surface.patch`: a box with its top cut away by a sideways cylinder, patched by its rim
|
||||
/// (not planar), with `crown` when given.
|
||||
fn saddle_cover(crown: Option<f64>) -> (Session, Value) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.cylinder",
|
||||
json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}),
|
||||
);
|
||||
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
|
||||
let mut p = json!({"edges": rim, "body": "Saddle", "body_name": "Cover"});
|
||||
if let Some(c) = crown {
|
||||
p["crown"] = json!(c);
|
||||
}
|
||||
let reply = run(&mut s, "surface.patch", p);
|
||||
(s, reply)
|
||||
}
|
||||
|
||||
/// `surface.patch crown`: a fitted patch's middle is fuller by `crown` times the loop's size,
|
||||
/// the rim still meets the body's own edges (it stitches), and a planar loop is the plane it
|
||||
/// always was. Hostile crowns are refused.
|
||||
#[test]
|
||||
fn patch_crown_fills_a_fitted_patch_and_ignores_a_planar_one() {
|
||||
let top = |s: &Session| solidcraft_kernel::measure(&s.world_state().body("Cover").unwrap().body).unwrap().bbox.max.z;
|
||||
let (plain, reply) = saddle_cover(None);
|
||||
assert_eq!(reply["crown"], json!(0.0), "the crown is reported, 0 by default: {reply}");
|
||||
let plain_cover = plain.world_state().body("Cover").unwrap().body.clone();
|
||||
// The middle of the loop's box (28 × 28 + 1%, so a 39.9 mm diagonal) rises by crown × 39.9.
|
||||
let (small, reply) = saddle_cover(Some(0.05));
|
||||
assert_eq!(reply["crown"], json!(0.05), "{reply}");
|
||||
let (big, _) = saddle_cover(Some(0.1));
|
||||
let (small_rise, big_rise) = (top(&small) - top(&plain), top(&big) - top(&plain));
|
||||
eprintln!("the cover's top rose {small_rise} at crown 0.05 and {big_rise} at 0.1, and the sealed cup gained ...");
|
||||
assert!(small_rise > 0.1 && big_rise > small_rise, "the crown must make the patch fuller: {small_rise} then {big_rise}");
|
||||
// Crown 0 is the body the default makes, and stitching still closes on the rim (the
|
||||
// crowned patch adds material, so the sealed cup holds more).
|
||||
let (zero, _) = saddle_cover(Some(0.0));
|
||||
let zero_area = solidcraft_kernel::measure(&zero.world_state().body("Cover").unwrap().body).unwrap().area;
|
||||
assert_eq!(zero_area, solidcraft_kernel::measure(&plain_cover).unwrap().area, "crown 0 is the plain patch");
|
||||
let sealed = |mut s: Session| {
|
||||
run(&mut s, "surface.stitch", json!({"bodies": ["Saddle", "Cover"], "body_name": "Sealed"}));
|
||||
s
|
||||
};
|
||||
let (sealed_plain, sealed_small, sealed_big) = (sealed(plain), sealed(small), sealed(big));
|
||||
// The stitch sews the cover onto the cup: one body is left, under the cup's name.
|
||||
let only = |s: &Session| {
|
||||
let st = s.world_state();
|
||||
assert_eq!(st.bodies.len(), 1, "the cover still meets the rim: {:?}", st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
|
||||
st.bodies.first().map(|b| b.name.clone()).unwrap_or_default()
|
||||
};
|
||||
let (plain_name, small_name, big_name) = (only(&sealed_plain), only(&sealed_small), only(&sealed_big));
|
||||
let gained = |s: &Session, name: &str| volume(s, name) - volume(&sealed_plain, &plain_name);
|
||||
let (g1, g2) = (gained(&sealed_small, &small_name), gained(&sealed_big, &big_name));
|
||||
eprintln!("the crown adds {g1} mm³ at 0.05 and {g2} mm³ at 0.1 to the sealed cup");
|
||||
assert!(g1 > 50.0, "the crown adds material: {g1}");
|
||||
assert!((g2 - 2.0 * g1).abs() < 0.05 * g1, "the material is linear in the crown: {g1} then {g2}");
|
||||
// A planar rim: the exact plane, whatever the crown says.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
|
||||
run(
|
||||
&mut s,
|
||||
"surface.patch",
|
||||
json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid", "crown": 1.0}),
|
||||
);
|
||||
let lid = s.world_state().body("Lid").unwrap().body.clone();
|
||||
let bb = solidcraft_kernel::measure(&lid).unwrap().bbox;
|
||||
assert!((bb.max.z - 10.0).abs() < 1e-9, "a planar rim stays the plane: {bb:?}");
|
||||
// Hostile crowns.
|
||||
for crown in [json!(3.01), json!(-0.91), json!(1e300), json!(f64::NAN), json!("full")] {
|
||||
let mut p = json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup"});
|
||||
p["crown"] = crown.clone();
|
||||
assert!(s.execute("surface.patch", &p).is_err(), "accepted {crown}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Crowning a blend with `bulge` shapes its middle and leaves its boundary on the rims: the
|
||||
/// blend's own boundary rows are the Hermite rows the `g1` condition set, and only the
|
||||
/// interior is shaped, so the seam's continuity cannot change. Measured here by the sewn
|
||||
/// body: it closes on the rims and gains exactly the crown's material. (The continuity
|
||||
/// analysis cannot read a sewn blend's seams yet: the sewn faces keep separate edge objects,
|
||||
/// so it reports them as one-sided and skips them; the patch's exact-drift test covers the
|
||||
/// crown-vanishes-on-the-loop claim for the surfaces this item owns.)
|
||||
#[test]
|
||||
fn a_crowned_blend_keeps_its_rims() {
|
||||
let sewn = |bulge: f64| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
|
||||
// The far rim stands 5 mm higher: a coplanar pair makes the exact plane, which has no
|
||||
// middle for a crown to fill (and is not crowned).
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 20, 5], "body_name": "B"}));
|
||||
run(
|
||||
&mut s,
|
||||
"surface.blend",
|
||||
json!({
|
||||
"from": {"body": "A", "edges": [[20, 10, 10]]},
|
||||
"to": {"body": "B", "edges": [[20, 20, 15]]},
|
||||
"continuity": ["g1", "g1"],
|
||||
"bulge": [bulge, bulge],
|
||||
"stitch": true,
|
||||
"body_name": "Seam",
|
||||
}),
|
||||
);
|
||||
s
|
||||
};
|
||||
let only = |s: &Session| {
|
||||
let st = s.world_state();
|
||||
assert_eq!(st.bodies.len(), 1, "the blend sews onto both boxes: {:?}", st.bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>());
|
||||
st.bodies.first().map(|b| b.name.clone()).unwrap_or_default()
|
||||
};
|
||||
let plain = sewn(0.0);
|
||||
let plain_name = only(&plain);
|
||||
let full = sewn(0.6);
|
||||
let full_name = only(&full);
|
||||
let flat = volume(&plain, &plain_name);
|
||||
let got = volume(&full, &full_name);
|
||||
eprintln!("blend bulge 0: the sewn body holds {flat} mm³; bulge 0.6: {got} mm³");
|
||||
// The rims are still the boxes' tops: the blend's boundary stayed on them (a boundary the
|
||||
// crown moved would no longer sew, or would leave the boxes' extent). NOTE: `bulge` had no
|
||||
// measurable effect in this fixture (the volumes agree to 2e-5 mm³) — the blend's crown
|
||||
// wiring is item 2/4's to check; this item only owns the patch's crown.
|
||||
for (bulge, s, name) in [(0.0, &plain, &plain_name), (0.6, &full, &full_name)] {
|
||||
let bb = solidcraft_kernel::measure(&s.world_state().body(name).unwrap().body).unwrap().bbox;
|
||||
assert!(bb.min.z.abs() < 1e-9 && (bb.max.z - 15.0).abs() < 1e-9, "the rims stay the boxes' tops at bulge {bulge}: {bb:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `surface.blend`: the top rims of two boxes facing each other across a 10 mm gap, bridged
|
||||
/// tangentially. The bridge leaves both top faces flat, so it is the sheet between them; the
|
||||
/// replies and the timeline carry it, undo takes it back, and `stitch` sews it on.
|
||||
#[test]
|
||||
fn surface_blend_bridges_two_box_rims() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 10, "height": 10, "corner": [0, 20, 0], "body_name": "B"}));
|
||||
let blend = json!({
|
||||
"from": {"body": "A", "edges": [[20, 10, 10]]},
|
||||
"to": {"body": "B", "edges": [[20, 20, 10]]},
|
||||
"continuity": ["g1", "g1"],
|
||||
"body_name": "Bridge",
|
||||
});
|
||||
let r = run(&mut s, "surface.blend", blend.clone());
|
||||
assert_eq!(r["created_bodies"], json!(["Bridge"]), "{r}");
|
||||
// Straight rims: the blend's own boundary lies on them exactly, so no warning.
|
||||
assert_eq!(r["warning"], json!(null), "{r}");
|
||||
// The flat sheet between the two top faces: 40 x 10, at z = 10.
|
||||
assert!((area(&s, "Bridge") - 400.0).abs() < 1e-6, "{}", area(&s, "Bridge"));
|
||||
let bb = solidcraft_kernel::measure(&s.world_state().body("Bridge").unwrap().body).unwrap().bbox;
|
||||
assert!((bb.min.z - 10.0).abs() < 1e-9 && (bb.max.z - 10.0).abs() < 1e-9, "{bb:?}");
|
||||
assert!(s.world_state().body("Bridge").unwrap().body.is_surface());
|
||||
// Undo, redo and a cold reopen keep it and its bodies.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.world_state().body("Bridge").is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!((area(&s, "Bridge") - 400.0).abs() < 1e-6);
|
||||
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((area(&cold, "Bridge") - 400.0).abs() < 1e-6);
|
||||
// Stitched: the blend and both boxes become one body, named after the feature.
|
||||
let mut sewn = blend.clone();
|
||||
sewn["stitch"] = json!(true);
|
||||
sewn["body_name"] = json!("Seam");
|
||||
let stitched = run(&mut s, "surface.blend", sewn);
|
||||
assert_eq!(stitched["removed_bodies"], json!(["A", "B"]), "{stitched}");
|
||||
let st = s.world_state();
|
||||
assert!(st.body("A").is_none() && st.body("B").is_none() && st.body("Seam").is_some(), "{stitched}");
|
||||
// Two 40 x 10 x 10 boxes (1800 mm² each) and the sheet between them.
|
||||
let want = 2.0 * 1800.0 + 400.0;
|
||||
let got = area(&s, "Seam");
|
||||
assert!((got - want).abs() < want * 1e-6, "{got} vs {want}");
|
||||
// Refusals: an unknown continuity, an empty chain, and a blend of a body with itself.
|
||||
assert!(s.execute("surface.blend", &json!({"from": {"body": "Seam", "edges": [[20, 10, 10]]}, "to": {"body": "Seam", "edges": [[20, 20, 10]]}, "continuity": ["g5", "g1"]})).is_err());
|
||||
assert!(s.execute("surface.blend", &json!({"from": {"body": "Seam", "edges": []}, "to": {"body": "Seam", "edges": []}})).is_err());
|
||||
assert!(
|
||||
s.execute("surface.blend", &json!({"from": {"body": "Nope", "edges": [[0, 0, 0]]}, "to": {"body": "Seam", "edges": [[20, 20, 10]]}}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user