Sweep: orientation, twist and end scale (#1269)
Part of #771 and #772. solid.sweep and surface.sweep take orientation (perpendicular, parallel or a direction), twist (up to 4 turns) and end scale (0.01 to 100), applied about the profile's normal through the path's start; default options run the old sweep, so existing sweeps are unchanged. New fields are optional, left out when unset and noted under format 21; twist and scale are model parameters. Both sweep dialogs get Orientation, Twist and End Scale rows; labels in every catalog. Checked on main + this branch: cargo check --workspace, kernel/engine/UI sweep tests, the doc suite and the robustness goldens.
This commit is contained in:
1 parent
0872eb959a
commit
5fa8e7d60a
30 files changed
+1120
-71
No files matched your search
@@ -108,6 +108,7 @@ pub enum Kind {
|
||||
/// Sweep a profile along a path of sketch curves.
|
||||
Sweep {
|
||||
operation: usize,
|
||||
opts: crate::dialogs_sweep::SweepOpts,
|
||||
},
|
||||
/// A construction plane offset from a plane.
|
||||
OffsetPlane {
|
||||
@@ -544,8 +545,8 @@ impl Dialog {
|
||||
),
|
||||
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||||
"solid.sweep" => Dialog::new(
|
||||
Kind::Sweep { operation: usize::from(has_bodies) },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
|
||||
Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()],
|
||||
),
|
||||
"solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)),
|
||||
"solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]),
|
||||
@@ -2537,7 +2538,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "lf_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Sweep { operation } => {
|
||||
Kind::Sweep { operation, opts } => {
|
||||
enter |= crate::dialogs_sweep::rows(ui, opts, "sw");
|
||||
row_label(ui, "Operation");
|
||||
combo(ui, "sw_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
@@ -3548,16 +3550,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
|
||||
("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
|
||||
}
|
||||
Kind::Sweep { operation } => {
|
||||
Kind::Sweep { operation, opts } => {
|
||||
need(0, "a profile")?;
|
||||
need(1, "the path")?;
|
||||
let (sketch, idx) = profiles();
|
||||
let path: Vec<String> = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||||
let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?;
|
||||
(
|
||||
"solid.sweep",
|
||||
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?;
|
||||
("solid.sweep", p)
|
||||
}
|
||||
Kind::OffsetPlane { offset } => {
|
||||
need(0, "a plane")?;
|
||||
@@ -4614,9 +4615,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => {
|
||||
FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => {
|
||||
let mut d = start("solid.sweep")?;
|
||||
d.kind = Kind::Sweep { operation: op_index(operation) };
|
||||
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component));
|
||||
d.kind = Kind::Sweep { operation: op_index(operation), opts };
|
||||
if let Some(inp) = d.inputs.get_mut(2) {
|
||||
inp.items = direction.into_iter().collect();
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.get_mut(0) {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
|
||||
@@ -25,7 +25,7 @@ pub enum Surf {
|
||||
symmetric: bool,
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Sweep,
|
||||
Sweep(crate::dialogs_sweep::SweepOpts),
|
||||
Loft,
|
||||
Patch,
|
||||
Unstitch,
|
||||
@@ -68,7 +68,7 @@ impl Surf {
|
||||
match self {
|
||||
Surf::Extrude { .. } => "SURFACE EXTRUDE",
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Sweep => "SURFACE SWEEP",
|
||||
Surf::Sweep(_) => "SURFACE SWEEP",
|
||||
Surf::Loft => "SURFACE LOFT",
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
@@ -110,7 +110,11 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
match k {
|
||||
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
|
||||
Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
|
||||
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)],
|
||||
Surf::Sweep(_) => vec![
|
||||
SelInput::new("Profiles or curves", PROFILES | CURVES, true),
|
||||
SelInput::new("Path", CURVES, true),
|
||||
crate::dialogs_sweep::direction_input(),
|
||||
],
|
||||
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
|
||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
|
||||
@@ -137,7 +141,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let k = match id {
|
||||
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
|
||||
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
|
||||
"surface.sweep" => Surf::Sweep,
|
||||
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
|
||||
"surface.loft" => Surf::Loft,
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
@@ -243,7 +247,8 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
ui.checkbox(in_place, "");
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
|
||||
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
|
||||
}
|
||||
enter
|
||||
}
|
||||
@@ -345,7 +350,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
}
|
||||
("surface.revolve", p)
|
||||
}
|
||||
Surf::Sweep => {
|
||||
Surf::Sweep(opts) => {
|
||||
need(0, "profiles or curves to sweep")?;
|
||||
need(1, "the path")?;
|
||||
let mut p = sheet_picks(app, items(inputs, 0))?;
|
||||
@@ -354,6 +359,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
p["path_sketch"] =
|
||||
json!(crate::dialogs::curves_sketch(app, &path, p["sketch"].as_u64()).ok_or("the path curves must be in one other sketch")?);
|
||||
p["path"] = json!(path);
|
||||
crate::dialogs_sweep::params(app, opts, items(inputs, 2).first(), &mut p)?;
|
||||
("surface.sweep", p)
|
||||
}
|
||||
Surf::Loft => {
|
||||
@@ -578,10 +584,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => (
|
||||
Surf::Sweep,
|
||||
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => {
|
||||
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame);
|
||||
(
|
||||
Surf::Sweep(opts),
|
||||
vec![
|
||||
sheet_sels(app, *sketch, profiles.as_ref(), curves),
|
||||
path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(),
|
||||
direction.into_iter().collect(),
|
||||
],
|
||||
)
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a
|
||||
//! twist over the path's length and the profile's scale at the end.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{Mat, SweepOrientation};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{combo, field, row_label};
|
||||
use crate::selection::{AXES, EDGES, SelInput};
|
||||
|
||||
/// The orientation choices, in combo order: perpendicular, parallel, a direction.
|
||||
const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"];
|
||||
/// The orientation choice that takes a picked direction.
|
||||
const DIRECTION: usize = 2;
|
||||
/// A twist field that means no twist.
|
||||
const NO_TWIST: &str = "0 deg";
|
||||
/// An end scale field that means no scaling.
|
||||
const NO_SCALE: &str = "1";
|
||||
|
||||
/// A sweep dialog's options.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepOpts {
|
||||
/// An index into [`ORIENTATIONS`].
|
||||
pub orientation: usize,
|
||||
pub twist: String,
|
||||
pub scale: String,
|
||||
/// A stored direction (world), used while no direction is picked.
|
||||
pub direction: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl Default for SweepOpts {
|
||||
fn default() -> Self {
|
||||
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl SweepOpts {
|
||||
/// A feature's stored options as dialog values; `frame` maps the feature's component frame to
|
||||
/// the world (a stored direction is in the component's frame). A direction along an origin
|
||||
/// axis comes back as a pick of that axis.
|
||||
pub fn of(orientation: &SweepOrientation, twist: &Option<String>, scale: &Option<String>, frame: &Mat) -> (SweepOpts, Option<Sel>) {
|
||||
let mut o = SweepOpts {
|
||||
twist: twist.clone().unwrap_or_else(|| NO_TWIST.into()),
|
||||
scale: scale.clone().unwrap_or_else(|| NO_SCALE.into()),
|
||||
..SweepOpts::default()
|
||||
};
|
||||
let mut pick = None;
|
||||
match orientation {
|
||||
SweepOrientation::Perpendicular => {}
|
||||
SweepOrientation::Parallel => o.orientation = 1,
|
||||
SweepOrientation::Direction(d) => {
|
||||
o.orientation = DIRECTION;
|
||||
let world = solidcraft_engine::doc::apply_vector(frame, *d);
|
||||
match world.normalized().and_then(world_axis) {
|
||||
Some(name) => pick = Some(Sel::Axis { name: name.into() }),
|
||||
None => o.direction = Some(world),
|
||||
}
|
||||
}
|
||||
}
|
||||
(o, pick)
|
||||
}
|
||||
}
|
||||
|
||||
/// The origin axis a direction runs along (its way), if any.
|
||||
fn world_axis(d: Vec3) -> Option<&'static str> {
|
||||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
/// The input that picks a Follow Direction sweep's direction: an axis, a sketch line or a
|
||||
/// straight edge.
|
||||
pub fn direction_input() -> SelInput {
|
||||
SelInput::new("Direction", AXES | EDGES, false)
|
||||
}
|
||||
|
||||
/// The option rows; true when Enter was pressed in a field.
|
||||
pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool {
|
||||
let mut enter = false;
|
||||
row_label(ui, "Orientation");
|
||||
combo(ui, &format!("{id}_orient"), &ORIENTATIONS, &mut o.orientation);
|
||||
ui.end_row();
|
||||
row_label(ui, "Twist");
|
||||
enter |= field(ui, &mut o.twist);
|
||||
ui.end_row();
|
||||
row_label(ui, "End Scale");
|
||||
enter |= field(ui, &mut o.scale);
|
||||
ui.end_row();
|
||||
enter
|
||||
}
|
||||
|
||||
/// The options as command parameters, added to `p`: the orientation, and the twist and end
|
||||
/// scale when they change anything. `picked` is the direction input's selection.
|
||||
pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> {
|
||||
match o.orientation {
|
||||
0 => {}
|
||||
1 => p["orientation"] = json!("parallel"),
|
||||
_ => {
|
||||
let d = match (picked, o.direction) {
|
||||
(Some(x), _) => crate::dialogs::axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
|
||||
(None, Some(d)) => d,
|
||||
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) for the profile to follow".into()),
|
||||
};
|
||||
p["orientation"] = json!([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
let twist = o.twist.trim();
|
||||
if !twist.is_empty() && twist != NO_TWIST {
|
||||
p["twist"] = json!(twist);
|
||||
}
|
||||
let scale = o.scale.trim();
|
||||
if !scale.is_empty() && scale != NO_SCALE {
|
||||
p["scale"] = json!(scale);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub mod dialogs_plastic;
|
||||
pub mod dialogs_save_mesh;
|
||||
pub mod dialogs_sheet;
|
||||
pub mod dialogs_surface;
|
||||
pub mod dialogs_sweep;
|
||||
pub mod dim_view;
|
||||
#[cfg(test)]
|
||||
mod dimension_create_ui_tests;
|
||||
@@ -141,6 +142,8 @@ pub mod smart_extrude;
|
||||
#[cfg(test)]
|
||||
mod smart_extrude_ui_tests;
|
||||
pub mod stand_up;
|
||||
#[cfg(test)]
|
||||
mod sweep_dialog_tests;
|
||||
pub mod theme;
|
||||
#[cfg(test)]
|
||||
mod thin_extrude_tests;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! The Sweep and Surface Sweep dialogs' options (#771): orientation, twist and end scale reach
|
||||
//! the commands, and editing a sweep keeps them.
|
||||
use crate::dialogs::Kind;
|
||||
use crate::dialogs_surface::Surf;
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{FeatureKind, SweepOrientation};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
/// A square `Q` on XY and a path `P` 40 up the Z axis; the square's sketch id.
|
||||
fn setup(h: &mut Harness) -> u64 {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Q"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[5,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XZ","name":"P"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[0,-40]],"ids":["up"]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.doc.find_feature("Q").unwrap().id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_sweep_takes_its_options_from_the_dialog() {
|
||||
let mut h = Harness::new();
|
||||
let sketch = setup(&mut h);
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Twist").is_some() && h.widget("End Scale").is_some() && h.widget("Orientation").is_some());
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
|
||||
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
|
||||
d.inputs[2].items = vec![Sel::Axis { name: "X".into() }];
|
||||
let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) };
|
||||
opts.orientation = 2;
|
||||
opts.twist = "90 deg".into();
|
||||
opts.scale = "0.5".into();
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
let f = h.app.session.doc.features.last().unwrap();
|
||||
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert_eq!(*orientation, SweepOrientation::Direction(Vec3::X));
|
||||
assert_eq!(twist.as_deref(), Some("90 deg"));
|
||||
assert_eq!(scale.as_deref(), Some("0.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_sweep_from_the_dialog_stores_no_options() {
|
||||
let mut h = Harness::new();
|
||||
let sketch = setup(&mut h);
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
|
||||
h.frames(3);
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
|
||||
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
let f = h.app.session.doc.features.last().unwrap();
|
||||
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_sweeps_keeps_their_options() {
|
||||
let mut h = Harness::new();
|
||||
setup(&mut h);
|
||||
h.app
|
||||
.run(
|
||||
"solid.sweep",
|
||||
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":[1,1,0],"twist":"30 deg","scale":"2","operation":"new","name":"Solid"}),
|
||||
)
|
||||
.unwrap();
|
||||
h.app
|
||||
.run(
|
||||
"surface.sweep",
|
||||
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":"parallel","twist":"45 deg","scale":"0.5","name":"Sheet"}),
|
||||
)
|
||||
.unwrap();
|
||||
h.frames(4);
|
||||
for name in ["Solid", "Sheet"] {
|
||||
let before = h.app.session.doc.find_feature(name).unwrap().clone();
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
|
||||
h.frames(8);
|
||||
match &h.app.dialog.as_ref().unwrap().kind {
|
||||
Kind::Sweep { opts, .. } | Kind::Surface(Surf::Sweep(opts)) => assert_eq!(opts.twist, if name == "Solid" { "30 deg" } else { "45 deg" }),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
// The options come back as they were (the profile pick is stored by its curves).
|
||||
let options = |k: &FeatureKind| match k {
|
||||
FeatureKind::Sweep { orientation, twist, scale, .. } | FeatureKind::SurfaceSweep { orientation, twist, scale, .. } => {
|
||||
(*orientation, twist.clone(), scale.clone())
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let after = h.app.session.doc.find_feature(name).unwrap();
|
||||
assert_eq!(options(&after.kind), options(&before.kind), "{name}");
|
||||
assert_eq!(after.param_names, before.param_names, "{name}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user