Stream-parse 3MF files so Bambu projects don't exhaust memory
trimesh's 3MF loader re-reads a whole production-extension part file for every component that points into it. A Bambu Studio project with 884 components across four ~50 MB part files grew past 3 GB within seconds and failed both the preview conversion and the geometry fingerprint. - Add tools/threemf_stream.py: parses each part once with lxml iterparse, resolves each component to the single object it names, and returns each mesh once plus its placements. The same project now converts in ~8 s at ~380 MB peak (fingerprinting ~190 MB). - convert_3mf.py and fingerprint.py use it; the GLB stores each object once and instances repeats. Stats are computed per placement (mirrored placements keep a positive volume), without concatenating meshes. - Run sidecar scripts through one queue (lib/python.js) so a preview and background fingerprinting never load big meshes at the same time; previews go first, and 3MFs are fingerprinted one per run. - Surface the converter's JSON error / timeout instead of "Command failed". - Tests with a Bambu-style component fixture (shared part, mirrored placement, decoy object) and STL/3MF fingerprint parity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
00a4b4ba07
commit
502eefdec2
11 changed files with 337 additions and 71 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ thumbnails/
|
|||
.env
|
||||
glb/
|
||||
tools/venv/
|
||||
__pycache__/
|
||||
|
|
|
|||
|
|
@ -1,32 +1,22 @@
|
|||
// Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can
|
||||
// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't).
|
||||
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
|
||||
const SCRIPT = path.join(__dirname, '..', 'tools', 'convert_3mf.py');
|
||||
const { runScript } = require('./python');
|
||||
|
||||
// Returns { glb, volume_cm3, bbox, faces } or throws.
|
||||
function convert3mfToGlb(inputPath, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
PYTHON,
|
||||
[SCRIPT, inputPath, outputPath],
|
||||
{ maxBuffer: 16 * 1024 * 1024, timeout: 120000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) return reject(new Error(stderr ? stderr.split('\n').slice(-3).join(' ') : err.message));
|
||||
let result;
|
||||
async function convert3mfToGlb(inputPath, outputPath) {
|
||||
const { err, stdout, stderr } = await runScript('convert_3mf.py', [inputPath, outputPath], { timeout: 120000 });
|
||||
let result = null;
|
||||
try {
|
||||
result = JSON.parse(stdout.trim().split('\n').pop());
|
||||
} catch {
|
||||
return reject(new Error('Converter returned unparseable output.'));
|
||||
} catch { /* handled below */ }
|
||||
if (result && result.error) throw new Error(result.error);
|
||||
if (err) {
|
||||
if (err.killed) throw new Error('Timed out converting this 3MF for preview.');
|
||||
throw new Error(stderr ? stderr.trim().split('\n').slice(-3).join(' ') : err.message);
|
||||
}
|
||||
if (result.error) return reject(new Error(result.error));
|
||||
resolve(result);
|
||||
}
|
||||
);
|
||||
});
|
||||
if (!result) throw new Error('Converter returned unparseable output.');
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { convert3mfToGlb };
|
||||
|
|
|
|||
|
|
@ -6,11 +6,9 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const db = require('./db');
|
||||
const { PYTHON, runScript } = require('./python');
|
||||
|
||||
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
|
||||
const SCRIPT = path.join(__dirname, '..', 'tools', 'fingerprint.py');
|
||||
const GEOMETRY_FORMATS = ['stl', 'obj', '3mf'];
|
||||
const BATCH = 10;
|
||||
|
||||
|
|
@ -45,39 +43,37 @@ function geometryKey(fp) {
|
|||
|
||||
// Runs the trimesh sidecar over some files. Resolves Map(absPath -> {fp}|{error});
|
||||
// output from a run that timed out or crashed part-way is still returned.
|
||||
function measure(paths) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
PYTHON,
|
||||
[SCRIPT, ...paths],
|
||||
{ maxBuffer: 4 * 1024 * 1024, timeout: 60000 * (paths.length + 1) },
|
||||
(err, stdout) => {
|
||||
if (err && err.code === 'ENOENT') return reject(err);
|
||||
async function measure(paths) {
|
||||
const { err, stdout } = await runScript('fingerprint.py', paths,
|
||||
{ timeout: 60000 * (paths.length + 1), isBackground: true });
|
||||
if (err && err.code === 'ENOENT') throw err;
|
||||
const results = new Map();
|
||||
for (const line of String(stdout || '').split('\n')) {
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const r = JSON.parse(line);
|
||||
results.set(r.path, r);
|
||||
} catch { /* ignore non-JSON noise */ }
|
||||
}
|
||||
resolve(results);
|
||||
}
|
||||
);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fingerprintGeometry(rows) {
|
||||
const abs = (row) => path.resolve(row.file_path);
|
||||
const results = await measure(rows.map(abs));
|
||||
for (const row of rows) {
|
||||
// 3MF projects can be big, so each gets its own run; that keeps a queued
|
||||
// preview from waiting behind a whole batch of them.
|
||||
const chunks = [rows.filter((r) => r.format !== '3mf'), ...rows.filter((r) => r.format === '3mf').map((r) => [r])];
|
||||
for (const chunk of chunks.filter((c) => c.length)) {
|
||||
const results = await measure(chunk.map(abs));
|
||||
for (const row of chunk) {
|
||||
let r = results.get(abs(row));
|
||||
// A batch that died part-way: retry the missing file on its own.
|
||||
if (!r && rows.length > 1) r = (await measure([abs(row)])).get(abs(row));
|
||||
if (!r && chunk.length > 1) r = (await measure([abs(row)])).get(abs(row));
|
||||
const key = r && r.fp ? geometryKey(r.fp) : null;
|
||||
await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1',
|
||||
[row.id, key, key ? 'ok' : 'error']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
|
|
|
|||
42
lib/python.js
Normal file
42
lib/python.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Runs the Python/trimesh sidecar scripts one at a time. Loading a large mesh
|
||||
// can take hundreds of MB, so a preview conversion and background
|
||||
// fingerprinting never run at once. Jobs someone is waiting on (previews) go
|
||||
// ahead of queued background work.
|
||||
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
|
||||
const TOOLS = path.join(__dirname, '..', 'tools');
|
||||
|
||||
const urgent = [];
|
||||
const background = [];
|
||||
let busy = false;
|
||||
|
||||
// Resolves { err, stdout, stderr } — never rejects, so callers can use partial
|
||||
// output from a run that failed part-way.
|
||||
function runScript(script, args, { timeout = 120000, isBackground = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
(isBackground ? background : urgent).push({ script, args, timeout, resolve });
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (busy) return;
|
||||
const job = urgent.shift() || background.shift();
|
||||
if (!job) return;
|
||||
busy = true;
|
||||
execFile(
|
||||
PYTHON,
|
||||
[path.join(TOOLS, job.script), ...job.args],
|
||||
{ maxBuffer: 16 * 1024 * 1024, timeout: job.timeout },
|
||||
(err, stdout, stderr) => {
|
||||
busy = false;
|
||||
job.resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
||||
next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { PYTHON, runScript };
|
||||
BIN
test/fixtures/components.3mf
vendored
Normal file
BIN
test/fixtures/components.3mf
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/cube.stl
vendored
Normal file
BIN
test/fixtures/cube.stl
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/cube_rotated.3mf
vendored
Normal file
BIN
test/fixtures/cube_rotated.3mf
vendored
Normal file
Binary file not shown.
55
test/geometry.test.js
Normal file
55
test/geometry.test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Exercises the Python sidecar scripts against small fixtures. Skipped when the
|
||||
// Python venv isn't installed.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { PYTHON } = require('../lib/python');
|
||||
const { geometryKey } = require('../lib/fingerprint');
|
||||
|
||||
const TOOLS = path.join(__dirname, '..', 'tools');
|
||||
const fixture = (name) => path.join(__dirname, 'fixtures', name);
|
||||
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
|
||||
|
||||
function fingerprints(...names) {
|
||||
const out = execFileSync(PYTHON, [path.join(TOOLS, 'fingerprint.py'), ...names.map(fixture)], { encoding: 'utf8' });
|
||||
return out.trim().split('\n').map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
test('a cube matches across binary STL and a rotated, moved 3MF', { skip }, () => {
|
||||
const [stl, threemf] = fingerprints('cube.stl', 'cube_rotated.3mf');
|
||||
assert.ok(stl.fp && threemf.fp, JSON.stringify([stl, threemf]));
|
||||
assert.equal(threemf.fp.faces, 12);
|
||||
assert.equal(geometryKey(stl.fp), geometryKey(threemf.fp));
|
||||
});
|
||||
|
||||
// components.3mf is laid out like a Bambu project: the root model's objects are
|
||||
// components pointing (via p:path) at object 1 of a separate part file, which
|
||||
// also holds a decoy object 2. One placement is mirrored.
|
||||
test('3MF components resolve to the one object they reference, once per placement', { skip }, () => {
|
||||
const [r] = fingerprints('components.3mf');
|
||||
assert.ok(r.fp, JSON.stringify(r));
|
||||
assert.equal(r.fp.faces, 24);
|
||||
assert.ok(Math.abs(r.fp.volume - 16000) < 1e-6, `volume ${r.fp.volume}`);
|
||||
assert.deepEqual(r.fp.extents.map(Math.round).sort((a, b) => a - b), [10, 40, 120]);
|
||||
});
|
||||
|
||||
test('3MF converts to GLB with shared geometry and a combined bounding box', { skip }, () => {
|
||||
const out = path.join(os.tmpdir(), `printhive-test-${process.pid}.glb`);
|
||||
try {
|
||||
const stdout = execFileSync(PYTHON, [path.join(TOOLS, 'convert_3mf.py'), fixture('components.3mf'), out], { encoding: 'utf8' });
|
||||
const summary = JSON.parse(stdout.trim().split('\n').pop());
|
||||
assert.equal(summary.faces, 24);
|
||||
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [120, 40, 10]);
|
||||
assert.ok(Math.abs(summary.volume_cm3 - 16) < 1e-6);
|
||||
|
||||
const glb = fs.readFileSync(out);
|
||||
const gltf = JSON.parse(glb.subarray(20, 20 + glb.readUInt32LE(12)).toString('utf8'));
|
||||
assert.equal(gltf.meshes.length, 1, 'the object is stored once');
|
||||
assert.equal(gltf.nodes.filter((n) => n.mesh !== undefined).length, 2, 'and placed twice');
|
||||
} finally {
|
||||
fs.rmSync(out, { force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -6,8 +6,11 @@ Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
|
|||
"""
|
||||
import sys
|
||||
import json
|
||||
|
||||
import trimesh
|
||||
|
||||
import threemf_stream
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
|
|
@ -15,28 +18,38 @@ def main():
|
|||
return 2
|
||||
|
||||
in_path, out_path = sys.argv[1], sys.argv[2]
|
||||
scene = trimesh.load(in_path, force="scene")
|
||||
geoms = list(scene.geometry.values())
|
||||
if not geoms:
|
||||
try:
|
||||
meshes, placements = threemf_stream.load(in_path)
|
||||
except Exception as exc:
|
||||
print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"}))
|
||||
return 1
|
||||
if not placements:
|
||||
print(json.dumps({"error": "no geometry found in 3MF"}))
|
||||
return 1
|
||||
|
||||
# Each object is stored once; repeated placements become instances of it.
|
||||
scene = trimesh.Scene()
|
||||
added = set()
|
||||
for i, (key, transform) in enumerate(placements):
|
||||
node = f"part{i}"
|
||||
if key in added:
|
||||
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=transform, geometry=key)
|
||||
else:
|
||||
v, f = meshes[key]
|
||||
scene.add_geometry(trimesh.Trimesh(vertices=v, faces=f, process=False),
|
||||
node_name=node, geom_name=key, transform=transform)
|
||||
added.add(key)
|
||||
scene.export(out_path, file_type="glb")
|
||||
|
||||
combined = trimesh.util.concatenate(geoms)
|
||||
ext = combined.bounds[1] - combined.bounds[0]
|
||||
summary = {
|
||||
stats = threemf_stream.measure(meshes, placements)
|
||||
ext = stats["extents"]
|
||||
print(json.dumps({
|
||||
"glb": out_path,
|
||||
"bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)},
|
||||
"faces": int(sum(len(g.faces) for g in geoms)),
|
||||
}
|
||||
try:
|
||||
# trimesh volume is in model units (mm) -> cm^3 to match the rest of the app
|
||||
summary["volume_cm3"] = round(float(combined.volume) / 1000.0, 3)
|
||||
except Exception:
|
||||
summary["volume_cm3"] = None
|
||||
|
||||
print(json.dumps(summary))
|
||||
"bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)},
|
||||
"faces": stats["faces"],
|
||||
# mm^3 -> cm^3 to match the rest of the app
|
||||
"volume_cm3": round(stats["volume"] / 1000.0, 3),
|
||||
}))
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,19 @@ Multi-object files (e.g. 3MF projects) are measured as one combined mesh.
|
|||
"""
|
||||
import sys
|
||||
import json
|
||||
|
||||
import trimesh
|
||||
|
||||
import threemf_stream
|
||||
|
||||
|
||||
def measure(path):
|
||||
if path.lower().endswith(".3mf"):
|
||||
stats = threemf_stream.measure(*threemf_stream.load(path))
|
||||
if not stats["faces"]:
|
||||
raise ValueError("no geometry found")
|
||||
return stats
|
||||
|
||||
mesh = trimesh.load(path, force="mesh")
|
||||
if mesh is None or len(mesh.faces) == 0:
|
||||
raise ValueError("no geometry found")
|
||||
|
|
|
|||
160
tools/threemf_stream.py
Normal file
160
tools/threemf_stream.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Streaming 3MF reader shared by convert_3mf.py and fingerprint.py.
|
||||
|
||||
trimesh's 3MF loader re-reads a whole production-extension part file (every
|
||||
mesh in it) for each component that points into that file. Bambu Studio
|
||||
projects are hundreds of components pointing into a few large part files, so
|
||||
the geometry multiplies until the process runs out of memory.
|
||||
|
||||
This reader parses each part file once, element by element, resolves each
|
||||
component to the one object it names, and returns every object's mesh once
|
||||
plus the transforms where it is placed — so parts reused across plates share
|
||||
their geometry.
|
||||
"""
|
||||
import zipfile
|
||||
from array import array
|
||||
|
||||
import numpy as np
|
||||
from lxml import etree
|
||||
|
||||
UNIT_MM = {"micron": 0.001, "millimeter": 1.0, "centimeter": 10.0, "inch": 25.4, "foot": 304.8, "meter": 1000.0}
|
||||
PARSER = dict(huge_tree=True, resolve_entities=False, no_network=True)
|
||||
MAX_DEPTH = 32
|
||||
|
||||
|
||||
def _local(name):
|
||||
return name.rpartition("}")[2]
|
||||
|
||||
|
||||
def _path_attr(el):
|
||||
# The production extension's p:path, whatever prefix the file bound it to.
|
||||
for key, value in el.attrib.items():
|
||||
if _local(key) == "path":
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _matrix(text):
|
||||
m = np.eye(4)
|
||||
if text:
|
||||
v = [float(x) for x in text.split()]
|
||||
if len(v) == 12:
|
||||
# 3MF lists "m00 m01 m02 m10 ... m32" for row vectors; store column form.
|
||||
m[:3, :3] = np.array(v[:9]).reshape(3, 3).T
|
||||
m[:3, 3] = v[9:12]
|
||||
return m
|
||||
|
||||
|
||||
def _parse_part(fh):
|
||||
"""Returns (objects, build items, unit) for one model part.
|
||||
|
||||
objects: {id: (vertices (n,3) float64, faces (m,3) int32, [(path|None, objectid, 4x4)])}
|
||||
"""
|
||||
objects, items, unit = {}, [], None
|
||||
oid = verts = tris = comps = None
|
||||
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
|
||||
name = _local(el.tag)
|
||||
if event == "start":
|
||||
if name == "model":
|
||||
unit = el.get("unit")
|
||||
elif name == "object":
|
||||
oid, verts, tris, comps = el.get("id"), array("d"), array("i"), []
|
||||
continue
|
||||
|
||||
if name == "vertex":
|
||||
verts.extend((float(el.get("x")), float(el.get("y")), float(el.get("z"))))
|
||||
elif name == "triangle":
|
||||
tris.extend((int(el.get("v1")), int(el.get("v2")), int(el.get("v3"))))
|
||||
elif name == "component":
|
||||
comps.append((_path_attr(el), el.get("objectid"), _matrix(el.get("transform"))))
|
||||
elif name == "item":
|
||||
items.append((el.get("objectid"), _matrix(el.get("transform"))))
|
||||
elif name == "object":
|
||||
v = np.frombuffer(verts, dtype=np.float64).reshape(-1, 3)
|
||||
f = np.frombuffer(tris, dtype=np.int32).reshape(-1, 3)
|
||||
if len(f) and (f.min() < 0 or f.max() >= len(v)):
|
||||
f = f[:0] # corrupt indices: drop the mesh rather than crash later
|
||||
objects[oid] = (v, f, comps)
|
||||
|
||||
# Free what's been read so memory stays flat on huge parts.
|
||||
el.clear()
|
||||
parent = el.getparent()
|
||||
while parent is not None and el.getprevious() is not None:
|
||||
del parent[0]
|
||||
return objects, items, unit
|
||||
|
||||
|
||||
def _root_part(z):
|
||||
try:
|
||||
rels = etree.fromstring(z.read("_rels/.rels"), etree.XMLParser(resolve_entities=False, no_network=True))
|
||||
for rel in rels:
|
||||
if rel.get("Type", "").endswith("/3dmodel"):
|
||||
return rel.get("Target", "").lstrip("/")
|
||||
except (KeyError, etree.XMLSyntaxError):
|
||||
pass
|
||||
return "3D/3dmodel.model"
|
||||
|
||||
|
||||
def load(path):
|
||||
"""Returns (meshes, placements).
|
||||
|
||||
meshes: {key: (vertices, faces)} — each object once, in file units.
|
||||
placements: [(key, 4x4 transform)] — one per placed instance, mapping to millimetres.
|
||||
"""
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = set(z.namelist())
|
||||
parts = {}
|
||||
|
||||
def part(name):
|
||||
name = name.lstrip("/")
|
||||
if name not in parts:
|
||||
if name not in names:
|
||||
raise ValueError(f"3MF references a missing part: {name}")
|
||||
with z.open(name) as fh:
|
||||
parts[name] = _parse_part(fh)
|
||||
return parts[name]
|
||||
|
||||
root = _root_part(z)
|
||||
_, items, unit = part(root)
|
||||
scale = np.diag([UNIT_MM.get(unit or "millimeter", 1.0)] * 3 + [1.0])
|
||||
meshes, placements = {}, []
|
||||
|
||||
def walk(name, oid, transform, depth):
|
||||
if depth > MAX_DEPTH:
|
||||
raise ValueError("3MF components nest too deeply")
|
||||
obj = part(name)[0].get(oid)
|
||||
if obj is None:
|
||||
return # undefined reference; slicers skip these too
|
||||
v, f, comps = obj
|
||||
if len(f):
|
||||
key = f"{name}#{oid}"
|
||||
meshes.setdefault(key, (v, f))
|
||||
placements.append((key, transform))
|
||||
for cpath, coid, m in comps:
|
||||
walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1)
|
||||
|
||||
for oid, m in items:
|
||||
walk(root, oid, scale @ m, 0)
|
||||
return meshes, placements
|
||||
|
||||
|
||||
def measure(meshes, placements):
|
||||
"""Faces, surface area (mm^2), volume (mm^3) and extents (mm) of everything
|
||||
placed, as if concatenated into one mesh — computed per instance to keep
|
||||
memory flat."""
|
||||
faces, area, volume = 0, 0.0, 0.0
|
||||
lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
|
||||
for key, t in placements:
|
||||
v, f = meshes[key]
|
||||
w = v @ t[:3, :3].T + t[:3, 3]
|
||||
a, b, c = w[f[:, 0]], w[f[:, 1]], w[f[:, 2]]
|
||||
cross = np.cross(b - a, c - a)
|
||||
faces += len(f)
|
||||
area += 0.5 * float(np.linalg.norm(cross, axis=1).sum())
|
||||
# A mirroring transform flips winding; count its volume as positive
|
||||
# (matching trimesh, which re-winds faces when applying such a transform).
|
||||
signed = float(np.einsum("ij,ij->i", a, np.cross(b, c)).sum()) / 6.0
|
||||
volume += -signed if np.linalg.det(t[:3, :3]) < 0 else signed
|
||||
if len(w):
|
||||
lo, hi = np.minimum(lo, w.min(axis=0)), np.maximum(hi, w.max(axis=0))
|
||||
extents = (hi - lo) if faces else np.zeros(3)
|
||||
return {"faces": faces, "area": area, "volume": abs(volume), "extents": [float(x) for x in extents]}
|
||||
Loading…
Reference in a new issue