mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-04-04 14:37:24 +02:00
feat: Introduce TextureAtlasLayer for efficient texture management and rendering
refactor: Streamline WebGL2LayerClass methods and remove unused code refactor: Consolidate relief icon rendering logic and remove benchmarks
This commit is contained in:
parent
dc6ff785ba
commit
70e3eea4d1
8 changed files with 251 additions and 312 deletions
168
src/modules/texture-atlas-layer.ts
Normal file
168
src/modules/texture-atlas-layer.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
type Group,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
SRGBColorSpace,
|
||||
type Texture,
|
||||
TextureLoader,
|
||||
} from "three";
|
||||
|
||||
export interface AtlasConfig {
|
||||
url: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface AtlasQuad {
|
||||
atlasId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
s: number;
|
||||
tileIndex: number;
|
||||
}
|
||||
|
||||
export class TextureAtlasLayer {
|
||||
private group: Group | null = null;
|
||||
private readonly textureCache = new Map<string, Texture>();
|
||||
private readonly atlases: Record<string, AtlasConfig>;
|
||||
|
||||
constructor(id: string, atlases: Record<string, AtlasConfig>) {
|
||||
this.atlases = atlases;
|
||||
for (const [atlasId, config] of Object.entries(atlases)) {
|
||||
this.preloadTexture(atlasId, config.url);
|
||||
}
|
||||
WebGLLayer.register({
|
||||
id,
|
||||
setup: (group) => {
|
||||
this.group = group;
|
||||
},
|
||||
dispose: () => {
|
||||
this.disposeAll();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
draw(quads: AtlasQuad[]) {
|
||||
if (!this.group) return;
|
||||
this.disposeGroup();
|
||||
|
||||
const byAtlas = new Map<string, AtlasQuad[]>();
|
||||
for (const q of quads) {
|
||||
let arr = byAtlas.get(q.atlasId);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
byAtlas.set(q.atlasId, arr);
|
||||
}
|
||||
arr.push(q);
|
||||
}
|
||||
|
||||
for (const [atlasId, atlasQuads] of byAtlas) {
|
||||
const texture = this.textureCache.get(atlasId);
|
||||
const config = this.atlases[atlasId];
|
||||
if (!texture || !config) continue;
|
||||
this.group.add(buildMesh(atlasQuads, config, texture));
|
||||
}
|
||||
WebGLLayer.rerender();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.disposeGroup();
|
||||
WebGLLayer.rerender();
|
||||
}
|
||||
|
||||
private preloadTexture(atlasId: string, url: string) {
|
||||
new TextureLoader().load(
|
||||
url,
|
||||
(texture) => {
|
||||
texture.flipY = false;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
texture.minFilter = LinearMipmapLinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
this.textureCache.set(atlasId, texture);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
ERROR && console.error(`TextureAtlasLayer: failed to load "${url}"`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private disposeGroup() {
|
||||
if (!this.group) return;
|
||||
this.group.traverse((obj) => {
|
||||
if (obj instanceof Mesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as MeshBasicMaterial).dispose();
|
||||
}
|
||||
});
|
||||
this.group.clear();
|
||||
}
|
||||
|
||||
private disposeAll() {
|
||||
this.disposeGroup();
|
||||
for (const tex of this.textureCache.values()) tex.dispose();
|
||||
this.textureCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function buildMesh(
|
||||
quads: AtlasQuad[],
|
||||
atlas: AtlasConfig,
|
||||
texture: Texture,
|
||||
): Mesh {
|
||||
const { cols, rows } = atlas;
|
||||
const positions = new Float32Array(quads.length * 4 * 3);
|
||||
const uvs = new Float32Array(quads.length * 4 * 2);
|
||||
const indices = new Uint32Array(quads.length * 6);
|
||||
|
||||
let vi = 0,
|
||||
ii = 0;
|
||||
for (const q of quads) {
|
||||
const col = q.tileIndex % cols;
|
||||
const row = Math.floor(q.tileIndex / cols);
|
||||
const u0 = col / cols,
|
||||
u1 = (col + 1) / cols;
|
||||
const v0 = row / rows,
|
||||
v1 = (row + 1) / rows;
|
||||
const x1 = q.x + q.s,
|
||||
y1 = q.y + q.s;
|
||||
const base = vi;
|
||||
positions.set([q.x, q.y, 0], vi * 3);
|
||||
uvs.set([u0, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, q.y, 0], vi * 3);
|
||||
uvs.set([u1, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([q.x, y1, 0], vi * 3);
|
||||
uvs.set([u0, v1], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, y1, 0], vi * 3);
|
||||
uvs.set([u1, v1], vi * 2);
|
||||
vi++;
|
||||
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
|
||||
ii += 6;
|
||||
}
|
||||
|
||||
const geo = new BufferGeometry();
|
||||
geo.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
|
||||
geo.setIndex(new BufferAttribute(indices, 1));
|
||||
|
||||
return new Mesh(
|
||||
geo,
|
||||
new MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -34,8 +34,6 @@ export class WebGL2LayerClass {
|
|||
this.scene = new Scene();
|
||||
this.camera = new OrthographicCamera(0, graphWidth, 0, graphHeight, -1, 1);
|
||||
|
||||
svg.on("zoom.webgl", () => this.requestRender());
|
||||
|
||||
// Process pre-init registrations (register() before init() is explicitly safe)
|
||||
for (const config of this.pendingConfigs) {
|
||||
const group = new Group();
|
||||
|
|
@ -48,56 +46,6 @@ export class WebGL2LayerClass {
|
|||
return true;
|
||||
}
|
||||
|
||||
register(config: WebGLLayerConfig) {
|
||||
if (!this.scene) {
|
||||
// init() has not been called yet — queue for processing in init()
|
||||
this.pendingConfigs.push(config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-init registration: create group immediately
|
||||
const group = new Group();
|
||||
// group.renderOrder = config.renderOrder;
|
||||
config.setup(group);
|
||||
this.scene.add(group);
|
||||
this.layers.set(config.id, { config, group });
|
||||
}
|
||||
|
||||
unregister(id: string) {
|
||||
const layer = this.layers.get(id);
|
||||
if (!layer || !this.scene) return;
|
||||
const scene = this.scene;
|
||||
layer.config.dispose(layer.group);
|
||||
scene.remove(layer.group);
|
||||
this.layers.delete(id);
|
||||
const anyVisible = [...this.layers.values()].some((l) => l.group.visible);
|
||||
if (this.canvas && !anyVisible) this.canvas.style.display = "none";
|
||||
}
|
||||
|
||||
setVisible(id: string, visible: boolean) {
|
||||
const layer = this.layers.get(id);
|
||||
if (!layer) return;
|
||||
layer.group.visible = visible;
|
||||
const anyVisible = [...this.layers.values()].some((l) => l.group.visible);
|
||||
if (this.canvas) this.canvas.style.display = anyVisible ? "block" : "none";
|
||||
if (visible) this.requestRender();
|
||||
}
|
||||
|
||||
clearLayer(id: string) {
|
||||
const layer = this.layers.get(id);
|
||||
if (!layer) return;
|
||||
layer.group.clear();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
requestRender() {
|
||||
if (this.rafId !== null) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = null;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private syncTransform() {
|
||||
if (!this.camera) return;
|
||||
const x = -viewX / scale;
|
||||
|
|
@ -121,6 +69,28 @@ export class WebGL2LayerClass {
|
|||
}
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
register(config: WebGLLayerConfig) {
|
||||
if (!this.scene) {
|
||||
// init() has not been called yet — queue for processing in init()
|
||||
this.pendingConfigs.push(config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-init registration: create group immediately
|
||||
const group = new Group();
|
||||
config.setup(group);
|
||||
this.scene.add(group);
|
||||
this.layers.set(config.id, { config, group });
|
||||
}
|
||||
|
||||
rerender() {
|
||||
if (this.rafId !== null) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = null;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { BufferAttribute, BufferGeometry } from "three";
|
||||
import { bench, describe } from "vitest";
|
||||
import { RELIEF_SYMBOLS } from "../config/relief-config";
|
||||
import type { ReliefIcon } from "../modules/relief-generator";
|
||||
|
||||
// Standalone geometry harness — mirrors production buildSetMesh() without modifying source.
|
||||
// BufferGeometry and BufferAttribute are pure-JS objects; no GPU/WebGL context required.
|
||||
function buildSetMeshBench(
|
||||
entries: Array<{ icon: ReliefIcon; tileIndex: number }>,
|
||||
set: string,
|
||||
): BufferGeometry {
|
||||
const ids = RELIEF_SYMBOLS[set] ?? [];
|
||||
const n = ids.length || 1;
|
||||
const cols = Math.ceil(Math.sqrt(n));
|
||||
const rows = Math.ceil(n / cols);
|
||||
const positions = new Float32Array(entries.length * 4 * 3);
|
||||
const uvs = new Float32Array(entries.length * 4 * 2);
|
||||
const indices = new Uint32Array(entries.length * 6);
|
||||
let vi = 0;
|
||||
let ii = 0;
|
||||
for (const { icon: r, tileIndex } of entries) {
|
||||
const col = tileIndex % cols;
|
||||
const row = Math.floor(tileIndex / cols);
|
||||
const u0 = col / cols;
|
||||
const u1 = (col + 1) / cols;
|
||||
const v0 = row / rows;
|
||||
const v1 = (row + 1) / rows;
|
||||
const x0 = r.x;
|
||||
const x1 = r.x + r.s;
|
||||
const y0 = r.y;
|
||||
const y1 = r.y + r.s;
|
||||
const base = vi;
|
||||
positions.set([x0, y0, 0], vi * 3);
|
||||
uvs.set([u0, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, y0, 0], vi * 3);
|
||||
uvs.set([u1, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([x0, y1, 0], vi * 3);
|
||||
uvs.set([u0, v1], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, y1, 0], vi * 3);
|
||||
uvs.set([u1, v1], vi * 2);
|
||||
vi++;
|
||||
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
|
||||
ii += 6;
|
||||
}
|
||||
const geo = new BufferGeometry();
|
||||
geo.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
|
||||
geo.setIndex(new BufferAttribute(indices, 1));
|
||||
return geo;
|
||||
}
|
||||
|
||||
function makeIcons(n: number): Array<{ icon: ReliefIcon; tileIndex: number }> {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
icon: {
|
||||
i,
|
||||
href: "#relief-mount-1",
|
||||
x: (i % 100) * 10,
|
||||
y: Math.floor(i / 100) * 10,
|
||||
s: 8,
|
||||
},
|
||||
tileIndex: i % 9,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("draw-relief-icons geometry build benchmarks", () => {
|
||||
bench("buildSetMesh — 1,000 icons (NFR-P1 proxy)", () => {
|
||||
buildSetMeshBench(makeIcons(1000), "simple");
|
||||
});
|
||||
|
||||
bench("buildSetMesh — 10,000 icons (NFR-P2 proxy)", () => {
|
||||
buildSetMeshBench(makeIcons(10000), "simple");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,175 +1,33 @@
|
|||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
type Group,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
SRGBColorSpace,
|
||||
type Texture,
|
||||
TextureLoader,
|
||||
} from "three";
|
||||
import { RELIEF_SYMBOLS } from "../config/relief-config";
|
||||
import type { ReliefIcon } from "../modules/relief-generator";
|
||||
import { generateRelief } from "../modules/relief-generator";
|
||||
import { TextureAtlasLayer } from "../modules/texture-atlas-layer";
|
||||
import { byId } from "../utils";
|
||||
|
||||
const textureCache = new Map<string, Texture>(); // set name → Texture
|
||||
let terrainGroup: Group | null = null;
|
||||
let lastBuiltIcons: ReliefIcon[] | null = null;
|
||||
let lastBuiltSet: string | null = null;
|
||||
const atlases = Object.fromEntries(
|
||||
Object.entries(RELIEF_SYMBOLS).map(([set, ids]) => {
|
||||
const n = ids.length || 1;
|
||||
const cols = Math.ceil(Math.sqrt(n));
|
||||
return [
|
||||
set,
|
||||
{ url: `images/relief/${set}.png`, cols, rows: Math.ceil(n / cols) },
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
WebGLLayer.register({
|
||||
id: "terrain",
|
||||
setup(group: Group): void {
|
||||
terrainGroup = group;
|
||||
for (const set of Object.keys(RELIEF_SYMBOLS)) loadTexture(set);
|
||||
},
|
||||
dispose(group: Group): void {
|
||||
group.traverse((obj) => {
|
||||
if (obj instanceof Mesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as MeshBasicMaterial).map?.dispose();
|
||||
(obj.material as MeshBasicMaterial).dispose();
|
||||
}
|
||||
});
|
||||
for (const tex of textureCache.values()) tex?.dispose();
|
||||
textureCache.clear();
|
||||
},
|
||||
});
|
||||
const terrainLayer = new TextureAtlasLayer("terrain", atlases);
|
||||
let lastDrawnIcons: ReliefIcon[] | null = null;
|
||||
|
||||
function loadTexture(set: string): Promise<Texture | null> {
|
||||
if (textureCache.has(set))
|
||||
return Promise.resolve(textureCache.get(set) ?? null);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const loader = new TextureLoader();
|
||||
loader.load(
|
||||
`images/relief/${set}.png`,
|
||||
(texture) => {
|
||||
texture.flipY = false;
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.needsUpdate = true;
|
||||
texture.minFilter = LinearMipmapLinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
textureCache.set(set, texture);
|
||||
resolve(texture);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
ERROR && console.error(`Relief: failed to load atlas for "${set}"`);
|
||||
resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// map a symbol href to its atlas set and tile index
|
||||
function resolveSprite(symbolHref: string): {
|
||||
set: string;
|
||||
tileIndex: number;
|
||||
} {
|
||||
const id = symbolHref.startsWith("#") ? symbolHref.slice(1) : symbolHref;
|
||||
for (const [set, ids] of Object.entries(RELIEF_SYMBOLS)) {
|
||||
const idx = ids.indexOf(id);
|
||||
if (idx !== -1) return { set, tileIndex: idx };
|
||||
}
|
||||
throw new Error(`Relief: unknown symbol href "${symbolHref}"`);
|
||||
}
|
||||
|
||||
// Build a Mesh with all icon quads for one atlas set.
|
||||
function buildSetMesh(
|
||||
entries: Array<{ icon: ReliefIcon; tileIndex: number }>,
|
||||
set: string,
|
||||
texture: Texture,
|
||||
): Mesh {
|
||||
const ids = RELIEF_SYMBOLS[set] ?? [];
|
||||
const n = ids.length || 1;
|
||||
const cols = Math.ceil(Math.sqrt(n));
|
||||
const rows = Math.ceil(n / cols);
|
||||
|
||||
const positions = new Float32Array(entries.length * 4 * 3);
|
||||
const uvs = new Float32Array(entries.length * 4 * 2);
|
||||
const indices = new Uint32Array(entries.length * 6);
|
||||
|
||||
let vi = 0,
|
||||
ii = 0;
|
||||
for (const { icon: r, tileIndex } of entries) {
|
||||
const col = tileIndex % cols;
|
||||
const row = Math.floor(tileIndex / cols);
|
||||
const u0 = col / cols,
|
||||
u1 = (col + 1) / cols;
|
||||
const v0 = row / rows,
|
||||
v1 = (row + 1) / rows;
|
||||
const x0 = r.x,
|
||||
x1 = r.x + r.s;
|
||||
const y0 = r.y,
|
||||
y1 = r.y + r.s;
|
||||
const base = vi;
|
||||
positions.set([x0, y0, 0], vi * 3);
|
||||
uvs.set([u0, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, y0, 0], vi * 3);
|
||||
uvs.set([u1, v0], vi * 2);
|
||||
vi++;
|
||||
positions.set([x0, y1, 0], vi * 3);
|
||||
uvs.set([u0, v1], vi * 2);
|
||||
vi++;
|
||||
positions.set([x1, y1, 0], vi * 3);
|
||||
uvs.set([u1, v1], vi * 2);
|
||||
vi++;
|
||||
indices.set([base, base + 1, base + 3, base, base + 3, base + 2], ii);
|
||||
ii += 6;
|
||||
}
|
||||
|
||||
const geo = new BufferGeometry();
|
||||
geo.setAttribute("position", new BufferAttribute(positions, 3));
|
||||
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
|
||||
geo.setIndex(new BufferAttribute(indices, 1));
|
||||
|
||||
const mat = new MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
return new Mesh(geo, mat);
|
||||
}
|
||||
|
||||
function buildReliefScene(icons: ReliefIcon[]): void {
|
||||
if (!terrainGroup) return;
|
||||
terrainGroup.traverse((obj) => {
|
||||
if (obj instanceof Mesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as MeshBasicMaterial).dispose();
|
||||
function resolveQuads(icons: ReliefIcon[]) {
|
||||
return icons.map((r) => {
|
||||
const id = r.href.startsWith("#") ? r.href.slice(1) : r.href;
|
||||
for (const [set, ids] of Object.entries(RELIEF_SYMBOLS)) {
|
||||
const tileIndex = ids.indexOf(id);
|
||||
if (tileIndex !== -1)
|
||||
return { atlasId: set, x: r.x, y: r.y, s: r.s, tileIndex };
|
||||
}
|
||||
throw new Error(`Relief: unknown symbol href "${r.href}"`);
|
||||
});
|
||||
terrainGroup.clear();
|
||||
|
||||
const bySet = new Map<
|
||||
string,
|
||||
Array<{ icon: ReliefIcon; tileIndex: number }>
|
||||
>();
|
||||
for (const r of icons) {
|
||||
const { set, tileIndex } = resolveSprite(r.href);
|
||||
let arr = bySet.get(set);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
bySet.set(set, arr);
|
||||
}
|
||||
arr.push({ icon: r, tileIndex });
|
||||
}
|
||||
|
||||
for (const [set, setEntries] of bySet) {
|
||||
const texture = textureCache.get(set);
|
||||
if (!texture) continue;
|
||||
terrainGroup.add(buildSetMesh(setEntries, set, texture));
|
||||
}
|
||||
}
|
||||
|
||||
function drawSvg(icons: ReliefIcon[], parentEl: HTMLElement): void {
|
||||
|
|
@ -196,30 +54,21 @@ window.drawRelief = (
|
|||
if (type === "svg") {
|
||||
drawSvg(icons, parentEl);
|
||||
} else {
|
||||
const set = parentEl.getAttribute("set") || "simple";
|
||||
if (icons !== lastBuiltIcons || set !== lastBuiltSet) {
|
||||
buildReliefScene(icons);
|
||||
lastBuiltIcons = icons;
|
||||
lastBuiltSet = set;
|
||||
if (icons !== lastDrawnIcons) {
|
||||
terrainLayer.draw(resolveQuads(icons));
|
||||
lastDrawnIcons = icons;
|
||||
}
|
||||
WebGLLayer.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
window.undrawRelief = () => {
|
||||
WebGLLayer.clearLayer("terrain");
|
||||
lastBuiltIcons = null;
|
||||
lastBuiltSet = null;
|
||||
terrainLayer.clear();
|
||||
lastDrawnIcons = null;
|
||||
const terrainEl = byId("terrain");
|
||||
if (terrainEl) terrainEl.innerHTML = "";
|
||||
};
|
||||
|
||||
window.rerenderReliefIcons = () => {
|
||||
WebGLLayer.requestRender();
|
||||
};
|
||||
|
||||
declare global {
|
||||
var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
|
||||
var undrawRelief: () => void;
|
||||
var rerenderReliefIcons: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,9 +91,4 @@ declare global {
|
|||
var viewY: number;
|
||||
var changeFont: () => void;
|
||||
var getFriendlyHeight: (coords: [number, number]) => string;
|
||||
|
||||
var WebGLLayer: import("../modules/webgl-layer").WebGL2LayerClass;
|
||||
var drawRelief: (type?: "svg" | "webGL", parentEl?: HTMLElement) => void;
|
||||
var undrawRelief: () => void;
|
||||
var rerenderReliefIcons: () => void;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue