Cleanup slice operation stuff
This commit is contained in:
76
Cargo.lock
generated
76
Cargo.lock
generated
@@ -2420,6 +2420,12 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.2"
|
||||
@@ -2652,9 +2658,12 @@ dependencies = [
|
||||
"goo_format",
|
||||
"image 0.25.1",
|
||||
"nalgebra 0.32.6",
|
||||
"parking_lot",
|
||||
"plexus",
|
||||
"rfd",
|
||||
"slicer",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
@@ -2811,6 +2820,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
|
||||
dependencies = [
|
||||
"overload",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.2.1"
|
||||
@@ -3188,6 +3207,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.21.0"
|
||||
@@ -3975,6 +4000,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.2"
|
||||
@@ -4272,6 +4306,16 @@ dependencies = [
|
||||
"syn 2.0.66",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.9.1"
|
||||
@@ -4418,6 +4462,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b"
|
||||
dependencies = [
|
||||
"nu-ansi-term",
|
||||
"sharded-slab",
|
||||
"smallvec 1.13.2",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4532,6 +4602,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.0"
|
||||
|
7
TODO.md
7
TODO.md
@@ -17,8 +17,8 @@
|
||||
- [ ] Anti-aliasing
|
||||
- [x] Cache transformed points?
|
||||
- [x] Rename `ui` module to `mslicer`
|
||||
- [ ] Proper slice preview scaling
|
||||
- [ ] Preview image generation
|
||||
- [x] Proper slice preview scaling
|
||||
- [x] Preview image generation
|
||||
- [x] Don't clone mesh data when sending mesh to slicing thread
|
||||
- [ ] Close details dropdown in models window when deleting mesh
|
||||
- [x] Rotations in degrees
|
||||
@@ -41,3 +41,6 @@
|
||||
- [x] Optimize bvh more (benchmarking with criterion)
|
||||
- [x] Add documentation to slicer
|
||||
- [x] Make the slice preview layer slider full height
|
||||
- [ ] Combine slice result and slice preview windows and add a close button
|
||||
- [ ] Split slice operation to a new module
|
||||
- [ ] Re-enable multi-sampling
|
||||
|
@@ -13,8 +13,11 @@ egui-wgpu = "0.27.2"
|
||||
encase = { version = "0.8.0", features = ["nalgebra"] }
|
||||
image = "0.25.1"
|
||||
nalgebra = "0.32.6"
|
||||
parking_lot = "0.12.3"
|
||||
plexus = "0.0.11"
|
||||
rfd = "0.14.1"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
wgpu = "0.19.4"
|
||||
|
||||
common = { path = "../common" }
|
||||
|
@@ -1,18 +1,16 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex, RwLock},
|
||||
thread,
|
||||
time::Instant,
|
||||
};
|
||||
use std::{sync::Arc, thread, time::Instant};
|
||||
|
||||
use clone_macro::clone;
|
||||
use egui::{CentralPanel, Frame, Sense};
|
||||
use egui_wgpu::Callback;
|
||||
use image::{imageops::FilterType, RgbaImage};
|
||||
use nalgebra::{Vector2, Vector3};
|
||||
use parking_lot::{lock_api::MutexGuard, Condvar, MappedMutexGuard, Mutex, RawMutex, RwLock};
|
||||
use slicer::{
|
||||
slicer::{Progress as SliceProgress, Slicer},
|
||||
Pos,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
render::{
|
||||
@@ -29,10 +27,7 @@ pub struct App {
|
||||
pub slice_config: SliceConfig,
|
||||
pub meshes: Arc<RwLock<Vec<RenderedMesh>>>,
|
||||
|
||||
// todo: clean this up
|
||||
pub slice_progress: Option<SliceProgress>,
|
||||
pub slice_result: Arc<Mutex<Option<SliceResult>>>,
|
||||
pub slice_preview_image: Arc<Mutex<Option<RgbaImage>>>,
|
||||
pub slice_operation: Option<SliceOperation>,
|
||||
|
||||
pub render_style: RenderStyle,
|
||||
pub grid_size: f32,
|
||||
@@ -40,6 +35,11 @@ pub struct App {
|
||||
pub windows: Windows,
|
||||
}
|
||||
|
||||
pub struct FpsTracker {
|
||||
last_frame: Instant,
|
||||
last_frame_time: f32,
|
||||
}
|
||||
|
||||
pub struct SliceResult {
|
||||
pub goo: GooFile,
|
||||
|
||||
@@ -49,14 +49,56 @@ pub struct SliceResult {
|
||||
pub preview_scale: f32,
|
||||
}
|
||||
|
||||
pub struct FpsTracker {
|
||||
last_frame: Instant,
|
||||
last_frame_time: f32,
|
||||
// todo: Arc<SliceOperationInner>?
|
||||
#[derive(Clone)]
|
||||
pub struct SliceOperation {
|
||||
pub progress: SliceProgress,
|
||||
pub result: Arc<Mutex<Option<SliceResult>>>,
|
||||
|
||||
pub preview_image: Arc<Mutex<Option<RgbaImage>>>,
|
||||
preview_condvar: Arc<Condvar>,
|
||||
}
|
||||
|
||||
impl SliceOperation {
|
||||
pub fn new(progress: SliceProgress) -> Self {
|
||||
Self {
|
||||
progress,
|
||||
result: Arc::new(Mutex::new(None)),
|
||||
preview_image: Arc::new(Mutex::new(None)),
|
||||
preview_condvar: Arc::new(Condvar::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_preview_image(&self) -> bool {
|
||||
self.preview_image.lock().is_none()
|
||||
}
|
||||
|
||||
pub fn add_preview_image(&self, image: RgbaImage) {
|
||||
self.preview_image.lock().replace(image);
|
||||
self.preview_condvar.notify_all();
|
||||
}
|
||||
|
||||
pub fn preview_image(&self) -> MappedMutexGuard<'_, RgbaImage> {
|
||||
let mut preview_image = self.preview_image.lock();
|
||||
while preview_image.is_none() {
|
||||
self.preview_condvar.wait(&mut preview_image);
|
||||
}
|
||||
|
||||
MutexGuard::map(preview_image, |image| image.as_mut().unwrap())
|
||||
}
|
||||
|
||||
pub fn add_result(&self, result: SliceResult) {
|
||||
self.result.lock().replace(result);
|
||||
}
|
||||
|
||||
pub fn result(&self) -> MutexGuard<RawMutex, Option<SliceResult>> {
|
||||
self.result.lock()
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn slice(&mut self) {
|
||||
*self.slice_preview_image.lock().unwrap() = None;
|
||||
info!("Starting slicing operation");
|
||||
|
||||
let slice_config = self.slice_config.clone();
|
||||
let mut meshes = Vec::new();
|
||||
@@ -68,7 +110,7 @@ impl App {
|
||||
1.0,
|
||||
);
|
||||
|
||||
for mesh in self.meshes.read().unwrap().iter().cloned() {
|
||||
for mesh in self.meshes.read().iter().cloned() {
|
||||
let mut mesh = mesh.mesh;
|
||||
|
||||
mesh.set_scale_unchecked(mesh.scale().component_mul(&mm_to_px));
|
||||
@@ -94,25 +136,24 @@ impl App {
|
||||
}
|
||||
|
||||
let slicer = Slicer::new(slice_config, meshes);
|
||||
self.slice_progress = Some(slicer.progress());
|
||||
self.slice_operation
|
||||
.replace(SliceOperation::new(slicer.progress()));
|
||||
|
||||
thread::spawn(clone!(
|
||||
[
|
||||
{ self.slice_result } as slice_result,
|
||||
{ self.slice_preview_image } as preview_image
|
||||
],
|
||||
[{ self.slice_operation } as slice_operation],
|
||||
move || {
|
||||
let slice_operation = slice_operation.as_ref().unwrap();
|
||||
let mut goo = GooFile::from_slice_result(slicer.slice::<LayerEncoder>());
|
||||
|
||||
let preview_image = preview_image.lock().unwrap();
|
||||
let preview_image = preview_image.as_ref().unwrap();
|
||||
{
|
||||
let preview_image = slice_operation.preview_image();
|
||||
goo.header.big_preview =
|
||||
PreviewImage::from_image_scaled(&preview_image, FilterType::Nearest);
|
||||
goo.header.small_preview =
|
||||
PreviewImage::from_image_scaled(&preview_image, FilterType::Nearest);
|
||||
}
|
||||
|
||||
goo.header.big_preview =
|
||||
PreviewImage::from_image_scaled(preview_image, FilterType::Nearest);
|
||||
goo.header.small_preview =
|
||||
PreviewImage::from_image_scaled(preview_image, FilterType::Nearest);
|
||||
|
||||
slice_result.lock().unwrap().replace(SliceResult {
|
||||
slice_operation.add_result(SliceResult {
|
||||
goo,
|
||||
slice_preview_layer: 0,
|
||||
last_preview_layer: 0,
|
||||
@@ -149,9 +190,7 @@ impl eframe::App for App {
|
||||
grid_size: self.grid_size,
|
||||
|
||||
is_moving: response.dragged(),
|
||||
render_preview: (self.slice_progress.is_some()
|
||||
&& self.slice_preview_image.lock().unwrap().is_none())
|
||||
.then(|| self.slice_preview_image.clone()),
|
||||
slice_operation: self.slice_operation.clone(),
|
||||
|
||||
models: self.meshes.clone(),
|
||||
render_style: self.render_style,
|
||||
@@ -208,9 +247,7 @@ impl Default for App {
|
||||
render_style: RenderStyle::Normals,
|
||||
grid_size: 12.16,
|
||||
|
||||
slice_progress: None,
|
||||
slice_result: Arc::new(Mutex::new(None)),
|
||||
slice_preview_image: Arc::new(Mutex::new(None)),
|
||||
slice_operation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,8 @@ use anyhow::Result;
|
||||
use eframe::NativeOptions;
|
||||
use egui::{IconData, Vec2, ViewportBuilder};
|
||||
use egui_wgpu::WgpuConfiguration;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::{filter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use wgpu::{DeviceDescriptor, Features, TextureFormat};
|
||||
|
||||
const TEXTURE_FORMAT: TextureFormat = TextureFormat::Bgra8Unorm;
|
||||
@@ -17,6 +19,11 @@ use app::App;
|
||||
const ICON: &[u8] = include_bytes!("assets/icon.png");
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let filter = filter::Targets::new()
|
||||
.with_default(LevelFilter::OFF)
|
||||
.with_target("mslicer", LevelFilter::TRACE);
|
||||
tracing_subscriber::registry().with(filter).init();
|
||||
|
||||
let icon = image::load_from_memory(ICON)?;
|
||||
eframe::run_native(
|
||||
"mslicer",
|
||||
|
@@ -102,7 +102,7 @@ impl Pipeline<WorkspaceRenderCallback> for ModelPipeline {
|
||||
self.bind_groups.clear();
|
||||
let mut to_generate = Vec::new();
|
||||
|
||||
for (idx, model) in resources.models.read().unwrap().iter().enumerate() {
|
||||
for (idx, model) in resources.models.read().iter().enumerate() {
|
||||
if model.try_get_buffers().is_none() {
|
||||
to_generate.push(idx);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ impl Pipeline<WorkspaceRenderCallback> for ModelPipeline {
|
||||
}
|
||||
|
||||
if !to_generate.is_empty() {
|
||||
let mut meshes = resources.models.write().unwrap();
|
||||
let mut meshes = resources.models.write();
|
||||
for idx in to_generate {
|
||||
meshes[idx].get_buffers(device);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ impl Pipeline<WorkspaceRenderCallback> for ModelPipeline {
|
||||
fn paint<'a>(&'a self, render_pass: &mut RenderPass<'a>, resources: &WorkspaceRenderCallback) {
|
||||
render_pass.set_pipeline(&self.render_pipeline);
|
||||
|
||||
let models = resources.models.read().unwrap();
|
||||
let models = resources.models.read();
|
||||
for (idx, model) in models.iter().enumerate().filter(|(_, x)| !x.hidden) {
|
||||
render_pass.set_bind_group(0, &self.bind_groups[idx], &[]);
|
||||
|
||||
|
@@ -3,6 +3,7 @@ use std::f32::consts::PI;
|
||||
use egui_wgpu::ScreenDescriptor;
|
||||
use image::{Rgba, RgbaImage};
|
||||
use nalgebra::{Vector2, Vector3};
|
||||
use tracing::info;
|
||||
use wgpu::{
|
||||
BufferAddress, BufferDescriptor, BufferUsages, Color, CommandEncoder, CommandEncoderDescriptor,
|
||||
Device, Extent3d, ImageCopyBuffer, ImageCopyTexture, ImageDataLayout, LoadOp, Maintain,
|
||||
@@ -30,19 +31,21 @@ pub fn render_preview_image(
|
||||
model_pipeline: &mut ModelPipeline,
|
||||
workspace: &WorkspaceRenderCallback,
|
||||
) -> RgbaImage {
|
||||
info!("Generating {}x{} preview image", size.0, size.1);
|
||||
|
||||
let (texture, depth_texture) = init_textures(device, size);
|
||||
let texture_view = texture.create_view(&TextureViewDescriptor::default());
|
||||
let depth_texture_view = depth_texture.create_view(&TextureViewDescriptor::default());
|
||||
|
||||
let (mut min, mut max) = (Vector3::repeat(f32::MAX), Vector3::repeat(f32::MIN));
|
||||
for model in workspace.models.read().unwrap().iter() {
|
||||
for model in workspace.models.read().iter() {
|
||||
let (model_min, model_max) = model.mesh.minmax_point();
|
||||
min = min.zip_map(&model_min, f32::min);
|
||||
max = max.zip_map(&model_max, f32::max);
|
||||
}
|
||||
|
||||
let target = (min + max) / 2.0;
|
||||
let distance = (min - target).magnitude().max((max - target).magnitude());
|
||||
let distance = (min - max).magnitude() / 2.0;
|
||||
|
||||
let mut old_workspace = workspace.clone();
|
||||
old_workspace.camera = Camera {
|
||||
|
@@ -1,11 +1,14 @@
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::PaintCallbackInfo;
|
||||
use egui_wgpu::{CallbackResources, CallbackTrait, ScreenDescriptor};
|
||||
use image::RgbaImage;
|
||||
use nalgebra::{Matrix4, Vector3};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use wgpu::{CommandBuffer, CommandEncoder, Device, Queue, RenderPass};
|
||||
|
||||
use crate::app::SliceOperation;
|
||||
|
||||
use super::{
|
||||
camera::Camera,
|
||||
pipelines::{
|
||||
@@ -36,7 +39,7 @@ pub struct WorkspaceRenderCallback {
|
||||
pub render_style: RenderStyle,
|
||||
|
||||
pub is_moving: bool,
|
||||
pub render_preview: Option<Arc<Mutex<Option<RgbaImage>>>>,
|
||||
pub slice_operation: Option<SliceOperation>,
|
||||
}
|
||||
|
||||
impl CallbackTrait for WorkspaceRenderCallback {
|
||||
@@ -50,18 +53,20 @@ impl CallbackTrait for WorkspaceRenderCallback {
|
||||
) -> Vec<CommandBuffer> {
|
||||
let resources = resources.get_mut::<WorkspaceRenderResources>().unwrap();
|
||||
|
||||
if let Some(preview) = &self.render_preview {
|
||||
let image = render_preview_image(
|
||||
device,
|
||||
queue,
|
||||
screen_descriptor,
|
||||
encoder,
|
||||
(512, 512),
|
||||
&mut resources.model_pipeline,
|
||||
self,
|
||||
);
|
||||
|
||||
*preview.lock().unwrap() = Some(image);
|
||||
match &self.slice_operation {
|
||||
Some(slice_operation) if slice_operation.needs_preview_image() => {
|
||||
let image = render_preview_image(
|
||||
device,
|
||||
queue,
|
||||
screen_descriptor,
|
||||
encoder,
|
||||
(512, 512),
|
||||
&mut resources.model_pipeline,
|
||||
self,
|
||||
);
|
||||
slice_operation.add_preview_image(image);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
resources
|
||||
|
@@ -17,7 +17,7 @@ pub fn ui(app: &mut App, ctx: &Context, _frame: &mut Frame) {
|
||||
Window::new("Models")
|
||||
.open(&mut app.windows.show_models)
|
||||
.show(ctx, |ui| {
|
||||
let mut meshes = app.meshes.write().unwrap();
|
||||
let mut meshes = app.meshes.write();
|
||||
let mut action = Action::None;
|
||||
|
||||
if meshes.is_empty() {
|
||||
|
@@ -9,7 +9,12 @@ use crate::{
|
||||
};
|
||||
|
||||
pub fn ui(app: &mut App, ctx: &Context, _frame: &mut Frame) {
|
||||
if let Some(result) = app.slice_result.lock().unwrap().as_mut() {
|
||||
if let Some(slice_operation) = &app.slice_operation {
|
||||
let mut result = slice_operation.result();
|
||||
let Some(result) = result.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
Window::new("Slice Preview").show(ctx, move |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().slider_width = ui.available_size().x
|
||||
|
@@ -11,7 +11,9 @@ pub fn ui(app: &mut App, ctx: &Context, _frame: &mut Frame) {
|
||||
let mut window_open = true;
|
||||
let mut save_complete = false;
|
||||
|
||||
if let Some(progress) = app.slice_progress.as_ref() {
|
||||
if let Some(slice_operation) = &app.slice_operation {
|
||||
let progress = &slice_operation.progress;
|
||||
|
||||
let (current, total) = (progress.completed(), progress.total());
|
||||
|
||||
let mut window = Window::new("Slice Progress");
|
||||
@@ -32,7 +34,9 @@ pub fn ui(app: &mut App, ctx: &Context, _frame: &mut Frame) {
|
||||
} else {
|
||||
ui.label("Slicing complete!");
|
||||
if ui.button("Save").clicked() {
|
||||
let result = app.slice_result.lock().unwrap().take().unwrap();
|
||||
let result = app.slice_operation.as_ref().unwrap().result();
|
||||
let result = result.as_ref().unwrap();
|
||||
|
||||
if let Some(path) = FileDialog::new().save_file() {
|
||||
let mut file = File::create(path).unwrap();
|
||||
let mut serializer = DynamicSerializer::new();
|
||||
@@ -46,6 +50,6 @@ pub fn ui(app: &mut App, ctx: &Context, _frame: &mut Frame) {
|
||||
}
|
||||
|
||||
if !window_open || save_complete {
|
||||
app.slice_progress = None;
|
||||
app.slice_operation = None;
|
||||
}
|
||||
}
|
||||
|
@@ -31,7 +31,6 @@ pub fn ui(app: &mut App, ctx: &Context, _frame: &mut Frame) {
|
||||
|
||||
app.meshes
|
||||
.write()
|
||||
.unwrap()
|
||||
.push(RenderedMesh::from_mesh(model).with_name(name));
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user