forked from 1b-agent/opencadstudio-upstream
perf: eliminate per-frame re-tessellation and GPU re-upload in large files
Add two complementary render caches that make mouse navigation essentially free regardless of file size: Option A – Wire tessellation cache: entity_wires_arc() caches the tessellated Vec<WireModel> keyed by geometry_epoch and returns an Arc, so navigation frames (no preview wires) incur zero Vec clones and zero tessellation work. Primitive::wires changed to Arc<Vec<WireModel>>. Option B – GPU buffer cache: Pipeline stores cached_epoch; prepare() skips all upload_wires/hatches/images/meshes calls when the epoch is unchanged, writing only the 192-byte camera uniform instead. geometry_epoch is a process-wide AtomicU64 incremented by every Scene mutation (add/erase entity, selection, layer visibility, layout switch, populate_*, grip, transform, copy, clear, preview wires). Using a global counter rather than a per-scene counter prevents two tabs that happen to have the same local bump count from sharing a cached_epoch value, which was causing the first file's geometry to remain on screen after opening a second file. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
320312e81a
commit
d1ecaf4695
5 files changed
+225
-20
No files matched your search
+121
@@ -0,0 +1,121 @@
|
||||
# Performance Optimization Plan
|
||||
|
||||
## Problem
|
||||
|
||||
The application lags severely when large files are open — even simple mouse movement causes noticeable frame drops.
|
||||
|
||||
### Root Cause
|
||||
|
||||
Every frame (triggered by any mouse event), Iced calls `shader::Program::draw()` → `build_primitive()`.
|
||||
This function:
|
||||
|
||||
1. **Re-tessellates every entity from scratch** — `wires_for_block()` iterates all document entities and calls `tessellate_one()` for each, every single frame.
|
||||
2. **Recreates all GPU buffers** — `prepare()` calls `upload_wires/hatches/images/meshes()`, each of which allocates new `wgpu::Buffer` objects for every entity, every frame.
|
||||
3. **Linear-scans all objects** to find `SortEntitiesTable` on every frame.
|
||||
|
||||
During pure mouse navigation (pan/zoom), nothing in the geometry data changes — only the camera matrix (uniforms) changes. Despite this, all work above is repeated for every mouse-move event.
|
||||
|
||||
---
|
||||
|
||||
## Option A — Wire Tessellation Cache ✅ Implemented
|
||||
|
||||
**Status:** Done
|
||||
|
||||
Added `wire_cache: RefCell<Option<(u64, Arc<Vec<WireModel>>)>>` to `Scene`.
|
||||
`entity_wires_arc()` checks the cache first; if `geometry_epoch` matches it returns the cached
|
||||
`Arc` (O(1) refcount bump). On a cache miss it tessellates, wraps the result in `Arc`, stores it,
|
||||
and returns a clone of the `Arc`.
|
||||
|
||||
`build_primitive()` uses `entity_wires_arc()` directly:
|
||||
- **No preview wires (navigation):** stores the Arc as-is — zero Vec clones, zero tessellation.
|
||||
- **Command active (preview wires present):** clones the cached entity Vec once, appends
|
||||
preview/interim wires, wraps in a new Arc — only one allocation instead of full tessellation.
|
||||
|
||||
`Primitive::wires` changed from `Vec<WireModel>` to `Arc<Vec<WireModel>>`.
|
||||
|
||||
**Impact:** During navigation in large files the per-frame cost of the wire step drops from
|
||||
O(entities × tessellation) to O(1). Combined with Option B the GPU upload is also skipped,
|
||||
making navigation frames nearly free regardless of file size.
|
||||
|
||||
---
|
||||
|
||||
## Option B — GPU Buffer Cache ✅ Implemented
|
||||
|
||||
**Status:** Done
|
||||
|
||||
Add a `geometry_epoch: u64` counter to `Scene`. Bump it whenever geometry-affecting state changes.
|
||||
Carry the epoch through `Primitive` to `Pipeline`, which stores `cached_epoch: u64`.
|
||||
|
||||
In `prepare()`:
|
||||
- **Always** upload uniforms (camera changes every frame).
|
||||
- **Skip** geometry buffer uploads (`upload_wires/hatches/images/meshes`) when `geometry_epoch == cached_epoch`.
|
||||
- After uploading, set `pipeline.cached_epoch = geometry_epoch`.
|
||||
|
||||
**Impact:** During navigation, only a single 192-byte uniform write reaches the GPU instead of
|
||||
re-creating thousands of buffers. Fixes mouse-movement lag in large files.
|
||||
|
||||
**Difficulty:** Medium. Requires bumping the epoch in all geometry-mutation paths.
|
||||
|
||||
### Epoch bump locations
|
||||
|
||||
| Method | Location |
|
||||
|--------|----------|
|
||||
| `add_entity()` | `scene/mod.rs` |
|
||||
| `erase_entities()` | `scene/mod.rs` |
|
||||
| `populate_hatches_from_document()` | `scene/mod.rs` |
|
||||
| `populate_images_from_document()` | `scene/mod.rs` |
|
||||
| `populate_meshes_from_document()` | `scene/mod.rs` |
|
||||
| `set_preview_wires()` | `scene/mod.rs` |
|
||||
| `clear_preview_wire()` | `scene/mod.rs` |
|
||||
| `set_interim_wire()` | `scene/mod.rs` |
|
||||
| `select_entity()` | `scene/mod.rs` |
|
||||
| `deselect_all()` | `scene/mod.rs` |
|
||||
| `expand_selection_for_groups()` | `scene/mod.rs` |
|
||||
| `toggle_layer_visibility()` | `scene/mod.rs` |
|
||||
| `transform_entities()` | `scene/mod.rs` |
|
||||
| `copy_entities()` | `scene/mod.rs` |
|
||||
| `apply_grip()` | `scene/mod.rs` |
|
||||
| `clear()` | `scene/mod.rs` |
|
||||
|
||||
---
|
||||
|
||||
## Option C — Parallel Tessellation (Rayon)
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
Replace the sequential `.flat_map(|e| self.tessellate_one(e))` chain in `wires_for_block()` with
|
||||
`rayon::par_iter()`.
|
||||
|
||||
**Impact:** Spreads tessellation across all CPU cores. Improves file-open time and post-edit
|
||||
refresh. Does **not** fix per-frame navigation lag (Option A/B address that).
|
||||
|
||||
**Difficulty:** Easy. Requires making `tessellate_one` free of interior mutability, or collecting
|
||||
into a Vec first then parallel-processing.
|
||||
|
||||
---
|
||||
|
||||
## Option D — Frustum / Viewport Culling
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
Build a spatial index (R-tree or uniform grid) over entity bounding boxes.
|
||||
During `wires_for_block()`, skip entities whose bounding box lies entirely outside the camera frustum.
|
||||
|
||||
**Impact:** Dramatic speedup for large drawings when zoomed in — only visible entities are
|
||||
tessellated and uploaded. No benefit when the full drawing is visible.
|
||||
|
||||
**Difficulty:** Hard. Requires maintaining an up-to-date spatial index and computing per-entity
|
||||
bounding boxes for all entity types.
|
||||
|
||||
---
|
||||
|
||||
## Option E — SortEntitiesTable Scan Cache
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
Cache the result of the `SortEntitiesTable` lookup that currently performs a linear scan over all
|
||||
objects on every `wires_for_block()` call.
|
||||
|
||||
**Impact:** Minor. Removes one O(objects) scan per frame. Low priority compared to A–D.
|
||||
|
||||
**Difficulty:** Easy.
|
||||
@@ -752,6 +752,7 @@ impl H7CAD {
|
||||
if let Some(dl) = self.tabs[i].scene.document.layers.get_mut(&name) {
|
||||
if frozen { dl.freeze(); } else { dl.thaw(); }
|
||||
}
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
Task::none()
|
||||
@@ -788,6 +789,7 @@ impl H7CAD {
|
||||
let vp_info = self.tabs[i].scene.viewport_list();
|
||||
let doc_layers = self.tabs[i].scene.document.layers.clone();
|
||||
self.tabs[i].layers.sync_with_viewports(&doc_layers, vp_info);
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -2470,6 +2472,7 @@ impl H7CAD {
|
||||
// Switch to Model if active layout was deleted.
|
||||
if self.tabs[i].scene.current_layout == name {
|
||||
self.tabs[i].scene.current_layout = "Model".to_string();
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
}
|
||||
self.layout_manager_selected = "Model".to_string();
|
||||
self.layout_manager_rename_buf = String::new();
|
||||
@@ -2516,6 +2519,7 @@ impl H7CAD {
|
||||
let i = self.active_tab;
|
||||
let name = self.layout_manager_selected.clone();
|
||||
self.tabs[i].scene.current_layout = name.clone();
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line.push_output(&format!("Switched to layout '{name}'."));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
+60
-4
@@ -47,6 +47,14 @@ use iced::time::Duration;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Global counter so every Scene and every geometry mutation gets a
|
||||
/// process-wide unique epoch. This prevents two different tabs (Scenes)
|
||||
/// from ever sharing the same epoch value, which would cause the shared
|
||||
/// GPU Pipeline to skip re-uploading geometry when switching tabs.
|
||||
static GEOMETRY_EPOCH: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub struct Scene {
|
||||
pub camera: Rc<RefCell<Camera>>,
|
||||
@@ -60,6 +68,14 @@ pub struct Scene {
|
||||
/// Committed-segment wire drawn during multi-point commands (normal colour).
|
||||
pub interim_wire: Option<WireModel>,
|
||||
pub camera_generation: u64,
|
||||
/// Incremented whenever geometry-affecting state changes (entities, selection,
|
||||
/// preview wires, layer visibility, layout). The GPU pipeline uses this to
|
||||
/// skip re-uploading unchanged geometry buffers every frame.
|
||||
pub geometry_epoch: u64,
|
||||
/// Cached tessellation of all visible entity wires for the current layout.
|
||||
/// Keyed by `geometry_epoch`; invalidated automatically when the epoch changes.
|
||||
/// Uses `Arc` so `build_primitive()` avoids a full Vec clone during navigation.
|
||||
wire_cache: RefCell<Option<(u64, Arc<Vec<WireModel>>)>>,
|
||||
/// Active layout name — "Model" or a paper space layout name.
|
||||
pub current_layout: String,
|
||||
/// GPU render data for hatch fills, keyed by the DXF entity Handle.
|
||||
@@ -89,6 +105,8 @@ impl Scene {
|
||||
preview_wires: vec![],
|
||||
interim_wire: None,
|
||||
camera_generation: 0,
|
||||
geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||||
wire_cache: RefCell::new(None),
|
||||
current_layout: "Model".to_string(),
|
||||
hatches: HashMap::new(),
|
||||
meshes: HashMap::new(),
|
||||
@@ -99,6 +117,10 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bump_geometry(&mut self) {
|
||||
self.geometry_epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Public accessor for the block-record handle of the current layout.
|
||||
/// Used by external callers (e.g. `commit_entity`) that need the handle
|
||||
/// without going through private API.
|
||||
@@ -343,14 +365,31 @@ impl Scene {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build WireModels from all document entities + optional preview wire.
|
||||
pub fn entity_wires(&self) -> Vec<WireModel> {
|
||||
/// Build WireModels from all document entities for the current layout.
|
||||
/// Returns a shared `Arc` so `build_primitive()` can skip the clone during
|
||||
/// navigation frames where no preview wires are active.
|
||||
pub(super) fn entity_wires_arc(&self) -> Arc<Vec<WireModel>> {
|
||||
{
|
||||
let cache = self.wire_cache.borrow();
|
||||
if let Some((cached_epoch, ref arc)) = *cache {
|
||||
if cached_epoch == self.geometry_epoch {
|
||||
return Arc::clone(arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
let layout_block = self.current_layout_block_handle();
|
||||
let mut wires: Vec<WireModel> = self.wires_for_block(layout_block);
|
||||
let mut wires = self.wires_for_block(layout_block);
|
||||
if self.current_layout != "Model" {
|
||||
wires.extend(self.viewport_content_wires(layout_block, None, None));
|
||||
}
|
||||
wires
|
||||
let arc = Arc::new(wires);
|
||||
*self.wire_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc)));
|
||||
arc
|
||||
}
|
||||
|
||||
/// Build WireModels from all document entities + optional preview wire.
|
||||
pub fn entity_wires(&self) -> Vec<WireModel> {
|
||||
(*self.entity_wires_arc()).clone()
|
||||
}
|
||||
|
||||
/// Wires that should participate in hit-testing, snapping, and selection.
|
||||
@@ -1126,6 +1165,7 @@ impl Scene {
|
||||
self.current_layout = "Model".to_string();
|
||||
}
|
||||
|
||||
self.bump_geometry();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1224,6 +1264,7 @@ impl Scene {
|
||||
if let Some(model) = mesh_seed {
|
||||
self.meshes.insert(handle, model);
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
handle
|
||||
}
|
||||
@@ -1646,6 +1687,7 @@ impl Scene {
|
||||
self.images.insert(handle, model);
|
||||
}
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn populate_hatches_from_document(&mut self) {
|
||||
@@ -1677,6 +1719,7 @@ impl Scene {
|
||||
self.hatches.insert(handle, m);
|
||||
}
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
/// Tessellate all `Solid3D` entities in the current document into
|
||||
@@ -1712,6 +1755,7 @@ impl Scene {
|
||||
self.meshes.insert(handle, m);
|
||||
}
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
/// Rebuild hatch / image / mesh caches after the document is modified
|
||||
@@ -1783,17 +1827,20 @@ impl Scene {
|
||||
self.meshes = HashMap::new();
|
||||
*self.camera.borrow_mut() = Camera::default();
|
||||
self.camera_generation += 1;
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
// ── Preview wire ──────────────────────────────────────────────────────
|
||||
|
||||
pub fn set_preview_wires(&mut self, wires: Vec<WireModel>) {
|
||||
self.preview_wires = wires;
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn clear_preview_wire(&mut self) {
|
||||
self.preview_wires = vec![];
|
||||
self.interim_wire = None;
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn wire_models_for(&self, handles: &[acadrust::Handle]) -> Vec<WireModel> {
|
||||
@@ -1820,6 +1867,7 @@ impl Scene {
|
||||
|
||||
pub fn set_interim_wire(&mut self, w: WireModel) {
|
||||
self.interim_wire = Some(w);
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────────────
|
||||
@@ -1829,10 +1877,12 @@ impl Scene {
|
||||
self.selected.clear();
|
||||
}
|
||||
self.selected.insert(handle);
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn deselect_all(&mut self) {
|
||||
self.selected.clear();
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> {
|
||||
@@ -1873,6 +1923,7 @@ impl Scene {
|
||||
}
|
||||
self.document.objects.remove(gh);
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
// ── Group helpers ──────────────────────────────────────────────────────
|
||||
@@ -1955,6 +2006,7 @@ impl Scene {
|
||||
for h in to_add {
|
||||
self.selected.insert(h);
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
// ── Layer helpers ──────────────────────────────────────────────────────
|
||||
@@ -1963,6 +2015,7 @@ impl Scene {
|
||||
if let Some(layer) = self.document.layers.get_mut(name) {
|
||||
layer.flags.off = !layer.flags.off;
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn toggle_layer_lock(&mut self, name: &str) {
|
||||
@@ -1990,6 +2043,7 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec<Handle> {
|
||||
@@ -2015,6 +2069,7 @@ impl Scene {
|
||||
}
|
||||
new_handles.push(h);
|
||||
}
|
||||
self.bump_geometry();
|
||||
new_handles
|
||||
}
|
||||
|
||||
@@ -2040,6 +2095,7 @@ impl Scene {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
// ── Hit-test convenience: wire name → Handle ──────────────────────────
|
||||
|
||||
@@ -52,6 +52,9 @@ pub struct Pipeline {
|
||||
gpu_images: Vec<ImageGpu>,
|
||||
gpu_meshes: Vec<MeshGpu>,
|
||||
pub viewcube: ViewCubePipeline,
|
||||
/// Last geometry epoch for which GPU buffers were uploaded.
|
||||
/// Initialized to u64::MAX so the first frame always uploads.
|
||||
pub cached_epoch: u64,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
@@ -476,6 +479,7 @@ impl Pipeline {
|
||||
gpu_images: vec![],
|
||||
gpu_meshes: vec![],
|
||||
viewcube,
|
||||
cached_epoch: u64::MAX,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-16
@@ -9,6 +9,8 @@ use iced::mouse;
|
||||
use iced::widget::shader::{self, Viewport};
|
||||
use iced::{Rectangle, Size};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
|
||||
use super::pipeline::Pipeline;
|
||||
use super::tessellate;
|
||||
@@ -75,7 +77,7 @@ pub struct CameraState {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Primitive {
|
||||
pub(super) wires: Vec<WireModel>,
|
||||
pub(super) wires: Arc<Vec<WireModel>>,
|
||||
pub(super) hatches: Vec<HatchModel>,
|
||||
/// Wipeout fills — rendered in a separate pass AFTER wires.
|
||||
pub(super) wipeout_hatches: Vec<HatchModel>,
|
||||
@@ -89,6 +91,7 @@ pub struct Primitive {
|
||||
/// Background color used to clear the MSAA buffer at the start of each frame.
|
||||
pub(super) bg_color: [f32; 4],
|
||||
pub(super) show_viewcube: bool,
|
||||
pub(super) geometry_epoch: u64,
|
||||
}
|
||||
|
||||
// ── shader::Primitive impl ────────────────────────────────────────────────
|
||||
@@ -116,11 +119,14 @@ impl shader::Primitive for Primitive {
|
||||
pipeline.ensure_depth_texture(device, clip_size);
|
||||
pipeline.viewcube.ensure_depth_texture(device, full_size);
|
||||
pipeline.upload_uniforms(queue, &self.uniforms);
|
||||
pipeline.upload_hatches(device, &self.hatches);
|
||||
pipeline.upload_wipeouts(device, &self.wipeout_hatches);
|
||||
pipeline.upload_images(device, queue, &self.images);
|
||||
pipeline.upload_meshes(device, &self.meshes);
|
||||
pipeline.upload_wires(device, &self.wires);
|
||||
if self.geometry_epoch != pipeline.cached_epoch {
|
||||
pipeline.upload_hatches(device, &self.hatches);
|
||||
pipeline.upload_wipeouts(device, &self.wipeout_hatches);
|
||||
pipeline.upload_images(device, queue, &self.images);
|
||||
pipeline.upload_meshes(device, &self.meshes);
|
||||
pipeline.upload_wires(device, &self.wires[..]);
|
||||
pipeline.cached_epoch = self.geometry_epoch;
|
||||
}
|
||||
if self.show_viewcube {
|
||||
pipeline.viewcube.upload(
|
||||
queue,
|
||||
@@ -226,11 +232,17 @@ impl Scene {
|
||||
let cam = self.camera.borrow();
|
||||
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
|
||||
|
||||
let mut all_wires = self.entity_wires();
|
||||
if let Some(iw) = &self.interim_wire {
|
||||
all_wires.push(iw.clone());
|
||||
}
|
||||
all_wires.extend(self.preview_wires.iter().cloned());
|
||||
let entity_arc = self.entity_wires_arc();
|
||||
let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {
|
||||
entity_arc
|
||||
} else {
|
||||
let mut v = (*entity_arc).clone();
|
||||
if let Some(iw) = &self.interim_wire {
|
||||
v.push(iw.clone());
|
||||
}
|
||||
v.extend(self.preview_wires.iter().cloned());
|
||||
Arc::new(v)
|
||||
};
|
||||
|
||||
let bg_color = if self.current_layout == "Model" {
|
||||
self.bg_color
|
||||
@@ -249,6 +261,7 @@ impl Scene {
|
||||
hover_region,
|
||||
bg_color,
|
||||
show_viewcube,
|
||||
geometry_epoch: self.geometry_epoch,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,11 +279,17 @@ impl Scene {
|
||||
None => return self.build_primitive(hover_region, bounds, false),
|
||||
};
|
||||
|
||||
let mut all_wires = self.model_wires_for_viewport(vp_handle);
|
||||
if let Some(iw) = &self.interim_wire {
|
||||
all_wires.push(iw.clone());
|
||||
}
|
||||
all_wires.extend(self.preview_wires.iter().cloned());
|
||||
let base_wires = self.model_wires_for_viewport(vp_handle);
|
||||
let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() {
|
||||
Arc::new(base_wires)
|
||||
} else {
|
||||
let mut v = base_wires;
|
||||
if let Some(iw) = &self.interim_wire {
|
||||
v.push(iw.clone());
|
||||
}
|
||||
v.extend(self.preview_wires.iter().cloned());
|
||||
Arc::new(v)
|
||||
};
|
||||
|
||||
Primitive {
|
||||
wires: all_wires,
|
||||
@@ -283,6 +302,7 @@ impl Scene {
|
||||
hover_region,
|
||||
bg_color: self.bg_color,
|
||||
show_viewcube: false,
|
||||
geometry_epoch: self.geometry_epoch,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user