fix(B07): 구조물이 깎은 설계선을 그리고, 한 변을 한 선으로 만든다
구조물을 세워도 설계선이 원래대로 지나갔다. 정본(design_line)에 담긴 건 트림 전 원본이고, 구조물이 깎아 내는 부분은 B06이 그릴 때 계산하기 때문이다. 수확기가 appendCrossDesignOverlay를 구조물 layout의 designTrim과 함께 태우고, 그 블록의 서버 설계선은 걷어낸다. 걷어낼 자리를 알도록 서버가 블록 테두리를 함께 내려준다. 곧은 사면 하나가 CAD에서 선 열댓 개였던 것은 B06이 설계선 점 사이마다 line 하나를 만들기 때문이다. 수확한 뒤 끝점이 맞물리는 선분을 잇고 공선점을 지운다(RDP). 측점 20m 18조각 → 폴리라인 3개(선분 7). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -523,6 +523,14 @@ def build_cross_drawing(
|
||||
"mm_per_m": CROSS_MM,
|
||||
"x0": x0,
|
||||
"x1": x1,
|
||||
# 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울
|
||||
# 서버 설계선을 이 안에서만 골라내는 데 쓴다.
|
||||
"frame": [
|
||||
center_x - frame_x,
|
||||
frame_bottom,
|
||||
center_x + frame_x,
|
||||
frame_top,
|
||||
],
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
DEFAULT_FORD_WALL_ADJUST,
|
||||
} from "../B06_Section/B06_Section_UI_Cross_Ford_Geom";
|
||||
import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
||||
import {
|
||||
appendCrossDesignOverlay,
|
||||
appendPavementOverlay,
|
||||
} from "../B06_Section/B06_Section_UI_Cross_Design";
|
||||
import {
|
||||
appendRevetmentOverlay,
|
||||
computeRevetmentLayout,
|
||||
@@ -54,6 +58,8 @@ export interface CrossPlacement {
|
||||
mm_per_m: number;
|
||||
x0: number;
|
||||
x1: number;
|
||||
/** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */
|
||||
frame?: number[];
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
@@ -61,6 +67,9 @@ const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
|
||||
const STRUCTURE_LAYER_ID = "b08-structure";
|
||||
const STRUCTURE_COLOR = "#f6d55c";
|
||||
/** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */
|
||||
const DESIGN_LAYER_ID = "b08-design";
|
||||
const DESIGN_COLOR = "#b794f6";
|
||||
/** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */
|
||||
const LABEL_FONT_MM = 2.0;
|
||||
/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */
|
||||
@@ -71,51 +80,147 @@ const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]";
|
||||
|
||||
type Entity = Record<string, unknown>;
|
||||
type XY = [number, number];
|
||||
/** 수확한 도형을 어느 도면층·색으로 넣을지. */
|
||||
interface Style {
|
||||
layerId: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function baseEntity(type: string, shapeData: unknown): Entity {
|
||||
const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR };
|
||||
const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR };
|
||||
|
||||
function baseEntity(style: Style, type: string, shapeData: unknown): Entity {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
lineColor: STRUCTURE_COLOR,
|
||||
lineColor: style.color,
|
||||
lineWidth: 1,
|
||||
layerId: STRUCTURE_LAYER_ID,
|
||||
layerId: style.layerId,
|
||||
shapeData,
|
||||
};
|
||||
}
|
||||
|
||||
function lineEntity(start: XY, end: XY): Entity {
|
||||
return baseEntity("Line", {
|
||||
function lineEntity(style: Style, start: XY, end: XY): Entity {
|
||||
return baseEntity(style, "Line", {
|
||||
startPoint: { x: start[0], y: start[1] },
|
||||
endPoint: { x: end[0], y: end[1] },
|
||||
});
|
||||
}
|
||||
|
||||
/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */
|
||||
function polyEntity(points: XY[]): Entity | null {
|
||||
function polyEntity(style: Style, points: XY[]): Entity | null {
|
||||
if (points.length < 2) return null;
|
||||
const children: Entity[] = [];
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
children.push(lineEntity(points[index], points[index + 1]));
|
||||
children.push(lineEntity(style, points[index], points[index + 1]));
|
||||
}
|
||||
const poly = baseEntity("PolyLine", null);
|
||||
const poly = baseEntity(style, "PolyLine", null);
|
||||
poly.children = children;
|
||||
return poly;
|
||||
}
|
||||
|
||||
function textEntity(label: string, at: XY, align: string): Entity {
|
||||
return baseEntity("Text", {
|
||||
function textEntity(style: Style, label: string, at: XY, align: string): Entity {
|
||||
return baseEntity(style, "Text", {
|
||||
label,
|
||||
basePoint: { x: at[0], y: at[1] },
|
||||
options: {
|
||||
textDirection: { x: 1, y: 0 },
|
||||
textAlign: align,
|
||||
textColor: STRUCTURE_COLOR,
|
||||
textColor: style.color,
|
||||
fontSize: LABEL_FONT_MM,
|
||||
fontFamily: "sans-serif",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 공선점 제거(Ramer–Douglas–Peucker) — 설계선은 지반 샘플마다 점을 갖고 있어(측점 하나에
|
||||
* 83점) 곧은 사면이 수십 조각으로 쪼개진다. CAD에서 한 변은 한 선이어야 잡고 고칠 수 있다.
|
||||
* 허용 오차는 **종이 mm**라, 1/100에서 0.05mm = 실거리 5mm — 도면상 같은 직선이다.
|
||||
*/
|
||||
function simplify(points: XY[], tolerance = 0.05): XY[] {
|
||||
if (points.length < 3) return points;
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const [dx, dy] = [last[0] - first[0], last[1] - first[1]];
|
||||
const span = Math.hypot(dx, dy);
|
||||
let worst = 0;
|
||||
let index = 0;
|
||||
for (let i = 1; i < points.length - 1; i += 1) {
|
||||
const [px, py] = points[i];
|
||||
const distance =
|
||||
span > 1e-9
|
||||
? Math.abs(dy * px - dx * py + last[0] * first[1] - last[1] * first[0]) / span
|
||||
: Math.hypot(px - first[0], py - first[1]);
|
||||
if (distance > worst) {
|
||||
worst = distance;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
if (worst <= tolerance) return [first, last];
|
||||
return [
|
||||
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
|
||||
...simplify(points.slice(index), tolerance),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝점이 맞물리는 선분들을 하나의 점열로 잇는다.
|
||||
*
|
||||
* B06 설계선 그리기는 **점 사이마다 `<line>` 하나**를 만든다(_Cross_Design 603행) —
|
||||
* 그대로 옮기면 곧은 사면 한 변이 CAD에서 선 열댓 개가 된다. 이어 붙인 뒤 공선점을
|
||||
* 지우면 한 변이 한 선이 된다(2026-08-30 사용자 지적).
|
||||
*/
|
||||
function chain(segments: XY[][], tolerance = 1e-3): XY[][] {
|
||||
const used = new Array(segments.length).fill(false);
|
||||
const near = (a: XY, b: XY): boolean =>
|
||||
Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance;
|
||||
const chains: XY[][] = [];
|
||||
for (let seed = 0; seed < segments.length; seed += 1) {
|
||||
if (used[seed]) continue;
|
||||
used[seed] = true;
|
||||
const points: XY[] = [segments[seed][0], segments[seed][1]];
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
if (used[i]) continue;
|
||||
const [a, b] = segments[i];
|
||||
const head = points[0];
|
||||
const tail = points[points.length - 1];
|
||||
if (near(tail, a)) points.push(b);
|
||||
else if (near(tail, b)) points.push(a);
|
||||
else if (near(head, b)) points.unshift(a);
|
||||
else if (near(head, a)) points.unshift(b);
|
||||
else continue;
|
||||
used[i] = true;
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
chains.push(points);
|
||||
}
|
||||
return chains;
|
||||
}
|
||||
|
||||
/** 점열을 종이 x범위로 자른다 — 경계는 선형보간으로 새 점을 만든다(서버 _clip_polyline과 같은 규칙). */
|
||||
function clipX(points: XY[], x0: number, x1: number): XY[] {
|
||||
const clipped: XY[] = [];
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const [x, y] = points[index];
|
||||
if (index > 0) {
|
||||
const [px, py] = points[index - 1];
|
||||
for (const edge of [x0, x1]) {
|
||||
if ((px < edge && edge < x) || (x < edge && edge < px)) {
|
||||
const ratio = (edge - px) / (x - px);
|
||||
clipped.push([edge, py + (y - py) * ratio]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (x >= x0 && x <= x1) clipped.push([x, y]);
|
||||
}
|
||||
return clipped;
|
||||
}
|
||||
|
||||
function attr(element: SVGElement, name: string): number {
|
||||
return Number(element.getAttribute(name) ?? 0);
|
||||
}
|
||||
@@ -136,9 +241,12 @@ function parsePoints(element: SVGElement): XY[] {
|
||||
return points;
|
||||
}
|
||||
|
||||
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다. */
|
||||
function harvest(root: SVGElement): Entity[] {
|
||||
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
|
||||
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
|
||||
const [fx0, , fx1] = frame;
|
||||
const entities: Entity[] = [];
|
||||
const segments: XY[][] = [];
|
||||
const inside = (px: number): boolean => px >= fx0 && px <= fx1;
|
||||
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, text");
|
||||
for (const element of Array.from(nodes)) {
|
||||
if (element.closest(SKIP_SELECTOR)) continue;
|
||||
@@ -146,29 +254,55 @@ function harvest(root: SVGElement): Entity[] {
|
||||
if (tag === "polygon" || tag === "polyline") {
|
||||
const points = parsePoints(element);
|
||||
if (tag === "polygon" && points.length > 2) points.push(points[0]);
|
||||
const poly = polyEntity(points);
|
||||
const poly = polyEntity(style, simplify(clipX(points, fx0, fx1)));
|
||||
if (poly) entities.push(poly);
|
||||
} else if (tag === "line") {
|
||||
entities.push(
|
||||
lineEntity(
|
||||
flip(attr(element, "x1"), attr(element, "y1")),
|
||||
flip(attr(element, "x2"), attr(element, "y2")),
|
||||
),
|
||||
);
|
||||
const start = flip(attr(element, "x1"), attr(element, "y1"));
|
||||
const end = flip(attr(element, "x2"), attr(element, "y2"));
|
||||
const cut = clipX([start, end], fx0, fx1);
|
||||
if (cut.length === 2) segments.push(cut);
|
||||
} else if (tag === "circle") {
|
||||
const [cx, cy] = flip(attr(element, "cx"), attr(element, "cy"));
|
||||
entities.push(baseEntity("Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") }));
|
||||
if (!inside(cx)) continue;
|
||||
entities.push(
|
||||
baseEntity(style, "Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") }),
|
||||
);
|
||||
} else if (tag === "text") {
|
||||
const label = (element.textContent ?? "").trim();
|
||||
if (!label) continue;
|
||||
const at = flip(attr(element, "x"), attr(element, "y"));
|
||||
if (!label || !inside(at[0])) continue;
|
||||
const anchor = element.getAttribute("text-anchor");
|
||||
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
|
||||
entities.push(textEntity(label, flip(attr(element, "x"), attr(element, "y")), align));
|
||||
entities.push(textEntity(style, label, at, align));
|
||||
}
|
||||
}
|
||||
// 낱개 선분은 이어 붙인 뒤 공선점을 지워 한 변을 한 선으로 만든다.
|
||||
for (const points of chain(segments)) {
|
||||
const poly = polyEntity(style, simplify(points));
|
||||
if (poly) entities.push(poly);
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
/** 엔티티가 차지하는 종이 좌표 사각형 (없으면 null). */
|
||||
function entityBox(entity: Record<string, unknown>): number[] | null {
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
const walk = (node: Record<string, unknown>): void => {
|
||||
const shape = node.shapeData as Record<string, Record<string, number>> | null;
|
||||
for (const key of ["startPoint", "endPoint", "basePoint", "center"]) {
|
||||
const point = shape?.[key];
|
||||
if (point) {
|
||||
xs.push(point.x);
|
||||
ys.push(point.y);
|
||||
}
|
||||
}
|
||||
for (const child of (node.children as Record<string, unknown>[]) ?? []) walk(child);
|
||||
};
|
||||
walk(entity);
|
||||
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -205,16 +339,10 @@ const inletStructure: InletStructureControl = {
|
||||
resetAdjust: () => undefined,
|
||||
};
|
||||
|
||||
/** 한 측점의 구조물을 오프스크린 SVG에 그린다. 그리는 순서는 B06 카드와 같다. */
|
||||
function drawStructures(
|
||||
svg: SVGElement,
|
||||
section: CrossSection,
|
||||
sections: CrossSection[],
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): void {
|
||||
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
|
||||
function computeLayouts(section: CrossSection, sections: CrossSection[]) {
|
||||
const design = section.design;
|
||||
if (!design) return;
|
||||
if (!design) return null;
|
||||
const designZAt = (chainageM: number): number | null => {
|
||||
const found = sections.find(
|
||||
(item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M,
|
||||
@@ -222,7 +350,7 @@ function drawStructures(
|
||||
return found?.design?.design_elevation_m ?? null;
|
||||
};
|
||||
const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt);
|
||||
const culvertLayout = computeCardCulvert(
|
||||
const culvert = computeCardCulvert(
|
||||
section,
|
||||
section.samples,
|
||||
null,
|
||||
@@ -231,31 +359,68 @@ function drawStructures(
|
||||
extraWalls,
|
||||
link,
|
||||
);
|
||||
const boxLayout = computeBoxLayout(section, section.samples, {
|
||||
const box = computeBoxLayout(section, section.samples, {
|
||||
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
|
||||
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
|
||||
});
|
||||
const fordLayout = computeFordLayout(section, section.samples, {
|
||||
const ford = computeFordLayout(section, section.samples, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
|
||||
});
|
||||
|
||||
appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
|
||||
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
|
||||
if (!section.culvert && !link) {
|
||||
appendRevetmentOverlay(svg, computeRevetmentLayout(section, design.revet_adjust?.own), x, y);
|
||||
}
|
||||
if (boxLayout) appendBoxOverlay(svg, boxLayout, x, y);
|
||||
if (fordLayout) appendFordOverlay(svg, fordLayout, x, y);
|
||||
if (culvertLayout) {
|
||||
const own =
|
||||
!section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null;
|
||||
return { design, link, culvert, box, ford, own };
|
||||
}
|
||||
|
||||
type Layouts = NonNullable<ReturnType<typeof computeLayouts>>;
|
||||
|
||||
/**
|
||||
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은
|
||||
* 우선순위로 넘긴다 — 정본에는 트림 전 원본만 있어서, 이걸 안 태우면 벽이 서 있어도
|
||||
* 설계선이 원래대로 지나간다(2026-08-30 사용자 지적).
|
||||
*/
|
||||
function drawDesign(
|
||||
svg: SVGElement,
|
||||
section: CrossSection,
|
||||
layouts: Layouts,
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): void {
|
||||
const { design, culvert, box, ford, own } = layouts;
|
||||
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
|
||||
if (!paved) appendPavementOverlay(svg, design, x, y);
|
||||
appendCrossDesignOverlay(
|
||||
svg,
|
||||
design,
|
||||
x,
|
||||
y,
|
||||
section.samples,
|
||||
culvert?.designTrim ?? ford?.designTrim ?? box?.designTrim ?? own?.designTrim,
|
||||
);
|
||||
}
|
||||
|
||||
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */
|
||||
function drawStructures(
|
||||
svg: SVGElement,
|
||||
section: CrossSection,
|
||||
layouts: Layouts,
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): void {
|
||||
const { link, culvert, box, ford, own } = layouts;
|
||||
if (own) appendRevetmentOverlay(svg, own, x, y);
|
||||
if (box) appendBoxOverlay(svg, box, x, y);
|
||||
if (ford) appendFordOverlay(svg, ford, x, y);
|
||||
if (culvert) {
|
||||
const linked = !section.culvert && !!link;
|
||||
appendCulvertOverlay(
|
||||
svg,
|
||||
culvertLayout,
|
||||
culvert,
|
||||
x,
|
||||
y,
|
||||
undefined,
|
||||
linked || culvertLayout.culvert.hidden_pipe === true,
|
||||
linked || culvert.culvert.hidden_pipe === true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -279,27 +444,49 @@ export async function appendStructureEntities(
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
const fresh: Entity[] = [];
|
||||
const replaced: number[][] = [];
|
||||
for (const placement of placements) {
|
||||
const section = sections.find(
|
||||
(item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M,
|
||||
);
|
||||
if (!section) continue;
|
||||
if (!section.culvert && !section.ford && !section.box && !section.ford_pavement) {
|
||||
// 연동으로 옆에서 이어져 온 기슭막이는 세트가 없어도 그려야 하므로 정본만 더 본다.
|
||||
if (!section.revetment && !sections.some((item) => item.culvert)) continue;
|
||||
}
|
||||
const frame = placement.frame;
|
||||
if (!section || !frame) continue;
|
||||
const x = (offset: number): number => offset * placement.mm_per_m + placement.ox;
|
||||
const y = (elevation: number): number =>
|
||||
-((elevation - placement.dy) * placement.mm_per_m + placement.oy);
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
let entities: Entity[];
|
||||
try {
|
||||
drawStructures(svg, section, sections, x, y);
|
||||
const layouts = computeLayouts(section, sections);
|
||||
if (!layouts) continue;
|
||||
const designSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawDesign(designSvg, section, layouts, x, y);
|
||||
const structureSvg = document.createElementNS(SVG_NS, "svg");
|
||||
drawStructures(structureSvg, section, layouts, x, y);
|
||||
entities = [
|
||||
...harvest(designSvg, DESIGN_STYLE, frame),
|
||||
...harvest(structureSvg, STRUCTURE_STYLE, frame),
|
||||
];
|
||||
} catch {
|
||||
continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다.
|
||||
}
|
||||
const entities = harvest(svg);
|
||||
drawing.entities.push(...entities);
|
||||
if (!entities.length) continue;
|
||||
// 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다.
|
||||
replaced.push(frame);
|
||||
fresh.push(...entities);
|
||||
added += entities.length;
|
||||
}
|
||||
if (replaced.length) {
|
||||
// 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다.
|
||||
drawing.entities = drawing.entities.filter((entity) => {
|
||||
if (entity.layerId !== DESIGN_LAYER_ID) return true;
|
||||
const box = entityBox(entity);
|
||||
if (!box) return true;
|
||||
return !replaced.some(
|
||||
([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1,
|
||||
);
|
||||
});
|
||||
}
|
||||
drawing.entities.push(...fresh);
|
||||
return added;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user