forked from 1b-agent/opencadstudio-upstream
Add kernel solid operations and complete Solid3D (create and rigid move)
An additive HostApi::solid_operation builds cadkernel primitives and applies rigid transforms, verifying each result lifts back losslessly and refusing a payload it cannot lift. Exposed as doc.solids in Python; Solid3D, Body and Region are mapped read-only with the opaque payload preserved. The lifecycle test passes through the real runner with analytic volumes and exact extents as oracles, DWG and DXF reopen with re-edit, and undo restores exactly. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
07fe739571
commit
04cd9e462d
18 files changed
+717
-13
No files matched your search
@@ -530,6 +530,151 @@ impl<'a> HostSession<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kernel-backed solid create or transform. Every result is verified by
|
||||
/// `solid_to_sat` (the exported payload must lift back with no loss) and an
|
||||
/// existing payload is only transformed when it lifts losslessly, so a
|
||||
/// failure never changes the document.
|
||||
pub fn solid_operation(
|
||||
&mut self,
|
||||
operation: ocs_plugin_api::host::SolidOperation,
|
||||
) -> Result<Handle, String> {
|
||||
use crate::scene::model::solid_model as model;
|
||||
use ocs_plugin_api::host::{SolidOperation, SolidPrimitive};
|
||||
let positive = |name: &str, value: f64| {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{name} must be finite and greater than zero"))
|
||||
}
|
||||
};
|
||||
let finite3 = |name: &str, value: [f64; 3]| {
|
||||
if value.iter().all(|v| v.is_finite()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{name} must be finite"))
|
||||
}
|
||||
};
|
||||
match operation {
|
||||
SolidOperation::Create { primitive, layer } => {
|
||||
let body = match primitive {
|
||||
SolidPrimitive::Box { center, size } => {
|
||||
finite3("box center", center)?;
|
||||
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
|
||||
positive(&format!("box {name}"), v)?;
|
||||
}
|
||||
model::box_solid(center, size[0], size[1], size[2])
|
||||
}
|
||||
SolidPrimitive::Wedge { origin, size } => {
|
||||
finite3("wedge origin", origin)?;
|
||||
for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] {
|
||||
positive(&format!("wedge {name}"), v)?;
|
||||
}
|
||||
model::wedge_solid(origin, size[0], size[1], size[2])
|
||||
}
|
||||
SolidPrimitive::Cylinder { center, radius, height } => {
|
||||
finite3("cylinder center", center)?;
|
||||
positive("cylinder radius", radius)?;
|
||||
positive("cylinder height", height)?;
|
||||
model::cylinder_solid(center, radius, height)
|
||||
}
|
||||
SolidPrimitive::Sphere { center, radius } => {
|
||||
finite3("sphere center", center)?;
|
||||
positive("sphere radius", radius)?;
|
||||
model::sphere_solid(center, radius)
|
||||
}
|
||||
SolidPrimitive::Torus { center, major, minor } => {
|
||||
finite3("torus center", center)?;
|
||||
positive("torus major radius", major)?;
|
||||
positive("torus minor radius", minor)?;
|
||||
if minor >= major {
|
||||
return Err("torus minor radius must be smaller than the major radius".into());
|
||||
}
|
||||
model::torus_solid(center, major, minor)
|
||||
}
|
||||
SolidPrimitive::Pyramid { center, radius, height, sides } => {
|
||||
finite3("pyramid center", center)?;
|
||||
positive("pyramid radius", radius)?;
|
||||
positive("pyramid height", height)?;
|
||||
if !(3..=1024).contains(&sides) {
|
||||
return Err("pyramid sides must be between 3 and 1024".into());
|
||||
}
|
||||
model::pyramid_solid(center, radius, height, sides as usize)
|
||||
}
|
||||
}
|
||||
.ok_or("the geometry kernel refused these dimensions")?;
|
||||
let sat = crate::scene::convert::acis_export::solid_to_sat(&body)
|
||||
.ok_or("the solid could not be exported losslessly")?;
|
||||
let mut solid = acadrust::entities::Solid3D::new();
|
||||
solid.wires = model::edge_wires(&body);
|
||||
solid.set_sat_document(&sat);
|
||||
if let Some(layer) = layer {
|
||||
if layer.trim().is_empty() {
|
||||
return Err("layer name is empty".into());
|
||||
}
|
||||
solid.common.layer = layer;
|
||||
}
|
||||
self.push_undo("Create solid");
|
||||
let handle = self.add_entity(EntityType::Solid3D(solid));
|
||||
if handle.is_null() {
|
||||
return Err("the solid could not be added".into());
|
||||
}
|
||||
Ok(handle)
|
||||
}
|
||||
SolidOperation::Transform { handle, matrix } => {
|
||||
if matrix.iter().any(|v| !v.is_finite()) {
|
||||
return Err("transform matrix must be finite".into());
|
||||
}
|
||||
let column = |i: usize| [matrix[i], matrix[i + 1], matrix[i + 2]];
|
||||
let (x, y, z) = (column(0), column(4), column(8));
|
||||
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
let rigid = [x, y, z].iter().all(|c| (dot(*c, *c) - 1.0).abs() < 1e-6)
|
||||
&& dot(x, y).abs() < 1e-6
|
||||
&& dot(x, z).abs() < 1e-6
|
||||
&& dot(y, z).abs() < 1e-6
|
||||
&& matrix[3].abs() < 1e-9
|
||||
&& matrix[7].abs() < 1e-9
|
||||
&& matrix[11].abs() < 1e-9
|
||||
&& (matrix[15] - 1.0).abs() < 1e-9;
|
||||
if !rigid {
|
||||
return Err("only rigid moves and mirrors are supported; scaling and shear are not".into());
|
||||
}
|
||||
let mut entity = self
|
||||
.document()
|
||||
.get_entity(handle)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("entity {handle:?} does not exist"))?;
|
||||
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
|
||||
return Err(format!("entity {handle:?} is on a locked layer"));
|
||||
}
|
||||
macro_rules! retarget {
|
||||
($value:expr) => {{
|
||||
let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&$value.acis_data)
|
||||
.ok_or("this body's payload cannot be lifted losslessly, so it is left untouched")?;
|
||||
let moved = model::by_matrix(&body, matrix)
|
||||
.ok_or("the geometry kernel refused this transform")?;
|
||||
let sat = crate::scene::convert::acis_export::solid_to_sat(&moved)
|
||||
.ok_or("the moved body could not be exported losslessly")?;
|
||||
$value.wires = model::edge_wires(&moved);
|
||||
$value.silhouettes.clear();
|
||||
$value.history_handle = None;
|
||||
$value.set_sat_document(&sat);
|
||||
}};
|
||||
}
|
||||
match &mut entity {
|
||||
EntityType::Solid3D(value) => retarget!(value),
|
||||
EntityType::Body(value) => retarget!(value),
|
||||
EntityType::Region(value) => retarget!(value),
|
||||
_ => return Err("transform applies to Solid3D, Body and Region entities".into()),
|
||||
}
|
||||
self.push_undo("Transform solid");
|
||||
if !self.update_entity(entity) {
|
||||
return Err(format!("entity {handle:?} could not be updated"));
|
||||
}
|
||||
Ok(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_undo(&mut self, label: &str) {
|
||||
self.app.push_undo_snapshot(self.tab, label);
|
||||
}
|
||||
@@ -622,6 +767,12 @@ impl HostApi for HostSession<'_> {
|
||||
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
||||
self.set_selection(handles)
|
||||
}
|
||||
fn solid_operation(
|
||||
&mut self,
|
||||
operation: ocs_plugin_api::host::SolidOperation,
|
||||
) -> Result<Handle, String> {
|
||||
self.solid_operation(operation)
|
||||
}
|
||||
fn remove_entity(&mut self, handle: Handle) -> bool {
|
||||
self.remove_entity(handle)
|
||||
}
|
||||
@@ -4558,6 +4709,199 @@ mod tests {
|
||||
time("volume(union)", &mut || format!("{:.1}", sm::volume(&result)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_python_solid3d_lifecycle_over_real_ipc() {
|
||||
use crate::scene::convert::solid3d_tess::kernel_body;
|
||||
use crate::scene::model::solid_model as sm;
|
||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
||||
return;
|
||||
};
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let process = ocs_plugin_api::process::PluginProcess::spawn(
|
||||
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
|
||||
).unwrap();
|
||||
let dispatch = |host: &mut HostSession<'_>, command: &str| {
|
||||
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
|
||||
};
|
||||
let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone();
|
||||
let solids = |host: &HostSession<'_>| -> Vec<Handle> {
|
||||
let mut list: Vec<_> = host.document().entities().filter_map(|e| match e {
|
||||
EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect();
|
||||
list.sort_by_key(|h| h.value());
|
||||
list
|
||||
};
|
||||
let body_of = |host: &HostSession<'_>, handle: Handle| {
|
||||
let Some(EntityType::Solid3D(solid)) = host.document().get_entity(handle) else { panic!("no solid {handle:?}") };
|
||||
kernel_body(solid).expect("payload lifts losslessly")
|
||||
};
|
||||
let volume = |host: &HostSession<'_>, handle: Handle| sm::volume(&body_of(host, handle));
|
||||
let extent = |host: &HostSession<'_>, handle: Handle| sm::extent(&body_of(host, handle)).unwrap();
|
||||
let close = |a: f64, b: f64, tolerance: f64| (a - b).abs() <= tolerance * b.abs().max(1.0);
|
||||
|
||||
// C: every primitive, checked against its analytic volume.
|
||||
let script = std::env::temp_dir().join(format!("ocs_solid_create_{}.py", std::process::id()));
|
||||
std::fs::write(&script, concat!(
|
||||
"s = ocs.active_document.solids\n",
|
||||
"s.box(center=(0, 0, 0), size=(10, 6, 4))\n",
|
||||
"s.cylinder(center=(20, 0, 0), radius=2, height=5)\n",
|
||||
"s.sphere(center=(40, 0, 0), radius=3)\n",
|
||||
"s.torus(center=(60, 0, 0), major=5, minor=1)\n",
|
||||
"s.wedge(origin=(80, 0, 0), size=(4, 3, 2))\n",
|
||||
"s.pyramid(center=(100, 0, 0), radius=3, height=4, sides=5)\n",
|
||||
"s.box(center=(0, 20, 0), size=(2, 2, 2), layer='SOLIDS')\n",
|
||||
)).unwrap();
|
||||
dispatch(&mut host, &format!("PY_RUN {}", script.display()));
|
||||
let _ = std::fs::remove_file(&script);
|
||||
let list = solids(&host);
|
||||
assert_eq!(list.len(), 7, "{}", last(&host));
|
||||
let pi = std::f64::consts::PI;
|
||||
let five_gon = 0.5 * 5.0 * 3.0f64.powi(2) * (2.0 * pi / 5.0).sin();
|
||||
for (index, expected, tolerance) in [
|
||||
(0, 240.0, 1e-9), (1, pi * 4.0 * 5.0, 0.02), (2, 4.0 / 3.0 * pi * 27.0, 0.02),
|
||||
(3, 2.0 * pi * pi * 5.0 * 1.0, 0.02), (4, 12.0, 1e-9), (5, five_gon * 4.0 / 3.0, 0.02), (6, 8.0, 1e-9),
|
||||
] {
|
||||
let actual = volume(&host, list[index]);
|
||||
assert!(close(actual, expected, tolerance), "solid {index}: volume {actual}, expected {expected}");
|
||||
}
|
||||
let Some(EntityType::Solid3D(first)) = host.document().get_entity(list[0]) else { unreachable!() };
|
||||
assert_eq!((first.wires.len(), first.common.layer.as_str()), (12, "0"));
|
||||
assert!(matches!(host.document().get_entity(list[6]), Some(EntityType::Solid3D(s)) if s.common.layer == "SOLIDS"));
|
||||
assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry");
|
||||
let read = |host: &mut HostSession<'_>, expression: &str| {
|
||||
dispatch(host, &format!("PY_EVAL {expression}"));
|
||||
last(host)
|
||||
};
|
||||
assert!(read(&mut host, &format!("ocs.active_document.entities[{}].kind", list[0].value())).contains("Solid3D"));
|
||||
|
||||
// E: rigid moves keep the volume and move the extent exactly.
|
||||
let box_handle = list[0];
|
||||
let before = host.document().get_entity(box_handle).unwrap().clone();
|
||||
let undo_before = host.app.tabs[0].history.undo_stack.len();
|
||||
let (low, high) = extent(&host, box_handle);
|
||||
assert!(close(low[0], -5.0, 1e-6) && close(high[2], 2.0, 1e-6), "{low:?} {high:?}");
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (5, 5, 5)).handle", box_handle.value()));
|
||||
let (low, high) = extent(&host, box_handle);
|
||||
assert!(close(low[0], 0.0, 1e-6) && close(high[1], 8.0, 1e-6) && close(high[2], 7.0, 1e-6), "{low:?} {high:?}");
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.rotate({}, 'z', 0.7853981633974483, (5, 5, 5)).handle", box_handle.value()));
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.mirror({}, 'x', (0, 0, 0)).handle", box_handle.value()));
|
||||
assert!(close(volume(&host, box_handle), 240.0, 1e-9), "moves preserve volume");
|
||||
let Some(EntityType::Solid3D(moved)) = host.document().get_entity(box_handle) else { unreachable!() };
|
||||
assert_eq!((moved.wires.len(), moved.history_handle, moved.common.handle), (12, None, box_handle));
|
||||
assert_eq!(moved.common.layer, "0");
|
||||
assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 3, "one undo step per move");
|
||||
|
||||
// V: refused operations change nothing and leave no undo step.
|
||||
let snapshot = host.document().get_entity(box_handle).unwrap().clone();
|
||||
let count_before = solids(&host).len();
|
||||
let undo_now = host.app.tabs[0].history.undo_stack.len();
|
||||
let empty_payload = acadrust::entities::Solid3D::new();
|
||||
let unliftable = host.add_entity(EntityType::Solid3D(empty_payload.clone()));
|
||||
let unliftable_before = host.document().get_entity(unliftable).unwrap().clone();
|
||||
for (command, message) in [
|
||||
("ocs.active_document.solids.box(size=(0, 1, 1))".to_string(), "greater than zero"),
|
||||
("ocs.active_document.solids.box(center=(float('nan'), 0, 0))".to_string(), "must be finite"),
|
||||
("ocs.active_document.solids.torus(major=1, minor=2)".to_string(), "smaller than the major"),
|
||||
("ocs.active_document.solids.pyramid(sides=2)".to_string(), "between 3 and 1024"),
|
||||
("ocs.active_document.solids.sphere(radius=-1)".to_string(), "greater than zero"),
|
||||
("ocs.active_document.solids.box(layer='')".to_string(), "layer name is empty"),
|
||||
("ocs.solid_create('blob', [1.0], None)".to_string(), "unknown primitive"),
|
||||
("ocs.solid_create('box', [1.0], None)".to_string(), "takes 6 numbers"),
|
||||
(format!("ocs.active_document.solids.transform({}, [2,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])", box_handle.value()), "rigid moves"),
|
||||
(format!("ocs.active_document.solids.transform({}, [1]*16)", box_handle.value()), "rigid moves"),
|
||||
(format!("ocs.active_document.solids.translate({}, (float('nan'), 0, 0))", box_handle.value()), "must be finite"),
|
||||
(format!("ocs.active_document.solids.translate({}, (1, 0, 0))", unliftable.value()), "cannot be lifted losslessly"),
|
||||
("ocs.active_document.solids.translate(999999, (1, 0, 0))".to_string(), "does not exist"),
|
||||
] {
|
||||
dispatch(&mut host, &format!("PY_EVAL {command}"));
|
||||
assert!(last(&host).contains(message), "{command}: {}", last(&host));
|
||||
}
|
||||
assert_eq!(host.document().get_entity(box_handle), Some(&snapshot));
|
||||
assert_eq!(host.document().get_entity(unliftable), Some(&unliftable_before));
|
||||
assert_eq!(solids(&host).len(), count_before + 1, "only the fixture was added");
|
||||
assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step");
|
||||
// Transforming a non-solid is refused.
|
||||
let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0))));
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (1, 0, 0))", line.value()));
|
||||
assert!(last(&host).contains("outside the Python schema") || last(&host).contains("applies to Solid3D"), "{}", last(&host));
|
||||
|
||||
// W: both formats reopen the moved solid losslessly; the reopened
|
||||
// payload can be moved again.
|
||||
let moved_volume = volume(&host, box_handle);
|
||||
let moved_extent = extent(&host, box_handle);
|
||||
let dwg = crate::io::load_bytes("solid.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
|
||||
let dxf = crate::io::load_bytes("solid.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
|
||||
for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] {
|
||||
let Some(EntityType::Solid3D(back)) = document.get_entity(box_handle) else { panic!("{format} lost the solid") };
|
||||
let body = kernel_body(back).unwrap_or_else(|| panic!("{format} payload does not lift"));
|
||||
assert!(close(sm::volume(&body), moved_volume, 1e-6), "{format}");
|
||||
let reopened_extent = sm::extent(&body).unwrap();
|
||||
for axis in 0..3 {
|
||||
assert!(close(reopened_extent.0[axis], moved_extent.0[axis], 1e-6) && close(reopened_extent.1[axis], moved_extent.1[axis], 1e-6), "{format} extent");
|
||||
}
|
||||
let again = host.add_entity(EntityType::Solid3D(back.clone()));
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value()));
|
||||
let (low, _) = extent(&host, again);
|
||||
assert!(close(low[0], reopened_extent.0[0] + 100.0, 1e-6), "{format} re-edit");
|
||||
}
|
||||
|
||||
// D and U: delete, then undo and redo restore the moved state.
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", box_handle.value()));
|
||||
assert!(host.document().get_entity(box_handle).is_none());
|
||||
drop(process);
|
||||
drop(host);
|
||||
app.finish_pending_history(0);
|
||||
// Steps after creation: 3 moves, 2 re-edits of reopened copies, 1 delete.
|
||||
app.undo_steps(1);
|
||||
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_some(), "undo restores the deleted solid");
|
||||
app.undo_steps(5);
|
||||
assert_eq!(app.tabs[0].scene.document.get_entity(box_handle), Some(&before), "undoing every move restores the original solid exactly");
|
||||
app.redo_steps(6);
|
||||
assert!(app.tabs[0].scene.document.get_entity(box_handle).is_none(), "redo replays the moves and the delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_python_body_transform_over_real_ipc() {
|
||||
use crate::scene::model::solid_model as sm;
|
||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
||||
return;
|
||||
};
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
// A Body entity carrying a kernel box; scripts cannot create one.
|
||||
let boxed = sm::box_solid([0.0; 3], 4.0, 4.0, 4.0).unwrap();
|
||||
let mut body = acadrust::entities::Body::new();
|
||||
body.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap());
|
||||
body.wires = sm::edge_wires(&boxed);
|
||||
let handle = host.add_entity(EntityType::Body(body));
|
||||
let process = ocs_plugin_api::process::PluginProcess::spawn(
|
||||
std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(),
|
||||
).unwrap();
|
||||
let dispatch = |host: &mut HostSession<'_>, command: &str| {
|
||||
assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch"));
|
||||
};
|
||||
let extent = |host: &HostSession<'_>| {
|
||||
let Some(EntityType::Body(b)) = host.document().get_entity(handle) else { panic!("no body") };
|
||||
sm::extent(&crate::scene::convert::solid3d_tess::kernel_acis_body(&b.acis_data).unwrap()).unwrap()
|
||||
};
|
||||
assert!((extent(&host).0[0] + 2.0).abs() < 1e-6);
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", handle.value()));
|
||||
assert!(host.app.command_line.history.last().unwrap().text.contains("Body"));
|
||||
dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 0, 0)).handle", handle.value()));
|
||||
assert!((extent(&host).0[0] - 8.0).abs() < 1e-6, "{:?}", extent(&host));
|
||||
let bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap();
|
||||
let reopened = crate::io::load_bytes("body.dwg", bytes).unwrap();
|
||||
let Some(EntityType::Body(back)) = reopened.get_entity(handle) else { panic!("DWG lost the body") };
|
||||
let lifted = crate::scene::convert::solid3d_tess::kernel_acis_body(&back.acis_data).expect("DWG body lifts");
|
||||
assert!((sm::volume(&lifted) - 64.0).abs() < 1e-6);
|
||||
// Scripts cannot create a Body and get the explanatory message.
|
||||
dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Body')");
|
||||
assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_python_point_pick_and_cancel_over_real_ipc() {
|
||||
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {
|
||||
|
||||
Reference in new issue
Block a user