docs: Task 6b für die Kartenbeschriftung, Löcher in Task 7
Die Sichtprüfung nach Task 6 hat gezeigt, dass die Beschriftungen der Originalkarte zu Geometrie werden — "VICE CITY (MIAMI)" steht als 1.600 m breiter Schriftzug in der Bucht. Task 6b entfernt sie, bevor Task 7 daraus Meshes baut. Der Brief nennt drei gemessene Sackgassen (Buntheit, Farbton, Zusammenhang nur der Straßenmaske) und den Weg, der trägt: auf Regionenebene über alle Nicht-Wasser-Klassen, wo die Buchstaben eigene Inseln ohne jedes natürliche Merkmal bilden. extrudePolygons und flatPolygons in Task 7 verarbeiten jetzt Ringlisten mit Löchern statt einzelner Ringe, passend zu Ruling 34. overview.py rendert den ganzen Ausschnitt aus den Chunk-Dateien; damit ist der Beschriftungsfehler überhaupt erst aufgefallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce90ea1d5f
commit
062a9af2fd
2 files changed
+302
-37
No files matched your search
@@ -1510,6 +1510,168 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
---
|
||||
|
||||
### Task 6b: Kartenbeschriftung aus der Klassifikation entfernen
|
||||
|
||||
**Warum diese Aufgabe existiert:** Die Sichtprüfung der Gesamtansicht nach Task 6 hat gezeigt,
|
||||
dass die Beschriftungen der Originalkarte zu Geometrie werden. "VICE CITY (MIAMI)" steht als
|
||||
rund 1.600 m breiter Schriftzug aus Straßenfläche mitten in der Bucht, die roten Ortsnamen
|
||||
(STARFISH ISLAND, CATALAN BAY, PRAWN ISLAND) werden zu Brücken und spekulativen Gebäuden.
|
||||
Task 7 extrudiert genau diese Daten zu Meshes; ohne diese Aufgabe stehen die Buchstaben als
|
||||
Mauern in der Welt. Die 195 auffällig langen, dünnen "Gebäude" aus der Messung zu Task 5
|
||||
(eines 586 m lang bei nur 1.576 m² Fläche, eines 384 m lang bei 8 m Breite) sind dieselbe
|
||||
Ursache.
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/extract/leonida/classify.py`
|
||||
- Test: `tools/extract/tests/test_classify.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CLASSES`, `classify(rgb)` aus Task 3
|
||||
- Produces: `drop_map_labels(labels: np.ndarray) -> np.ndarray` — gibt ein neues Label-Bild
|
||||
zurück, in dem als Beschriftung erkannte Bereiche auf `CLASSES["wasser"]` stehen.
|
||||
`run.py` ruft es unmittelbar nach `classify` auf.
|
||||
|
||||
**Was schon ausprobiert und widerlegt ist.** Diese drei Wege sind gemessen und führen nicht
|
||||
zum Ziel; probier sie nicht noch einmal:
|
||||
|
||||
1. *Buntheit.* Die als `strasse` klassifizierten Schriftpixel über Wasser haben Medianfarbe
|
||||
(47, 79, 102), Buntheit p50 49 gegen p50 0 bei echten Straßen. Eine Schwelle von 30
|
||||
entfernt aber nur 74 % der verdächtigen Pixel und dabei 5,8 % echter Straßenpixel.
|
||||
2. *Farbton.* Die Palette trennt sich sauber (Wasser 201°, Gras 92°, Sand 53°, Stadt und
|
||||
Straße und Gebäude neutral), aber die Regel erfasst nur 5.551 von 64.125 Nicht-Wasser-
|
||||
Pixeln im Schriftzug: die hellen Buchstabenkörper sind farblich identisch mit echten
|
||||
Stadtpixeln. Farbe allein kann das nicht lösen.
|
||||
3. *Zusammenhang nur der Straßenmaske.* Scheitert daran, dass die hellen Teile der Buchstaben
|
||||
als `stadt` klassifiziert werden und damit selbst "Land" um sich herum erzeugen.
|
||||
|
||||
**Der Weg, der trägt.** Auf Regionenebene über **alle** Nicht-Wasser-Klassen gemeinsam.
|
||||
Gemessen am echten Mosaik: `~wasser` zerfällt in 52 Komponenten. Die Hauptlandmasse hat
|
||||
5.852.263 px; die Buchstaben bilden eigene Komponenten von 4.876 bis 14.760 px, und ihr
|
||||
Anteil natürlicher Merkmale (`gruen`, `sand`, `land`) ist 0,0 %. Echte Inseln haben Grün oder
|
||||
Sand, Buchstaben nicht. Die Regel "nicht die Hauptlandmasse, höchstens 2 % natürliche
|
||||
Merkmale" entfernt 106.800 px (1,77 % aller Nicht-Wasser-Pixel) und darin 63.199 von 64.125
|
||||
Pixeln des Schriftzugs, also 98,6 %. Das Ergebnis ist zwischen den Größenschwellen 20.000 und
|
||||
150.000 px identisch — die Trennung ist also nicht auf einen Schwellenwert gefittet.
|
||||
|
||||
- [ ] **Step 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
An `tools/extract/tests/test_classify.py` anfügen:
|
||||
|
||||
```python
|
||||
from leonida.classify import drop_map_labels
|
||||
|
||||
def test_letter_shaped_island_becomes_water():
|
||||
"""Ein Buchstabe im Wasser ist Beschriftung, keine Stadt: keine natuerlichen Merkmale."""
|
||||
labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
|
||||
labels[40:60, 40:120] = CLASSES["stadt"] # Querbalken
|
||||
labels[40:120, 40:60] = CLASSES["strasse"] # Laengsbalken -> zusammen ein "L"
|
||||
out = drop_map_labels(labels)
|
||||
assert (out == CLASSES["wasser"]).all()
|
||||
|
||||
def test_real_island_survives():
|
||||
"""Eine Insel mit Gruen und Sand bleibt, auch wenn sie klein und vom Festland getrennt ist."""
|
||||
labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
|
||||
labels[40:120, 40:120] = CLASSES["stadt"]
|
||||
labels[50:70, 50:70] = CLASSES["gruen"]
|
||||
labels[100:115, 45:115] = CLASSES["sand"]
|
||||
out = drop_map_labels(labels)
|
||||
assert (out[40:120, 40:120] != CLASSES["wasser"]).all()
|
||||
|
||||
def test_mainland_is_never_dropped():
|
||||
"""Die groesste Nicht-Wasser-Komponente bleibt immer, auch voellig ohne Gruen und Sand."""
|
||||
labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
|
||||
labels[10:190, 10:100] = CLASSES["stadt"]
|
||||
out = drop_map_labels(labels)
|
||||
assert (out[10:190, 10:100] == CLASSES["stadt"]).all()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen, er muss scheitern**
|
||||
|
||||
Run: `cd tools/extract && uv run pytest tests/test_classify.py -v`
|
||||
Expected: FAIL, `ImportError: cannot import name 'drop_map_labels'`
|
||||
|
||||
- [ ] **Step 3: `drop_map_labels` in `leonida/classify.py` ergänzen**
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from scipy import ndimage as ndi
|
||||
|
||||
NATURAL_CLASSES = ("gruen", "sand", "land")
|
||||
LABEL_MAX_NATURAL = 0.02 # Anteil natuerlicher Merkmale, ab dem eine Flaeche als echt gilt
|
||||
|
||||
|
||||
def drop_map_labels(labels: np.ndarray) -> np.ndarray:
|
||||
"""Beschriftungen der Originalkarte auf Wasser zuruecksetzen.
|
||||
|
||||
Die Schriftzuege der Karte werden pixelweise als stadt/strasse/gebaeude klassifiziert und
|
||||
sind farblich von echten Flaechen nicht zu unterscheiden. Trennbar sind sie erst als
|
||||
Region: ein Schriftzug im Wasser bildet eine eigene, vom Festland getrennte Nicht-Wasser-
|
||||
Komponente ohne jedes natuerliche Merkmal, waehrend eine echte Insel Gruen oder Sand
|
||||
traegt. Die groesste Komponente ist immer das Festland und bleibt unangetastet.
|
||||
"""
|
||||
solid = labels != CLASSES["wasser"]
|
||||
if not solid.any():
|
||||
return labels
|
||||
components, count = ndi.label(solid, structure=np.ones((3, 3)))
|
||||
sizes = ndi.sum(solid, components, range(1, count + 1))
|
||||
mainland = int(np.argmax(sizes)) + 1
|
||||
natural = np.zeros(labels.shape, dtype=bool)
|
||||
for name in NATURAL_CLASSES:
|
||||
natural |= labels == CLASSES[name]
|
||||
shares = ndi.mean(natural.astype(np.float32), components, range(1, count + 1))
|
||||
drop = np.zeros(count + 1, dtype=bool)
|
||||
for index in range(1, count + 1):
|
||||
drop[index] = index != mainland and shares[index - 1] <= LABEL_MAX_NATURAL
|
||||
out = labels.copy()
|
||||
out[drop[components]] = CLASSES["wasser"]
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test laufen lassen, er muss bestehen**
|
||||
|
||||
Run: `cd tools/extract && uv run pytest tests/test_classify.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: In `run.py` einhängen**
|
||||
|
||||
In `main()` direkt nach `labels = classify(mosaic)` einfügen:
|
||||
|
||||
```python
|
||||
labels = drop_map_labels(labels)
|
||||
```
|
||||
|
||||
Der Import kommt zu der bestehenden Zeile aus `leonida.classify` dazu. Die Prüfung der
|
||||
Klassenanteile steht danach und misst damit die bereinigten Anteile — das ist gewollt.
|
||||
|
||||
- [ ] **Step 6: Durchlauf und Sichtprüfung**
|
||||
|
||||
Run: `cd tools/extract && uv run python run.py && uv run python overview.py`
|
||||
Expected: 144 Chunks, und in `debug/overview.png` steht kein Schriftzug mehr in der Bucht.
|
||||
|
||||
Die Sichtprüfung ist hier die eigentliche Abnahme, nicht die Unit-Tests. Sieh dir das Bild an
|
||||
und beschreib, was du siehst: ob "VICE CITY (MIAMI)" verschwunden ist, ob die roten Ortsnamen
|
||||
weg sind, und vor allem, ob dabei etwas Echtes mitverschwunden ist — kleine Inseln, Molen,
|
||||
Hafenanlagen. Eine vollständig gepflasterte kleine Insel ohne Grün wäre der Fall, in dem die
|
||||
Regel zu viel nimmt. Wenn du so etwas findest, melde es mit Position und Größe, statt die
|
||||
Schwelle zu verschieben.
|
||||
|
||||
Beschriftungen, die auf dem Festland liegen (etwa "BOHEMIA (DESIGN DISTRICT)"), gehören zur
|
||||
Hauptlandmasse und bleiben bestehen. Das ist bewusst so: sie sind klein und stehen zwischen
|
||||
echten Gebäuden, statt als 1.600-m-Schriftzug im Wasser. Melde im Bericht, wie störend sie im
|
||||
Bild noch wirken.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/extract/leonida/classify.py tools/extract/tests/test_classify.py \
|
||||
tools/extract/run.py public/data/vice-a
|
||||
git commit -m "fix(extract): Kartenbeschriftung nicht mehr als Geometrie ausgeben
|
||||
|
||||
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Geometrie-Baustein in JavaScript
|
||||
|
||||
**Files:**
|
||||
@@ -1518,7 +1680,7 @@ Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Chunk-JSON aus Task 6
|
||||
- Produces: `buildChunkGeometry(chunk) -> {ground, sand, park, urban, roads, bridges, buildings}` (je eine `BufferGeometry` oder `null`), `extrudePolygons(rings, {base, top, uvScale}) -> BufferGeometry`, `flatPolygons(rings, {y, uvScale}) -> BufferGeometry`
|
||||
- Produces: `buildChunkGeometry(chunk) -> {ground, sand, park, urban, roads, bridges, buildings}` (je eine `BufferGeometry` oder `null`), `extrudePolygons(polygons, {base, top, uvScale}) -> BufferGeometry`, `flatPolygons(polygons, {y, uvScale}) -> BufferGeometry` — je Fläche eine Liste von Ringen, Außenring zuerst
|
||||
|
||||
Reines JavaScript, nur `three` als Abhängigkeit, kein Zugriff auf `document` oder `window` — der Baustein muss in Node laufen.
|
||||
|
||||
@@ -1532,10 +1694,13 @@ import { Box3, BufferGeometry } from "three";
|
||||
import { buildChunkGeometry, extrudePolygons, flatPolygons } from "../src/world/geometry.js";
|
||||
|
||||
const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10]];
|
||||
const PLOT = [SQUARE]; // Fläche ohne Loch: nur der Außenring
|
||||
const COURTYARD = [[[0, 0], [30, 0], [30, 30], [0, 30]],
|
||||
[[10, 10], [20, 10], [20, 20], [10, 20]]]; // Block mit Innenhof
|
||||
|
||||
describe("geometry", () => {
|
||||
it("extrudiert ein Quadrat zu Wänden und Dach", () => {
|
||||
const geo = extrudePolygons([SQUARE], { base: 0, top: 20, uvScale: 4 });
|
||||
const geo = extrudePolygons([PLOT], { base: 0, top: 20, uvScale: 4 });
|
||||
// 4 Wände à 2 Dreiecke + Dach aus 2 Dreiecken
|
||||
expect(geo.getIndex().count / 3).toBe(10);
|
||||
const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
|
||||
@@ -1544,15 +1709,22 @@ describe("geometry", () => {
|
||||
});
|
||||
|
||||
it("richtet Dachflächen nach oben aus", () => {
|
||||
const geo = extrudePolygons([SQUARE], { base: 0, top: 5, uvScale: 4 });
|
||||
const geo = extrudePolygons([PLOT], { base: 0, top: 5, uvScale: 4 });
|
||||
geo.computeVertexNormals();
|
||||
const n = geo.getAttribute("normal");
|
||||
const up = [...Array(n.count).keys()].filter((i) => n.getY(i) > 0.9);
|
||||
expect(up.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lässt den Innenhof eines Blocks offen", () => {
|
||||
// Außenring 30x30, Loch 10x10: 8 Wände à 2 Dreiecke, das Dach ist ein Ring aus
|
||||
// 8 Dreiecken. Ohne Lochbehandlung wäre der Hof zugedeckt und das Dach hätte 2.
|
||||
const geo = extrudePolygons([COURTYARD], { base: 0, top: 10, uvScale: 4 });
|
||||
expect(geo.getIndex().count / 3).toBe(24);
|
||||
});
|
||||
|
||||
it("legt flache Flächen auf die gewünschte Höhe", () => {
|
||||
const geo = flatPolygons([SQUARE], { y: 0.03, uvScale: 8 });
|
||||
const geo = flatPolygons([PLOT], { y: 0.03, uvScale: 8 });
|
||||
const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
|
||||
expect(box.min.y).toBeCloseTo(0.03);
|
||||
expect(box.max.y).toBeCloseTo(0.03);
|
||||
@@ -1561,11 +1733,11 @@ describe("geometry", () => {
|
||||
it("baut alle Teile eines Chunks", () => {
|
||||
const chunk = {
|
||||
chunk: [0, 0], origin: [0, 0],
|
||||
land: [SQUARE],
|
||||
surfaces: [{ poly: SQUARE, kind: "sand" }, { poly: SQUARE, kind: "park" }],
|
||||
roads: [{ poly: SQUARE, bridge: false }, { poly: SQUARE, bridge: true }],
|
||||
land: [PLOT],
|
||||
surfaces: [{ poly: PLOT, kind: "sand" }, { poly: PLOT, kind: "park" }],
|
||||
roads: [{ poly: PLOT, bridge: false }, { poly: PLOT, bridge: true }],
|
||||
centerlines: [], palms: [],
|
||||
buildings: [{ poly: SQUARE, height: 12, source: "district", speculative: false }],
|
||||
buildings: [{ poly: PLOT, height: 12, source: "district", speculative: false }],
|
||||
};
|
||||
const out = buildChunkGeometry(chunk);
|
||||
for (const key of ["ground", "sand", "park", "roads", "bridges", "buildings"]) {
|
||||
@@ -1576,12 +1748,12 @@ describe("geometry", () => {
|
||||
|
||||
it("gibt Gebäuden eine Farbe je Haus", () => {
|
||||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], palms: [],
|
||||
buildings: [{ poly: SQUARE, height: 9, source: "district", speculative: false }] };
|
||||
buildings: [{ poly: PLOT, height: 9, source: "district", speculative: false }] };
|
||||
expect(buildChunkGeometry(chunk).buildings.getAttribute("color")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hält alle Koordinaten im Chunk", () => {
|
||||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [SQUARE], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] };
|
||||
const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] };
|
||||
const box = new Box3().setFromBufferAttribute(buildChunkGeometry(chunk).ground.getAttribute("position"));
|
||||
expect(box.min.x).toBeGreaterThanOrEqual(-0.01);
|
||||
expect(box.max.x).toBeLessThanOrEqual(250.01);
|
||||
@@ -1606,8 +1778,8 @@ const BRIDGE_THICKNESS = 0.6;
|
||||
export const SURFACE_KINDS = ["sand", "park", "urban"];
|
||||
|
||||
/** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */
|
||||
function buildingTint(ring) {
|
||||
const [x, z] = ring[0];
|
||||
function buildingTint(poly) {
|
||||
const [x, z] = poly[0][0];
|
||||
const h = Math.abs(Math.sin(x * 12.9898 + z * 78.233) * 43758.5453) % 1;
|
||||
return [0.82 + 0.18 * h, 0.80 + 0.20 * ((h * 3) % 1), 0.78 + 0.22 * ((h * 7) % 1)];
|
||||
}
|
||||
@@ -1630,44 +1802,57 @@ function toGeometry(pos, idx, uv, color) {
|
||||
return geo;
|
||||
}
|
||||
|
||||
export function flatPolygons(rings, { y = 0, uvScale = 8 } = {}) {
|
||||
export function flatPolygons(polygons, { y = 0, uvScale = 8 } = {}) {
|
||||
const pos = [], idx = [], uv = [];
|
||||
for (const raw of rings) {
|
||||
const ring = clockwise(raw);
|
||||
for (const raw of polygons) {
|
||||
const outer = clockwise(raw[0]);
|
||||
const holes = raw.slice(1).map((hole) => clockwise(hole).reverse());
|
||||
const base = pos.length / 3;
|
||||
for (const [x, z] of ring) {
|
||||
pos.push(x, y, z);
|
||||
uv.push(x / uvScale, z / uvScale);
|
||||
for (const ring of [outer, ...holes]) {
|
||||
for (const [x, z] of ring) {
|
||||
pos.push(x, y, z);
|
||||
uv.push(x / uvScale, z / uvScale);
|
||||
}
|
||||
}
|
||||
for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
|
||||
const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z }));
|
||||
for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) {
|
||||
idx.push(base + tri[0], base + tri[2], base + tri[1]); // nach oben gedreht
|
||||
}
|
||||
}
|
||||
return pos.length ? toGeometry(pos, idx, uv) : null;
|
||||
}
|
||||
|
||||
export function extrudePolygons(rings, { base = 0, top = 10, uvScale = 4, tint = null } = {}) {
|
||||
export function extrudePolygons(polygons, { base = 0, top = 10, uvScale = 4, tint = null } = {}) {
|
||||
const pos = [], idx = [], uv = [], color = [];
|
||||
for (const raw of rings) {
|
||||
const ring = clockwise(raw);
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [x0, z0] = ring[i];
|
||||
const [x1, z1] = ring[(i + 1) % n];
|
||||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||||
const b = pos.length / 3;
|
||||
pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0);
|
||||
uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale);
|
||||
if (tint) for (let k = 0; k < 4; k++) color.push(...tint);
|
||||
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
|
||||
for (const raw of polygons) {
|
||||
// Jede Fläche ist eine Liste von Ringen: Außenring zuerst, danach die Löcher.
|
||||
// triangulateShape erwartet die Löcher gegenläufig zum Außenring.
|
||||
const outer = clockwise(raw[0]);
|
||||
const holes = raw.slice(1).map((hole) => clockwise(hole).reverse());
|
||||
for (const ring of [outer, ...holes]) {
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [x0, z0] = ring[i];
|
||||
const [x1, z1] = ring[(i + 1) % n];
|
||||
const len = Math.hypot(x1 - x0, z1 - z0);
|
||||
const b = pos.length / 3;
|
||||
pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0);
|
||||
uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale);
|
||||
if (tint) for (let k = 0; k < 4; k++) color.push(...tint);
|
||||
idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
|
||||
}
|
||||
}
|
||||
// Deckfläche: erst alle Punkte in derselben Reihenfolge, die triangulateShape indiziert.
|
||||
const b = pos.length / 3;
|
||||
for (const [x, z] of ring) {
|
||||
pos.push(x, top, z);
|
||||
uv.push(x / uvScale, z / uvScale);
|
||||
if (tint) color.push(...tint);
|
||||
for (const ring of [outer, ...holes]) {
|
||||
for (const [x, z] of ring) {
|
||||
pos.push(x, top, z);
|
||||
uv.push(x / uvScale, z / uvScale);
|
||||
if (tint) color.push(...tint);
|
||||
}
|
||||
}
|
||||
for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
|
||||
const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z }));
|
||||
for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) {
|
||||
idx.push(b + tri[0], b + tri[2], b + tri[1]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user