统一 2D/3D 深度色条为同一量程。
将 2D 闩锁的深度色彩区间推给 3D 自动着色,固定量程仍跟旋钮。用 test_shared_depth_scale 与深度闩锁冒烟用例验证。
This commit is contained in:
@@ -2331,6 +2331,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._active_calibration_id = ""
|
self._active_calibration_id = ""
|
||||||
self._depth_range_value = None
|
self._depth_range_value = None
|
||||||
self._last_depth_frame = None
|
self._last_depth_frame = None
|
||||||
|
self._sync_shared_depth_scale(None)
|
||||||
# The new session has not established a domain boundary yet; the previous
|
# The new session has not established a domain boundary yet; the previous
|
||||||
# session's island must not survive into it (a different sensor is a different
|
# session's island must not survive into it (a different sensor is a different
|
||||||
# island, and the latch compares against what it holds).
|
# island, and the latch compares against what it holds).
|
||||||
@@ -2652,7 +2653,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
Re-fitting the range on every frame (the old behaviour) made colours
|
Re-fitting the range on every frame (the old behaviour) made colours
|
||||||
drift, so two frames could look identical while holding different
|
drift, so two frames could look identical while holding different
|
||||||
depths. Latching keeps frames comparable for the whole session; the
|
depths. Latching keeps frames comparable for the whole session; the
|
||||||
numeric range is shown in the panel header.
|
numeric range is shown in the panel header. The same span is handed to
|
||||||
|
the 3D pane so both legends name one interval.
|
||||||
"""
|
"""
|
||||||
if self._depth_range_value is not None:
|
if self._depth_range_value is not None:
|
||||||
return self._depth_range_value
|
return self._depth_range_value
|
||||||
@@ -2668,8 +2670,15 @@ class StudioMainWindow(QMainWindow):
|
|||||||
if hi <= lo:
|
if hi <= lo:
|
||||||
hi = lo + 1.0
|
hi = lo + 1.0
|
||||||
self._depth_range_value = (lo, hi)
|
self._depth_range_value = (lo, hi)
|
||||||
|
self._sync_shared_depth_scale(self._depth_range_value)
|
||||||
return self._depth_range_value
|
return self._depth_range_value
|
||||||
|
|
||||||
|
def _sync_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Push the latched 2D depth colour span to the 3D surface (or clear it)."""
|
||||||
|
panel = getattr(self, "mesh3d_panel", None)
|
||||||
|
if panel is not None and hasattr(panel, "set_shared_depth_scale"):
|
||||||
|
panel.set_shared_depth_scale(span)
|
||||||
|
|
||||||
def _depth_scale_text(self, frame: ImageFrame | None = None) -> str:
|
def _depth_scale_text(self, frame: ImageFrame | None = None) -> str:
|
||||||
"""The colour scale, plus — when the frame's depth is exactly zero — why it is flat.
|
"""The colour scale, plus — when the frame's depth is exactly zero — why it is flat.
|
||||||
|
|
||||||
@@ -2795,8 +2804,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
boundary = self._latched_domain_mask(frame)
|
boundary = self._latched_domain_mask(frame)
|
||||||
self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
|
self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
|
||||||
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
||||||
if mode == DEPTH:
|
# Latch (and sync to 3D) from any depth-carrying frame, not only when the
|
||||||
|
# 2D mode is Depth — the surface may show depth while the canvas shows arrows.
|
||||||
|
if frame.depth is not None:
|
||||||
self._last_depth_frame = frame.depth
|
self._last_depth_frame = frame.depth
|
||||||
|
self._depth_range()
|
||||||
|
if mode == DEPTH:
|
||||||
self.arrow_panel.set_depth_scale(self._depth_scale_text(frame))
|
self.arrow_panel.set_depth_scale(self._depth_scale_text(frame))
|
||||||
else:
|
else:
|
||||||
self.arrow_panel.set_depth_scale(None)
|
self.arrow_panel.set_depth_scale(None)
|
||||||
|
|||||||
@@ -506,6 +506,12 @@ class ThreeDCoordinator:
|
|||||||
# held field frame so the operator sees the change without waiting.
|
# held field frame so the operator sees the change without waiting.
|
||||||
self._settings_dirty = True
|
self._settings_dirty = True
|
||||||
|
|
||||||
|
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Adopt the 2D canvas's latched depth colour span for automatic colouring."""
|
||||||
|
setter = getattr(self._widget, "set_shared_depth_scale", None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(span)
|
||||||
|
|
||||||
def set_ramp(self, ramp: str) -> None:
|
def set_ramp(self, ramp: str) -> None:
|
||||||
"""Hand the theme's colour ramp to the composer.
|
"""Hand the theme's colour ramp to the composer.
|
||||||
|
|
||||||
|
|||||||
@@ -359,6 +359,33 @@ def _scale_bounds(scale, automatic: tuple[float, float] | None) -> tuple[float,
|
|||||||
return lower, upper
|
return lower, upper
|
||||||
|
|
||||||
|
|
||||||
|
def _field_scalar_range(
|
||||||
|
field: Any,
|
||||||
|
*,
|
||||||
|
previous: tuple[float, float] | None,
|
||||||
|
shared_depth_scale: tuple[float, float] | None,
|
||||||
|
) -> tuple[float, float] | None:
|
||||||
|
"""The automatic colour span for the uploaded field.
|
||||||
|
|
||||||
|
Depth shares the 2D canvas's latched ``(lo, hi)`` when the window has one, so the
|
||||||
|
two legends name the same interval. Flow (and depth before that latch exists) keep
|
||||||
|
the measured P5/P95 blend.
|
||||||
|
"""
|
||||||
|
descriptor = getattr(field, "descriptor", None)
|
||||||
|
quantity = str(getattr(descriptor, "quantity", "") or "")
|
||||||
|
if quantity == "depth_response" and shared_depth_scale is not None:
|
||||||
|
lo = float(shared_depth_scale[0])
|
||||||
|
hi = float(shared_depth_scale[1])
|
||||||
|
if hi > lo and not (math.isnan(lo) or math.isnan(hi)):
|
||||||
|
return lo, hi
|
||||||
|
measured = _valid_scalar_range(
|
||||||
|
field.scalar,
|
||||||
|
field.valid,
|
||||||
|
signed=bool(getattr(descriptor, "signed", False)),
|
||||||
|
)
|
||||||
|
return _blend_scale_range(previous, measured)
|
||||||
|
|
||||||
|
|
||||||
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
|
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
|
||||||
"""A 256x1 RGBA8 lookup table, built from the **one** ramp both views share.
|
"""A 256x1 RGBA8 lookup table, built from the **one** ramp both views share.
|
||||||
|
|
||||||
@@ -667,6 +694,9 @@ class GLRenderer:
|
|||||||
#: Range of the valid samples of the field currently uploaded, for the
|
#: Range of the valid samples of the field currently uploaded, for the
|
||||||
#: automatic colour scale. ``None`` until a frame carries valid data.
|
#: automatic colour scale. ``None`` until a frame carries valid data.
|
||||||
self._scalar_range: tuple[float, float] | None = None
|
self._scalar_range: tuple[float, float] | None = None
|
||||||
|
#: The 2D canvas's latched depth ``(lo, hi)``, when the window has one.
|
||||||
|
#: Depth automatic colouring uses this so both legends name one interval.
|
||||||
|
self._shared_depth_scale: tuple[float, float] | None = None
|
||||||
#: The colour bounds of the last draw and how they were chosen.
|
#: The colour bounds of the last draw and how they were chosen.
|
||||||
self._last_scale: tuple[float, float] | None = None
|
self._last_scale: tuple[float, float] | None = None
|
||||||
self._last_scale_mode = ""
|
self._last_scale_mode = ""
|
||||||
@@ -789,10 +819,11 @@ class GLRenderer:
|
|||||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||||
self._upload_field_textures(field)
|
self._upload_field_textures(field)
|
||||||
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
|
||||||
measured = _valid_scalar_range(
|
self._scalar_range = _field_scalar_range(
|
||||||
field.scalar, field.valid, signed=bool(field.descriptor.signed)
|
field,
|
||||||
|
previous=self._scalar_range,
|
||||||
|
shared_depth_scale=self._shared_depth_scale,
|
||||||
)
|
)
|
||||||
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
|
|
||||||
self._upload_overlays(frame)
|
self._upload_overlays(frame)
|
||||||
|
|
||||||
def _upload_field_textures(self, field: Any) -> None:
|
def _upload_field_textures(self, field: Any) -> None:
|
||||||
@@ -987,7 +1018,16 @@ class GLRenderer:
|
|||||||
view = np.asarray(camera.view, dtype=np.float32).reshape(4, 4)
|
view = np.asarray(camera.view, dtype=np.float32).reshape(4, 4)
|
||||||
projection = np.asarray(camera.projection, dtype=np.float32).reshape(4, 4)
|
projection = np.asarray(camera.projection, dtype=np.float32).reshape(4, 4)
|
||||||
scale = settings.color_scale
|
scale = settings.color_scale
|
||||||
lower, upper = _scale_bounds(scale, self._scalar_range)
|
# Prefer the 2D latched span at draw time, not only at upload: the latch can
|
||||||
|
# arrive after this field was uploaded, and a repaint must still equalise.
|
||||||
|
automatic = self._scalar_range
|
||||||
|
descriptor = getattr(scale, "descriptor", None) if scale is not None else None
|
||||||
|
if (
|
||||||
|
self._shared_depth_scale is not None
|
||||||
|
and str(getattr(descriptor, "quantity", "") or "") == "depth_response"
|
||||||
|
):
|
||||||
|
automatic = self._shared_depth_scale
|
||||||
|
lower, upper = _scale_bounds(scale, automatic)
|
||||||
# What the shader was actually given, for the legend: in automatic mode the
|
# What the shader was actually given, for the legend: in automatic mode the
|
||||||
# operator's spin boxes are disabled and hold values nobody is using, so the
|
# operator's spin boxes are disabled and hold values nobody is using, so the
|
||||||
# panel has to be able to ask what the picture means.
|
# panel has to be able to ask what the picture means.
|
||||||
@@ -1061,6 +1101,17 @@ class GLRenderer:
|
|||||||
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
"""The colour bounds the last draw actually used (``None`` before any draw)."""
|
||||||
return self._last_scale
|
return self._last_scale
|
||||||
|
|
||||||
|
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Adopt the 2D canvas's latched depth colour span, or clear it."""
|
||||||
|
if span is None:
|
||||||
|
self._shared_depth_scale = None
|
||||||
|
return
|
||||||
|
lo, hi = float(span[0]), float(span[1])
|
||||||
|
if hi <= lo or math.isnan(lo) or math.isnan(hi):
|
||||||
|
self._shared_depth_scale = None
|
||||||
|
return
|
||||||
|
self._shared_depth_scale = (lo, hi)
|
||||||
|
|
||||||
def set_ramp(self, ramp: str) -> None:
|
def set_ramp(self, ramp: str) -> None:
|
||||||
"""Adopt the theme's ramp. Rebuilds the LUT at the next draw, once.
|
"""Adopt the theme's ramp. Rebuilds the LUT at the next draw, once.
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ class Scene3DView(QWidget):
|
|||||||
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
|
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
|
||||||
#: each frame's own mask, which is what the tests and the PoC want.
|
#: each frame's own mask, which is what the tests and the PoC want.
|
||||||
self._domain_latch: Any = None
|
self._domain_latch: Any = None
|
||||||
|
#: The 2D canvas's latched depth colour span, held until the coordinator exists.
|
||||||
|
self._shared_depth_scale: tuple[float, float] | None = None
|
||||||
self._expected: ExpectedGeometry | None = None
|
self._expected: ExpectedGeometry | None = None
|
||||||
self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None
|
self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None
|
||||||
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
|
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
|
||||||
@@ -148,6 +150,8 @@ class Scene3DView(QWidget):
|
|||||||
self._coordinator = ThreeDCoordinator(
|
self._coordinator = ThreeDCoordinator(
|
||||||
self._widget, self._composer, self._executor, max_fps=30
|
self._widget, self._composer, self._executor, max_fps=30
|
||||||
)
|
)
|
||||||
|
if self._shared_depth_scale is not None:
|
||||||
|
self._coordinator.set_shared_depth_scale(self._shared_depth_scale)
|
||||||
self._panel = ThreeDPanel(self._coordinator, self._widget)
|
self._panel = ThreeDPanel(self._coordinator, self._widget)
|
||||||
# The picture must not be squeezed out of existence by the controls above it:
|
# The picture must not be squeezed out of existence by the controls above it:
|
||||||
# a GL widget with zero height draws nothing and looks like a broken view.
|
# a GL widget with zero height draws nothing and looks like a broken view.
|
||||||
@@ -313,6 +317,19 @@ class Scene3DView(QWidget):
|
|||||||
if self._adapter is not None and hasattr(self._adapter, "domain_latch"):
|
if self._adapter is not None and hasattr(self._adapter, "domain_latch"):
|
||||||
self._adapter.domain_latch = latch
|
self._adapter.domain_latch = latch
|
||||||
|
|
||||||
|
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Share the 2D canvas's latched depth colour span with this pane.
|
||||||
|
|
||||||
|
Automatic depth colouring on the surface uses this interval so the 3D legend
|
||||||
|
names the same ``(lo, hi)`` as the 2D depth scale. Fixed mode still follows the
|
||||||
|
operator's spin boxes.
|
||||||
|
"""
|
||||||
|
self._shared_depth_scale = None if span is None else (float(span[0]), float(span[1]))
|
||||||
|
if self._coordinator is not None and hasattr(
|
||||||
|
self._coordinator, "set_shared_depth_scale"
|
||||||
|
):
|
||||||
|
self._coordinator.set_shared_depth_scale(self._shared_depth_scale)
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
def set_theme(self, theme: str) -> None:
|
||||||
"""Adopt the application theme's colour ramp.
|
"""Adopt the application theme's colour ramp.
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
self._rejected_frames = 0
|
self._rejected_frames = 0
|
||||||
|
|
||||||
# ------------------------------------------------------------- CPU-side API
|
# ------------------------------------------------------------- CPU-side API
|
||||||
|
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Hand the 2D canvas's latched depth colour span to the GL renderer."""
|
||||||
|
setter = getattr(self._renderer, "set_shared_depth_scale", None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(span)
|
||||||
|
self.update()
|
||||||
|
|
||||||
def set_scene(self, assets: SceneAssets) -> None:
|
def set_scene(self, assets: SceneAssets) -> None:
|
||||||
"""Retain the asset and schedule its upload for the next current context."""
|
"""Retain the asset and schedule its upload for the next current context."""
|
||||||
self._assets = assets
|
self._assets = assets
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ class Mesh3dPanel(QFrame):
|
|||||||
#: The session's island latch, shared with the 2D canvas (see
|
#: The session's island latch, shared with the 2D canvas (see
|
||||||
#: ``set_domain_latch``). Held until the view exists.
|
#: ``set_domain_latch``). Held until the view exists.
|
||||||
self._domain_latch = None
|
self._domain_latch = None
|
||||||
|
#: The 2D canvas's latched depth colour span (see ``set_shared_depth_scale``).
|
||||||
|
self._shared_depth_scale: tuple[float, float] | None = None
|
||||||
|
|
||||||
# Reserve this top-level window's native handle *now*, before the window is
|
# Reserve this top-level window's native handle *now*, before the window is
|
||||||
# shown and while no OpenGL widget exists yet.
|
# shown and while no OpenGL widget exists yet.
|
||||||
@@ -227,6 +229,12 @@ class Mesh3dPanel(QFrame):
|
|||||||
if self._view is not None and hasattr(self._view, "set_theme"):
|
if self._view is not None and hasattr(self._view, "set_theme"):
|
||||||
self._view.set_theme(self._theme)
|
self._view.set_theme(self._theme)
|
||||||
|
|
||||||
|
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Share the 2D depth colour span with the 3D surface (and hold it until built)."""
|
||||||
|
self._shared_depth_scale = None if span is None else (float(span[0]), float(span[1]))
|
||||||
|
if self._view is not None and hasattr(self._view, "set_shared_depth_scale"):
|
||||||
|
self._view.set_shared_depth_scale(self._shared_depth_scale)
|
||||||
|
|
||||||
def set_domain_latch(self, latch) -> None:
|
def set_domain_latch(self, latch) -> None:
|
||||||
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
|
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
|
||||||
|
|
||||||
@@ -321,6 +329,8 @@ class Mesh3dPanel(QFrame):
|
|||||||
view.set_theme(self._theme)
|
view.set_theme(self._theme)
|
||||||
if self._domain_latch is not None and hasattr(view, "set_domain_latch"):
|
if self._domain_latch is not None and hasattr(view, "set_domain_latch"):
|
||||||
view.set_domain_latch(self._domain_latch)
|
view.set_domain_latch(self._domain_latch)
|
||||||
|
if hasattr(view, "set_shared_depth_scale"):
|
||||||
|
view.set_shared_depth_scale(self._shared_depth_scale)
|
||||||
if self._pending_settings is not None:
|
if self._pending_settings is not None:
|
||||||
view.restore_settings(self._pending_settings)
|
view.restore_settings(self._pending_settings)
|
||||||
# The kind was decided before the view existed (the window sets it when the
|
# The kind was decided before the view existed (the window sets it when the
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""2D and 3D depth colour bars name one interval."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.scene3d.contracts import FieldDescriptor
|
||||||
|
from orisys_studio.scene3d.gl_backend import _field_scalar_range
|
||||||
|
|
||||||
|
|
||||||
|
def _field(quantity: str, values, *, signed: bool = False) -> SimpleNamespace:
|
||||||
|
arr = np.asarray(values, dtype=np.float32)
|
||||||
|
return SimpleNamespace(
|
||||||
|
descriptor=FieldDescriptor(quantity, "1", "approximate", signed),
|
||||||
|
scalar=arr,
|
||||||
|
valid=np.isfinite(arr),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_automatic_scale_uses_the_shared_2d_span() -> None:
|
||||||
|
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
|
||||||
|
assert _field_scalar_range(
|
||||||
|
field,
|
||||||
|
previous=(0.0, 2.0),
|
||||||
|
shared_depth_scale=(0.0, 1.0),
|
||||||
|
) == (0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_ignores_the_shared_depth_span() -> None:
|
||||||
|
field = _field("flow_magnitude", np.linspace(0.0, 9.0, 16).reshape(4, 4))
|
||||||
|
span = _field_scalar_range(
|
||||||
|
field,
|
||||||
|
previous=None,
|
||||||
|
shared_depth_scale=(0.0, 1.0),
|
||||||
|
)
|
||||||
|
assert span is not None
|
||||||
|
assert span != (0.0, 1.0)
|
||||||
|
assert span[1] > span[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_without_a_shared_span_still_measures_the_field() -> None:
|
||||||
|
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
|
||||||
|
span = _field_scalar_range(
|
||||||
|
field,
|
||||||
|
previous=None,
|
||||||
|
shared_depth_scale=None,
|
||||||
|
)
|
||||||
|
assert span is not None
|
||||||
|
assert span[0] == 0.0
|
||||||
|
assert span[1] > 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_degenerate_shared_span_falls_back_to_the_measurement() -> None:
|
||||||
|
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
|
||||||
|
span = _field_scalar_range(
|
||||||
|
field,
|
||||||
|
previous=None,
|
||||||
|
shared_depth_scale=(1.0, 1.0),
|
||||||
|
)
|
||||||
|
assert span is not None
|
||||||
|
assert span != (1.0, 1.0)
|
||||||
@@ -920,6 +920,11 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
|
|||||||
window._on_run_tick()
|
window._on_run_tick()
|
||||||
latched = window.arrow_panel.depth_scale_label.text()
|
latched = window.arrow_panel.depth_scale_label.text()
|
||||||
assert latched
|
assert latched
|
||||||
|
shared = window.mesh3d_panel._shared_depth_scale
|
||||||
|
assert shared == window._depth_range_value
|
||||||
|
assert shared is not None
|
||||||
|
assert shared[0] == pytest.approx(0.0)
|
||||||
|
assert shared[1] == pytest.approx(1.0)
|
||||||
|
|
||||||
window._last_rendered_view_mode = None
|
window._last_rendered_view_mode = None
|
||||||
stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8)))
|
stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8)))
|
||||||
@@ -927,6 +932,9 @@ def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
|
|||||||
assert window.arrow_panel.depth_scale_label.text() == latched, (
|
assert window.arrow_panel.depth_scale_label.text() == latched, (
|
||||||
"the depth colour scale re-fitted on every frame"
|
"the depth colour scale re-fitted on every frame"
|
||||||
)
|
)
|
||||||
|
assert window.mesh3d_panel._shared_depth_scale == shared, (
|
||||||
|
"the 3D depth colour span must stay locked to the 2D latch"
|
||||||
|
)
|
||||||
_finish(window, stuck)
|
_finish(window, stuck)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user