From e697a730bbbd1f5a6b8789e1cd386f1821e1a8cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 22:04:04 +0100 Subject: [PATCH 1/4] Files made to harm: four ways a crafted file could crash, hang or mislead PrintView3D - 3MF: a triangle that uses a vertex that does not exist is refused. The 3D engine does not check this itself, and such a file crashed the whole program. - glTF: data must lie inside its buffer. A crafted byteStride or count read whatever followed the buffer in memory, or crashed. Triangles must use existing vertices too. - Text from a file (title, description, object names, error texts) is shown as plain text in the Details and Objects boxes, their tooltips and the error messages. It was read as HTML, so a title like was carried out instead of shown. - Loading that never ended: a glTF node listed as its own child, or 3MF objects that contain themselves or place each other without end. Nodes are visited once; 3MF objects that contain themselves are skipped and a file may place 200,000 objects at most. The macOS Quick Look reader gets the same limit. A 3MF object placed many times is now read once instead of every time, which also makes projects with many copies of one object open faster. --- .../macos/quicklook/Shared/ThreeMF.swift | 14 +- src/printview3d/loaders/formats.py | 27 +- src/printview3d/loaders/mesh.py | 51 +++- src/printview3d/ui/main_window.py | 61 +++-- tests/test_file_safety.py | 237 ++++++++++++++++++ 5 files changed, 362 insertions(+), 28 deletions(-) create mode 100644 tests/test_file_safety.py diff --git a/packaging/macos/quicklook/Shared/ThreeMF.swift b/packaging/macos/quicklook/Shared/ThreeMF.swift index 9a87426..0b8ef1c 100644 --- a/packaging/macos/quicklook/Shared/ThreeMF.swift +++ b/packaging/macos/quicklook/Shared/ThreeMF.swift @@ -9,6 +9,9 @@ import simd final class ThreeMFReader { /// A model file inside the 3MF can be large; at most this much is read. static let maxModelBytes: UInt64 = 300 << 20 + /// Objects placed through components, over the whole file (as in loaders/mesh.py): a crafted + /// file can make every object place several others, level after level, which never finishes. + static let maxPlacedObjects = 200_000 static let unitScale: [String: Float] = [ "micron": 0.001, "millimeter": 1, "centimeter": 10, "inch": 25.4, "foot": 304.8, "meter": 1000, ] @@ -26,6 +29,8 @@ final class ThreeMFReader { private var parts = [String: Part]() private var out = [Float]() private var full = false + private var placed = 0 + private var onPath = Set() // the objects being placed right now init(file: MappedFile) { self.file = file @@ -81,7 +86,12 @@ final class ThreeMFReader { } private func collect(path: String, id: String, matrix: simd_float4x4, depth: Int) { - guard depth <= 16, !full, let p = part(path), let obj = p.objects[id] else { return } + guard depth <= 16, !full else { return } + let key = Self.norm(path).lowercased() + "\n" + id + guard !onPath.contains(key) else { return } // an object that contains itself + placed += 1 + guard placed <= Self.maxPlacedObjects else { full = true; return } + guard let p = part(path), let obj = p.objects[id] else { return } let vcount = obj.vertices.count / 3 var t = 0 while t + 2 < obj.triangles.count { @@ -95,6 +105,8 @@ final class ThreeMFReader { } if out.count >= MeshLoader.maxTriangles * 9 { full = true; return } } + onPath.insert(key) + defer { onPath.remove(key) } for comp in obj.components { // 3MF uses row vectors: a component inside a placed object is placed by // (component transform) then (the object's placement). diff --git a/src/printview3d/loaders/formats.py b/src/printview3d/loaders/formats.py index 7d36b62..74b9c3f 100644 --- a/src/printview3d/loaders/formats.py +++ b/src/printview3d/loaders/formats.py @@ -439,6 +439,9 @@ def load_off(path: str, progress: Progress = None) -> ModelData: } +_GLTF_DAMAGED = "This glTF file is damaged: it refers to data that is not in the file." + + def _linear_to_srgb(c: np.ndarray) -> np.ndarray: c = np.clip(c, 0.0, 1.0) return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(c, 1 / 2.4) - 0.055) @@ -518,12 +521,20 @@ def accessor(self, i: int) -> np.ndarray: a = self.doc["accessors"][i] comps = _GL_SIZES[a["type"]] dt = np.dtype("<" + _GL_TYPES[a["componentType"]]) - count = a["count"] + count = int(a["count"]) + if count < 0: + raise ValueError(_GLTF_DAMAGED) if "bufferView" in a: bv = self.doc["bufferViews"][a["bufferView"]] buf = self.buffer(bv["buffer"]) - start = bv.get("byteOffset", 0) + a.get("byteOffset", 0) - stride = bv.get("byteStride") or dt.itemsize * comps + start = int(bv.get("byteOffset", 0)) + int(a.get("byteOffset", 0)) + stride = int(bv.get("byteStride") or dt.itemsize * comps) + # Everything that is read must lie inside the buffer. The interleaved case below reads + # by address, so without this check a crafted stride or count read whatever followed + # the buffer in memory (or crashed). + if start < 0 or stride <= 0 or ( + count and start + (count - 1) * stride + dt.itemsize * comps > len(buf)): + raise ValueError(_GLTF_DAMAGED) if stride == dt.itemsize * comps: out = np.frombuffer(buf, dt, count * comps, start).reshape(count, comps) else: # interleaved data @@ -613,9 +624,15 @@ def load_gltf(path: str, progress: Progress = None) -> ModelData: instances = [] # (name, mesh index, world matrix) + visited: set[int] = set() + def walk(i: int, parent: np.ndarray, depth: int = 0): - if depth > 64: + # glTF nodes form a tree: every node has one parent at most. A node seen before is + # skipped, so a crafted file whose nodes list themselves (or each other) as children, + # several times over, ends at once instead of never. + if depth > 64 or i in visited: return + visited.add(i) n = nodes[i] m = parent @ _node_matrix(n) if "mesh" in n: @@ -646,6 +663,8 @@ def walk(i: int, parent: np.ndarray, depth: int = 0): tris = _primitive_triangles(g, prim, len(pos)) if tris is None or not len(tris): continue + if tris.min() < 0 or tris.max() >= len(pos): + raise ValueError("This glTF file refers to vertices that do not exist.") world = pos @ mat[:3, :3].T + mat[:3, 3] if np.linalg.det(mat[:3, :3]) < 0: # mirrored: keep the outside facing out tris = tris[:, [0, 2, 1]] diff --git a/src/printview3d/loaders/mesh.py b/src/printview3d/loaders/mesh.py index 69f9970..a9f73bd 100644 --- a/src/printview3d/loaders/mesh.py +++ b/src/printview3d/loaders/mesh.py @@ -212,11 +212,19 @@ def _mesh_arrays(mesh_el) -> tuple[np.ndarray, np.ndarray]: ts = mesh_el.find(C + "triangles") if vs is None or ts is None: return np.zeros((0, 3), np.float32), np.zeros((0, 3), np.int64) - verts = np.array([(v.get("x"), v.get("y"), v.get("z")) for v in vs.iter(C + "vertex")], dtype=np.float64) - faces = np.array([(t.get("v1"), t.get("v2"), t.get("v3")) for t in ts.iter(C + "triangle")], dtype=np.int64) + try: + verts = np.array([(v.get("x"), v.get("y"), v.get("z")) for v in vs.iter(C + "vertex")], dtype=np.float64) + faces = np.array([(t.get("v1"), t.get("v2"), t.get("v3")) for t in ts.iter(C + "triangle")], dtype=np.int64) + except (TypeError, ValueError, OverflowError): + raise ValueError("This 3MF file contains a mesh with missing or invalid numbers.") from None if verts.size == 0 or faces.size == 0: return np.zeros((0, 3), np.float32), np.zeros((0, 3), np.int64) - return verts.reshape(-1, 3), faces.reshape(-1, 3) + verts, faces = verts.reshape(-1, 3), faces.reshape(-1, 3) + # Every triangle must use vertices that exist. The 3D engine does not check this itself: a + # triangle pointing outside the vertex list made the whole program crash. + if faces.min() < 0 or faces.max() >= len(verts): + raise ValueError("This 3MF file refers to vertices that do not exist.") + return verts, faces # -------------------------------------------------------------------------- @@ -238,6 +246,10 @@ def _mesh_arrays(mesh_el) -> tuple[np.ndarray, np.ndarray]: # - The 3MF core itself: or type="other". # -------------------------------------------------------------------------- NON_MODEL_TYPES = ("support", "other") +# Objects placed through components, counted over the whole file. Real projects place a few +# thousand at most; a crafted file can make every object place several others, level after +# level, which is millions of millions and never finishes. +MAX_PLACED_OBJECTS = 200000 @dataclass @@ -318,9 +330,22 @@ def get_part(part_path: str): parts_cache[part_path] = (objects, scale, root) return parts_cache[part_path] + placed = 0 + on_path: set[tuple[str, Optional[str]]] = set() # the objects being placed right now + meshes: dict[tuple[str, Optional[str]], tuple] = {} # an object placed many times is read once + n_vertices = 0 # of the build item being collected + def collect(part_path, obj_id, matrix, out_v, out_f, top_id, depth=0): + nonlocal placed, n_vertices if depth > 16: return + key = (_norm(part_path).lower(), obj_id) + if key in on_path: # an object that contains itself: the spec forbids it + return + placed += 1 + if placed > MAX_PLACED_OBJECTS: + raise ValueError("This 3MF file places far more objects than a 3D file normally does. " + "It was not opened, to protect your computer.") objects, scale, _ = get_part(part_path) obj = objects.get(obj_id) if obj is None: @@ -332,7 +357,9 @@ def collect(part_path, obj_id, matrix, out_v, out_f, top_id, depth=0): return mesh = obj.find(C + "mesh") if mesh is not None: - v, f = _mesh_arrays(mesh) + if key not in meshes: + meshes[key] = _mesh_arrays(mesh) + v, f = meshes[key] if raw and depth == 0 and _norm(part_path) == _norm(root_path) and obj_id in slicer.keep_ranges: keep = np.zeros(len(f), bool) for first, last in slicer.keep_ranges[obj_id]: @@ -342,14 +369,19 @@ def collect(part_path, obj_id, matrix, out_v, out_f, top_id, depth=0): v = v * scale vh = np.hstack([v, np.ones((len(v), 1))]) v = (vh @ matrix)[:, :3] - out_f.append(f + sum(len(a) for a in out_v)) + out_f.append(f + n_vertices) out_v.append(v.astype(np.float32)) + n_vertices += len(v) comps = obj.find(C + "components") if comps is not None: - for c in comps.iter(C + "component"): - sub_path = _path_attr(c) or part_path - m = _parse_transform(c.get("transform")) @ matrix - collect(sub_path, c.get("objectid"), m, out_v, out_f, top_id, depth + 1) + on_path.add(key) + try: + for c in comps.iter(C + "component"): + sub_path = _path_attr(c) or part_path + m = _parse_transform(c.get("transform")) @ matrix + collect(sub_path, c.get("objectid"), m, out_v, out_f, top_id, depth + 1) + finally: + on_path.discard(key) objects, _, root = get_part(root_path) build = root.find(C + "build") @@ -364,6 +396,7 @@ def collect(part_path, obj_id, matrix, out_v, out_f, top_id, depth=0): item_path = _path_attr(item) or root_path oid = item.get("objectid") out_v, out_f = [], [] + n_vertices = 0 collect(item_path, oid, _parse_transform(item.get("transform")), out_v, out_f, oid) if not out_v: continue diff --git a/src/printview3d/ui/main_window.py b/src/printview3d/ui/main_window.py index 1def670..4cd7786 100644 --- a/src/printview3d/ui/main_window.py +++ b/src/printview3d/ui/main_window.py @@ -193,6 +193,22 @@ def plain_menu_text(text: str) -> str: return text.replace("&", "&&") +def plain_tip(text: str) -> str: + """A tooltip that shows `text` as it is. Qt reads a tooltip that looks like HTML as HTML, so + a name from a file such as "" was carried out instead of shown.""" + if not Qt.mightBeRichText(text): + return text + return "" + html.escape(text).replace("\n", "
") + "
" + + +def message_box(parent, icon, title: str, text: str) -> None: + """A message whose text is shown as it is (plain text): for messages that carry a file name, + a path or an error text that came out of a file.""" + box = QMessageBox(icon, title, text, QMessageBox.Ok, parent) + box.setTextFormat(Qt.PlainText) + box.exec() + + def breakable_path(text: str) -> str: """A path that a label may wrap after each folder separator (a zero-width space after it).""" return text.replace("\\", "\\\u200b").replace("/", "/\u200b") @@ -510,11 +526,15 @@ class _ElidedLabel(QLabel): def __init__(self, text: str, parent=None): super().__init__(parent) self._full = text + self.setTextFormat(Qt.PlainText) # names from files are never formatting self.setToolTip(text) self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) self.setMinimumWidth(20) self.setText(text) + def setToolTip(self, text: str): + super().setToolTip(plain_tip(text)) + def resizeEvent(self, e): super().resizeEvent(e) self.setText(self.fontMetrics().elidedText(self._full, Qt.ElideRight, max(10, self.width()))) @@ -533,7 +553,9 @@ def __init__(self, text: str, parent=None): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(2) - self.label = QLabel(text) + self.label = QLabel() + self.label.setTextFormat(Qt.PlainText) # a description from a file is never formatting + self.label.setText(text) self.label.setWordWrap(True) self.label.setAlignment(Qt.AlignTop | Qt.AlignLeading) self.label.setTextInteractionFlags(Qt.TextSelectableByMouse) @@ -1227,6 +1249,7 @@ def _show_freecad_preview(self, m: ModelData): self.preview_image.hide() status = (m.freecad or {}).get("status") self.preview_title.setText(tr("Preview picture") if m.thumbnail else tr("No 3D view")) + self.preview_text.setTextFormat(Qt.PlainText) self.preview_text.setText(self._freecad_status_text(m)) self.preview_text.setMinimumHeight(self.preview_text.heightForWidth(520)) no_freecad = status == "no_freecad" @@ -2079,7 +2102,9 @@ def _update_details_toggle(self): self.details_body.setVisible(is_open) def _add_detail(self, key: str, value: str): - k = QLabel(key) + k = QLabel() + k.setTextFormat(Qt.PlainText) + k.setText(key) k.setObjectName("formKey") # Field names may wrap: a long one (Arabic "ملف تعريف الخيط (الفلمنت)") next to a value # that can't wrap (BambuStudio-02.03.00.70) made the panel wider than its view, and in @@ -2089,7 +2114,11 @@ def _add_detail(self, key: str, value: str): if len(text) > self.LONG_DETAIL_CHARS or "\n" in text: self.details_form.addRow(k, _LongValue(text)) return - v = QLabel(_breakable(text)) + # Values come out of the file (title, designer, printer, ...): shown as they are, never + # read as HTML. + v = QLabel() + v.setTextFormat(Qt.PlainText) + v.setText(_breakable(text)) v.setWordWrap(True) v.setTextInteractionFlags(Qt.TextSelectableByMouse) self.details_form.addRow(k, v) @@ -2562,8 +2591,8 @@ def load(self, path: str, _zip: Optional[za.ZipContents] = None, _zip_index: int path = os.path.abspath(path) self._keep_folder(path, _zip) if not os.path.isfile(path): - QMessageBox.warning(self, tr("File not found"), - tr("The file no longer exists:") + f"\n{path}") + message_box(self, QMessageBox.Warning, tr("File not found"), + tr("The file no longer exists:") + f"\n{path}") return if za.is_archive(path): self._load_archive(path) @@ -2623,7 +2652,7 @@ def _on_failed(self, token: int, msg: str): self.progress.hide() self.slicer_btn.setEnabled(True) self.statusBar().clearMessage() - QMessageBox.critical(self, tr("Could not open file"), msg) + message_box(self, QMessageBox.Critical, tr("Could not open file"), msg) def _on_loaded(self, token: int, payload): if token != self._load_token: @@ -2811,7 +2840,7 @@ def _fill_model_panel(self, m: ModelData): if size_text: tip.append(size_text) tip.append(tr("{n} triangles").format(n=count(part.n_triangles))) - row.setToolTip("\n".join(tip)) + row.setToolTip(plain_tip("\n".join(tip))) name.setToolTip(row.toolTip()) self.objects_layout.addWidget(row) self.objects_box.setTitle(tr("Objects ({n})").format(n=len(m.parts)) if many else tr("Object")) @@ -2880,13 +2909,17 @@ def _fill_freecad_box(self, m: ModelData): info = m.freecad self.embedded_box.setTitle(tr("STEP file") if info.get("source") == "step" else tr("FreeCAD document")) if m.parts: # (otherwise the preview page explains it) - text = QLabel(self._freecad_status_text(m)) + text = QLabel() + text.setTextFormat(Qt.PlainText) # may carry FreeCAD's error text + text.setText(self._freecad_status_text(m)) text.setObjectName("muted") text.setWordWrap(True) self.embedded_layout.addWidget(text) shown = info.get("shown") or [] if shown and not m.parts: - names = QLabel(tr("Visible objects: {names}").format( + names = QLabel() + names.setTextFormat(Qt.PlainText) # object names from the document + names.setText(tr("Visible objects: {names}").format( names=", ".join(o.label for o in shown))) names.setWordWrap(True) self.embedded_layout.addWidget(names) @@ -2916,7 +2949,7 @@ def open_in_freecad(self): try: fc.open_in_freecad(install, self.current_path) except OSError as e: - QMessageBox.critical(self, tr("Could not start FreeCAD"), str(e)) + message_box(self, QMessageBox.Critical, tr("Could not start FreeCAD"), str(e)) return self.statusBar().showMessage(tr("Opening {file} in {slicer}…").format( file=os.path.basename(self.current_path or ""), slicer="FreeCAD"), 6000) @@ -3883,7 +3916,7 @@ def open_in_slicer(self, slicer_id: Optional[str] = None, files: Optional[list] try: sl.open_in_slicer(exe, file_to_send) except OSError as e: - QMessageBox.critical(self, tr("Could not start {slicer}").format(slicer=name), str(e)) + message_box(self, QMessageBox.Critical, tr("Could not start {slicer}").format(slicer=name), str(e)) return if isinstance(file_to_send, list): what = tr("{n} models").format(n=len(file_to_send)) @@ -3942,7 +3975,7 @@ def _on_export_failed(self, path: str, msg: str): self.act_export_stl.setEnabled(True) self.statusBar().clearMessage() if path == self.current_path: - QMessageBox.critical(self, tr("Could not export STL"), msg) + message_box(self, QMessageBox.Critical, tr("Could not export STL"), msg) def _on_export_read(self, path: str, parts): self.act_export_stl.setEnabled(True) @@ -3986,7 +4019,7 @@ def _save_combined_stl(self, parts): try: write_binary_stl(parts, out) except OSError as e: - QMessageBox.critical(self, tr("Could not export STL"), str(e)) + message_box(self, QMessageBox.Critical, tr("Could not export STL"), str(e)) return self.statusBar().showMessage(tr("Saved {path}").format(path=out), 8000) @@ -4011,7 +4044,7 @@ def _save_stl_per_object(self, parts): for part, name in zip(parts, names): write_binary_stl([part], os.path.join(folder, name)) except OSError as e: - QMessageBox.critical(self, tr("Could not export STL"), str(e)) + message_box(self, QMessageBox.Critical, tr("Could not export STL"), str(e)) return self.statusBar().showMessage(tr("Saved {n} STL files in {folder}").format( n=count(len(parts)), folder=folder), 8000) diff --git a/tests/test_file_safety.py b/tests/test_file_safety.py new file mode 100644 index 0000000..f6bc5aa --- /dev/null +++ b/tests/test_file_safety.py @@ -0,0 +1,237 @@ +"""Files made to harm: each of these crashed PrintView3D, kept it loading for ever, read memory +that was not the file's, or had its text carried out as HTML. They must now end in an ordinary +error message, or simply show their text as it is.""" + +import base64 +import json +import os +import struct +import zipfile + +import numpy as np +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from printview3d.loaders import load_file # noqa: E402 +from printview3d.settings import Settings # noqa: E402 + +NS = 'xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02"' +RELS = ('' + '') +MESH = ('' + '') + + +def make_3mf(path, resources, build='', metadata=""): + with zipfile.ZipFile(path, "w") as z: + z.writestr("_rels/.rels", RELS) + z.writestr("3D/3dmodel.model", f'{metadata}{resources}' + f'{build}') + return str(path) + + +def make_gltf(path, doc): + doc.setdefault("asset", {"version": "2.0"}) + path.write_text(json.dumps(doc), encoding="utf-8") + return str(path) + + +def data_uri(raw: bytes) -> str: + return "data:application/octet-stream;base64," + base64.b64encode(raw).decode() + + +# ------------------------------------------------------------------ 3MF: triangles +@pytest.mark.parametrize("v3", ["3", "999999999", "-1", "-5"]) +def test_3mf_triangle_outside_the_vertex_list_is_refused(tmp_path, v3): + f = make_3mf(tmp_path / "bad.3mf", f'{MESH.format(v3=v3)}') + with pytest.raises(ValueError, match="vertices that do not exist"): + load_file(f) + + +@pytest.mark.parametrize("v3", ["", "x", "1.5"]) +def test_3mf_triangle_that_is_no_number_is_refused(tmp_path, v3): + f = make_3mf(tmp_path / "bad.3mf", f'{MESH.format(v3=v3)}') + with pytest.raises(ValueError): + load_file(f) + + +def test_3mf_with_a_good_triangle_still_opens(tmp_path): + f = make_3mf(tmp_path / "ok.3mf", f'{MESH.format(v3=2)}') + m = load_file(f) + assert m.n_triangles == 1 + + +# ------------------------------------------------------------------ 3MF: components +def test_3mf_object_that_contains_itself_ends(tmp_path): + comps = '' * 10 + f = make_3mf(tmp_path / "self.3mf", + f'{comps}') + with pytest.raises(ValueError, match="no printable objects"): + load_file(f) # at once; it used to run for ever + + +def test_3mf_objects_that_contain_each_other_end(tmp_path): + res = ('' + '' + '' + f'{MESH.format(v3=2)}') + m = load_file(make_3mf(tmp_path / "loop.3mf", res)) + assert m.n_triangles == 2 # object 3, once through each of the two ways in + + +def test_3mf_that_places_objects_without_end_is_refused(tmp_path): + # No object contains itself, but each places the next one ten times: 10^7 objects. + res = "" + for i in range(1, 8): + comps = f'' * 10 + res += f'{comps}' + res += f'{MESH.format(v3=2)}' + with pytest.raises(ValueError, match="far more objects"): + load_file(make_3mf(tmp_path / "many.3mf", res)) + + +def test_3mf_same_object_placed_several_times_still_opens(tmp_path): + comps = "".join(f'' for i in range(5)) + res = (f'{MESH.format(v3=2)}' + f'{comps}') + m = load_file(make_3mf(tmp_path / "copies.3mf", res)) + assert m.n_triangles == 5 + + +# ------------------------------------------------------------------ glTF: data outside the buffer +def _triangle_doc(**view): + raw = struct.pack("<9f", 0, 0, 0, 1, 0, 0, 0, 1, 0) + return {"buffers": [{"uri": data_uri(raw), "byteLength": len(raw)}], + "bufferViews": [dict({"buffer": 0, "byteOffset": 0, "byteLength": len(raw)}, **view)], + "accessors": [{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}], + "nodes": [{"mesh": 0}], "scenes": [{"nodes": [0]}]} + + +def test_gltf_triangle_still_opens(tmp_path): + assert load_file(make_gltf(tmp_path / "ok.gltf", _triangle_doc())).n_triangles == 1 + + +@pytest.mark.parametrize("change", [ + {"view": {"byteStride": 4096}, "count": 3000}, # read far past the 36 bytes of the buffer + {"view": {"byteStride": 16}, "count": 3}, # the last vertex ends 8 bytes too late + {"view": {}, "count": 4}, # one vertex too many + {"view": {"byteOffset": 30}, "count": 3}, + {"view": {"byteOffset": -4}, "count": 3}, + {"view": {"byteStride": -12}, "count": 3}, + {"view": {}, "count": -1}, +]) +def test_gltf_data_outside_its_buffer_is_refused(tmp_path, change): + doc = _triangle_doc(**change["view"]) + doc["accessors"][0]["count"] = change["count"] + with pytest.raises(ValueError, match="damaged"): + load_file(make_gltf(tmp_path / "bad.gltf", doc)) + + +def test_gltf_interleaved_data_inside_its_buffer_still_opens(tmp_path): + # position (12 bytes) + 4 bytes of something else per vertex: stride 16 + raw = b"".join(struct.pack("<3fI", *p, 7) for p in ((0, 0, 0), (1, 0, 0), (0, 1, 0))) + doc = _triangle_doc(byteStride=16) + doc["buffers"] = [{"uri": data_uri(raw), "byteLength": len(raw)}] + doc["bufferViews"][0]["byteLength"] = len(raw) + m = load_file(make_gltf(tmp_path / "interleaved.gltf", doc)) + assert m.n_triangles == 1 + size = m.parts[0].vertices.max(axis=0) - m.parts[0].vertices.min(axis=0) + assert sorted(np.round(size).tolist()) == [0, 1000, 1000] # metres -> millimetres + + +@pytest.mark.parametrize("index", [3, 70000, -1]) +def test_gltf_triangle_outside_the_vertex_list_is_refused(tmp_path, index): + doc = _triangle_doc() + raw = base64.b64decode(doc["buffers"][0]["uri"].split(",", 1)[1]) + struct.pack("<3i", 0, 1, index) + doc["buffers"] = [{"uri": data_uri(raw), "byteLength": len(raw)}] + doc["bufferViews"].append({"buffer": 0, "byteOffset": 36, "byteLength": 12}) + doc["accessors"].append({"bufferView": 1, "componentType": 5125, "count": 3, "type": "SCALAR"}) + doc["meshes"][0]["primitives"][0]["indices"] = 1 + with pytest.raises(ValueError, match="vertices that do not exist"): + load_file(make_gltf(tmp_path / "bad.gltf", doc)) + + +# ------------------------------------------------------------------ glTF: nodes +def test_gltf_node_that_is_its_own_child_ends(tmp_path): + doc = _triangle_doc() + doc["nodes"] = [{"mesh": 0, "children": [0, 0, 0]}] + assert load_file(make_gltf(tmp_path / "self.gltf", doc)).n_triangles == 1 # it used to run for ever + + +def test_gltf_nodes_listed_as_children_again_and_again_end(tmp_path): + doc = _triangle_doc() + n = 40 + doc["nodes"] = [{"children": [i + 1, i + 1]} for i in range(n)] + [{"mesh": 0}] + doc["scenes"] = [{"nodes": [0]}] + assert load_file(make_gltf(tmp_path / "fan.gltf", doc)).n_triangles == 1 # not 2^40 times + + +# ------------------------------------------------------------------ text from a file is only text +@pytest.fixture +def app(): + from PySide6.QtWidgets import QApplication + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def window(app, tmp_path): + from PySide6.QtCore import QEvent + from printview3d.ui import main_window as mw + w = mw.MainWindow(Settings(str(tmp_path / "settings.json"))) + w.resize(1280, 760) + w.show() + yield w + w.close() + w.deleteLater() + app.sendPostedEvents(None, QEvent.DeferredDelete) + app.processEvents() + + +HTML_TITLE = '' +HTML_NAME = "

BIG

" +HTML_LONG = "bold " + "and a long description " * 12 + + +def _html_3mf(tmp_path): + from xml.sax.saxutils import escape, quoteattr + meta = (f'{escape(HTML_TITLE)}' + f'{escape(HTML_LONG)}') + return make_3mf(tmp_path / "html.3mf", + f'{MESH.format(v3=2)}', metadata=meta) + + +def test_text_from_a_file_is_shown_as_plain_text(app, window, tmp_path): + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QLabel + window.load_sync(_html_3mf(tmp_path)) + app.processEvents() + found = set() + for label in window.findChildren(QLabel): + text = label.text().replace("​", "") + for what in (HTML_TITLE, HTML_NAME, HTML_LONG.strip()): + if what[:12] in text: + found.add(what) + assert label.textFormat() == Qt.PlainText, text + tip = label.toolTip() + assert not tip or "

" not in tip and "\n12 triangles') + assert "" in tip + + +def test_error_message_is_plain_text(app, window, monkeypatch): + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QMessageBox + seen = [] + monkeypatch.setattr(QMessageBox, "exec", lambda self: seen.append((self.textFormat(), self.text())) or 0) + window._load_token = 5 + window._on_failed(5, 'ValueError: in the ZIP is damaged.') + assert seen == [(Qt.PlainText, 'ValueError: in the ZIP is damaged.')] From f573e6c7c461ae58576646265a31a63d6f9e5b36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 22:04:04 +0100 Subject: [PATCH 2/4] Settings -> Viewer: the Build plate box in two columns Left: the plate itself (grid on/off, X/Y/Z axes, which plate, its size). Right: the grid's numbers and lines (numbers on/off, their spacing, the two line spacings). The box is half as tall, with room for more options. A window too narrow for both columns shows the right one under the left one instead of cutting it off, and Settings opens wide enough for the two columns when the screen allows it. --- src/printview3d/ui/settings_dialog.py | 152 +++++++++++++++++++++----- tests/test_check_and_settings_2.py | 6 +- tests/test_grid_labels.py | 17 ++- tests/test_viewer_settings_layout.py | 62 +++++++++++ 4 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 tests/test_viewer_settings_layout.py diff --git a/src/printview3d/ui/settings_dialog.py b/src/printview3d/ui/settings_dialog.py index 955720d..d893e92 100644 --- a/src/printview3d/ui/settings_dialog.py +++ b/src/printview3d/ui/settings_dialog.py @@ -359,8 +359,19 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): # ---------------- Viewer ---------------- # Three boxes: Build plate (the grid, its numbers and the plate), Other (the rest of the # 3D view: travel moves, view cube, Measure, Clip, Check) and Graphics. + # Build plate has two columns, so the box stays short and has room for more options: + # left: the plate itself (grid on/off, axes, which plate, its size) + # right: the grid's numbers and lines (numbers on/off, their spacing, the two line spacings) + # A window too narrow for both (a small screen, long words in some languages) shows the + # right column under the left one instead of cutting it off (_TwoColumns). g3 = QGroupBox(tr("Build plate")) - l3 = QVBoxLayout(g3) + plate_left, plate_right = QWidget(), QWidget() + ll, lr = QGridLayout(plate_left), QGridLayout(plate_right) + for lay in (ll, lr): + lay.setContentsMargins(0, 0, 0, 0) + ll.setColumnStretch(2, 1) # the left column keeps to the left + lr.setColumnStretch(1, 1) # spare width goes before the right dropdowns, + right = Qt.AlignRight | Qt.AlignVCenter # which sit at the box's edge (left in Arabic) # The settings of the toolbar's Measure, View and Check (named after those buttons). g_other = QGroupBox(tr("Measure, View and Check")) l_other = QVBoxLayout(g_other) @@ -372,23 +383,21 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): self.cube_cb.setChecked(bool(settings.get("show_view_cube", True))) l_other.addWidget(self.travel_cb) l_other.addWidget(self.cube_cb) - l3.addWidget(self.bed_cb) + ll.addWidget(self.bed_cb, 0, 0, 1, 3) # X/Y/Z arrows on the plate, beside the model (off by default). self.plate_axes_cb = QCheckBox(tr("Also show the X, Y and Z axes on the build plate")) self.plate_axes_cb.setChecked(bool(settings.get("show_plate_axes", False))) self.plate_axes_cb.setToolTip(tr("Small arrows pointing along X, Y and Z, beside the back-right corner of the " "model, in addition to the marker in the corner of the view.")) - l3.addWidget(self.plate_axes_cb) + ll.addWidget(self.plate_axes_cb, 1, 0, 1, 3) # Numbers in millimetres along the plate's major grid lines (grid_labels.py); off by default. self.grid_labels_cb = QCheckBox(tr("Show scale numbers on the grid")) self.grid_labels_cb.setChecked(bool(settings.get("show_grid_labels", False))) self.grid_labels_cb.setToolTip(tr("Small numbers along the edges of the build plate, on its major " "grid lines. Hidden while you look from under the plate.")) - l3.addWidget(self.grid_labels_cb) + lr.addWidget(self.grid_labels_cb, 0, 0, 1, 2) # How far apart the numbers are: automatic (the grid's major lines, 50 mm on a normal plate) # or a spacing of your own, for example 10 mm. - # In the same form as Build plate below it, so the two dropdowns line up. - plate_form = QFormLayout() self.grid_step_row = QWidget() step_layout = QHBoxLayout(self.grid_step_row) step_layout.setContentsMargins(0, 0, 0, 0) @@ -405,8 +414,8 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): for u in units.UNITS} self._spacing_unit = settings.get("units", "mm") if settings.get("units", "mm") in units.UNITS else "mm" step_layout.addWidget(self.grid_step_combo) - step_layout.addStretch(1) - plate_form.addRow(self.grid_step_label, self.grid_step_row) + lr.addWidget(self.grid_step_label, 1, 0) + lr.addWidget(self.grid_step_row, 1, 1, right) for wdg in (self.grid_step_label, self.grid_step_row): wdg.setEnabled(self.grid_labels_cb.isChecked()) self.grid_labels_cb.toggled.connect(wdg.setEnabled) @@ -417,8 +426,10 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): kmin.setObjectName("formKey") kmaj = QLabel(tr("Main grid lines every")) kmaj.setObjectName("formKey") - plate_form.addRow(kmin, self.grid_minor_combo) - plate_form.addRow(kmaj, self.grid_major_combo) + lr.addWidget(kmin, 2, 0) + lr.addWidget(self.grid_minor_combo, 2, 1, right) + lr.addWidget(kmaj, 3, 0) + lr.addWidget(self.grid_major_combo, 3, 1, right) self._spacing_combos = {"grid_labels_step": self.grid_step_combo, "grid_minor": self.grid_minor_combo, "grid_major": self.grid_major_combo} self._fill_spacing_combos() @@ -434,7 +445,8 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): self.plate_combo.setCurrentIndex(max(0, self.plate_combo.findData(settings.get("plate_mode", "printer")))) kp = QLabel(tr("Plate")) # in the Build plate box: "Plate" is enough kp.setObjectName("formKey") - plate_form.addRow(kp, self.plate_combo) + ll.addWidget(kp, 2, 0) + ll.addWidget(self.plate_combo, 2, 1) size = settings.get("plate_size", [300, 300]) self.plate_size_row = QWidget() size_layout = QHBoxLayout(self.plate_size_row) @@ -447,17 +459,16 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): spin.set_mm(float(value)) except (TypeError, ValueError): spin.set_mm(300) - size_layout.addWidget(self.plate_w_spin) + size_layout.addWidget(self.plate_w_spin, 1) # the two fields share the row's width, size_layout.addWidget(QLabel("×")) - size_layout.addWidget(self.plate_d_spin) - size_layout.addStretch(1) + size_layout.addWidget(self.plate_d_spin, 1) # which is the Plate dropdown's (see below) ks = QLabel(tr("Plate size")) ks.setObjectName("formKey") - plate_form.addRow(ks, self.plate_size_row) + ll.addWidget(ks, 3, 0) + ll.addWidget(self.plate_size_row, 3, 1) self.plate_combo.currentIndexChanged.connect( lambda _=0: self.plate_size_row.setEnabled(self.plate_combo.currentData() == "custom")) self.plate_size_row.setEnabled(self.plate_combo.currentData() == "custom") - l3.addLayout(plate_form) other_form = QFormLayout() # View -> Clip: how strongly the cut-away part (ghost and outline) and the cutting plane # are drawn, each with its own slider (#66). They change the 3D view while you drag; @@ -500,10 +511,12 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): self.check_colors_cb.setChecked(bool(settings.get("check_keep_colors", False))) self.check_colors_cb.setToolTip(tr("Off: the Check colours you pick last until PrintView3D closes.")) l_other.addWidget(self.check_colors_cb) - # The labels of both boxes' rows are as wide as the widest, so their dropdowns and sliders - # start in one column, as when they were one list. - keys_col = [plate_form.labelForField(plate_form.itemAt(i, QFormLayout.FieldRole).widget()) - for i in range(plate_form.rowCount()) if plate_form.itemAt(i, QFormLayout.FieldRole)] + # The labels of Build plate's left column and of this box's rows are as wide as the widest, + # so the Plate dropdown, the plate size and the sliders start in one column. + if other_form.horizontalSpacing() >= 0: + ll.setHorizontalSpacing(other_form.horizontalSpacing()) + lr.setHorizontalSpacing(other_form.horizontalSpacing()) + keys_col = [kp, ks] keys_col += [other_form.labelForField(other_form.itemAt(i, QFormLayout.FieldRole).widget()) for i in range(other_form.rowCount()) if other_form.itemAt(i, QFormLayout.FieldRole)] keys_col = [k for k in keys_col if k is not None] @@ -511,6 +524,24 @@ def __init__(self, settings, parent=None, gpu_info=None, page: str = ""): width = max(k.sizeHint().width() for k in keys_col) for k in keys_col: k.setMinimumWidth(width) + left_keys_width = kp.minimumWidth() + right_keys = (self.grid_step_label, kmin, kmaj) + right_fields = (self.grid_step_row, self.grid_minor_combo, self.grid_major_combo) + + def plate_columns_changed(stacked: bool): + """One under the other: the right column's rows look like the left one's (their names as + wide, their dropdowns starting where Plate's does). Side by side: dropdowns at the edge.""" + both = max([left_keys_width] + [k.sizeHint().width() for k in right_keys]) + for k in (kp, ks): + k.setMinimumWidth(both if stacked else left_keys_width) + for k in right_keys: + k.setMinimumWidth(both if stacked else 0) + for f in right_fields: + lr.setAlignment(f, (Qt.AlignLeft if stacked else Qt.AlignRight) | Qt.AlignVCenter) + + self.plate_columns = _TwoColumns(plate_left, plate_right, on_change=plate_columns_changed) + l3 = QVBoxLayout(g3) + l3.addWidget(self.plate_columns) # ---------------- Start page ---------------- g_start = QGroupBox(tr("Start page")) @@ -580,19 +611,26 @@ def _remember_page(self, row: int): self.settings.set("settings_page", self._page_keys[row]) def _even_combo_widths(self): - """The dropdowns of the Viewer page are all as wide as the widest needs to be, so they line - up (in every language and unit).""" + """The dropdowns of the Build plate box line up in every language and unit: the three of + the right column are as wide as the widest of them needs to be, and in the left column the + Plate dropdown and the plate size under it are equally wide.""" combos = [c for c in (getattr(self, "grid_step_combo", None), getattr(self, "grid_minor_combo", None), - getattr(self, "grid_major_combo", None), getattr(self, "plate_combo", None)) - if c is not None] - if not combos: - return + getattr(self, "grid_major_combo", None)) if c is not None] for c in combos: c.setMinimumWidth(0) c.setMaximumWidth(16777215) - width = max(c.sizeHint().width() for c in combos) - for c in combos: - c.setFixedWidth(width) + if combos: + width = max([150] + [c.sizeHint().width() for c in combos]) + for c in combos: + c.setFixedWidth(width) + plate, size = getattr(self, "plate_combo", None), getattr(self, "plate_size_row", None) + if plate is not None and size is not None: + for w in (plate, size): + w.setMinimumWidth(0) + w.setMaximumWidth(16777215) + width = max(plate.sizeHint().width(), size.sizeHint().width()) + plate.setFixedWidth(width) + size.setFixedWidth(width) # ---------------------------------------------------------------- pages def _add_page(self, key: str, title: str, widgets: list, keep_titles: bool = False): @@ -631,7 +669,12 @@ def _initial_size(self): if self._page_keys[i] not in ("about", "keys")) buttons = self.findChild(QDialogButtonBox).sizeHint().height() m = self.layout().contentsMargins() - w, h = 860, max(580, tallest + buttons + m.top() + m.bottom() + 24) + # Wide enough for the widest page too (Viewer, with its two columns, in long languages). + widest = max(self.pages.widget(i).widget().sizeHint().width() for i in range(self.pages.count()) + if self._page_keys[i] not in ("about", "keys")) + chrome = self.width() - self.pages.width() if self.pages.width() > 0 else 0 + w = max(860, widest + self.nav.width() + m.left() + m.right() + 32, widest + chrome + 8) + h = max(580, tallest + buttons + m.top() + m.bottom() + 24) if avail is not None: w, h = min(w, int(avail.width() * 0.9)), min(h, int(avail.height() * 0.9)) self.resize(w, h) @@ -1418,6 +1461,55 @@ def mouse_rows() -> list[tuple[list[str], str]]: ] +class _TwoColumns(QWidget): + """Two groups of settings side by side while the page is wide enough for both, otherwise one + under the other, so nothing is ever cut off at the window's edge. on_change(stacked) is told + which of the two it is.""" + + def __init__(self, left: QWidget, right: QWidget, gap: int = 24, on_change=None, parent=None): + super().__init__(parent) + self._left, self._right, self._gap, self._on_change = left, right, gap, on_change + self._stacked = None + self._grid = QGridLayout(self) + self._grid.setContentsMargins(0, 0, 0, 0) + self._grid.setHorizontalSpacing(gap) + self._arrange(False) + + @property + def stacked(self) -> bool: + return bool(self._stacked) + + def needed_width(self) -> int: + """The width both columns need side by side.""" + return self._left.sizeHint().width() + self._gap + self._right.sizeHint().width() + + def _arrange(self, stacked: bool): + if stacked == self._stacked: + return + self._stacked = stacked + if self._on_change is not None: + self._on_change(stacked) + self._grid.removeWidget(self._left) + self._grid.removeWidget(self._right) + self._grid.addWidget(self._left, 0, 0) + self._grid.addWidget(self._right, 1 if stacked else 0, 0 if stacked else 1) + self._grid.setColumnStretch(0, 1 if stacked else 0) + self._grid.setColumnStretch(1, 0 if stacked else 1) + self.updateGeometry() + + def sizeHint(self): + return QSize(self.needed_width(), max(self._left.sizeHint().height(), self._right.sizeHint().height())) + + def minimumSizeHint(self): + # as narrow as the wider of the two (then they are stacked), as tall as they are now + return QSize(max(self._left.minimumSizeHint().width(), self._right.minimumSizeHint().width()), + self._grid.minimumSize().height()) + + def resizeEvent(self, e): + super().resizeEvent(e) + self._arrange(self.width() < self.needed_width()) + + class Segmented(QFrame): """A row of choices where exactly one is chosen (Light | Dark | System), each with a small symbol. Looks like one control; the chosen part is filled (see theme: QFrame#segmented).""" diff --git a/tests/test_check_and_settings_2.py b/tests/test_check_and_settings_2.py index 23cf2b5..32782e1 100644 --- a/tests/test_check_and_settings_2.py +++ b/tests/test_check_and_settings_2.py @@ -203,9 +203,11 @@ def test_settings_layout(window, app): if dlg.height() < limit - 1: assert bar == 0, dlg._page_keys[i] assert dlg._page_keys[dlg.pages.count() - 1] == "about" - # the Viewer page's dropdowns line up: all as wide as the widest - widths = {c.width() for c in (dlg.grid_step_combo, dlg.grid_minor_combo, dlg.grid_major_combo, dlg.plate_combo)} + # the Build plate box's dropdowns line up: the three of the right column are as wide as the + # widest of them, and in the left column Plate is as wide as the plate size under it + widths = {c.width() for c in (dlg.grid_step_combo, dlg.grid_minor_combo, dlg.grid_major_combo)} assert len(widths) == 1 + assert dlg.plate_combo.width() == dlg.plate_size_row.width() plate_lay = dlg.bed_cb.parentWidget().layout() # the Build plate box assert plate_lay.indexOf(dlg.plate_axes_cb) == plate_lay.indexOf(dlg.bed_cb) + 1 assert dlg.plate_axes_cb.text().startswith("Also show") # in addition to the corner marker diff --git a/tests/test_grid_labels.py b/tests/test_grid_labels.py index ef6c04b..68f7d8d 100644 --- a/tests/test_grid_labels.py +++ b/tests/test_grid_labels.py @@ -68,9 +68,12 @@ def test_off_by_default_and_in_the_viewer_settings(window): assert DEFAULTS["show_grid_labels"] is False and not w.grid_labels.enabled dlg = SettingsDialog(w.settings, w, {}, page="viewer") assert not dlg.grid_labels_cb.isChecked() - # under the "Show build plate grid" checkbox, after the X/Y/Z arrows on the plate - layout = dlg.bed_cb.parentWidget().layout() - assert layout.indexOf(dlg.grid_labels_cb) == layout.indexOf(dlg.plate_axes_cb) + 1 == layout.indexOf(dlg.bed_cb) + 2 + # in the Build plate box: on top of its right column, beside the "Show build plate grid" + # checkbox that is on top of the left one (with the X/Y/Z arrows under it) + left, right = dlg.bed_cb.parentWidget(), dlg.grid_labels_cb.parentWidget() + assert left is not right and left.parentWidget() is right.parentWidget() is dlg.plate_columns + assert left.layout().indexOf(dlg.plate_axes_cb) == left.layout().indexOf(dlg.bed_cb) + 1 + assert right.layout().indexOf(dlg.grid_labels_cb) == 0 dlg.grid_labels_cb.setChecked(True) dlg._save() assert w.settings.get("show_grid_labels") is True @@ -199,12 +202,16 @@ def test_the_spacing_choice_is_greyed_out_while_the_numbers_are_off(window): def test_viewer_settings_line_up(window, app): from printview3d.ui.settings_dialog import SettingsDialog dlg = SettingsDialog(window.settings, window, {}, page="viewer") - dlg.resize(900, 800) + dlg.resize(1100, 800) # wide enough for Build plate's two columns dlg.show() for _ in range(4): app.processEvents() x = lambda wd: wd.mapTo(dlg, wd.rect().topLeft()).x() - assert x(dlg.grid_step_combo) == x(dlg.plate_combo) # the two dropdowns + # Build plate, two columns: the plate's own dropdown and size on the left, the grid's three + # dropdowns on the right, each column starting at one place + assert x(dlg.plate_combo) == x(dlg.plate_size_row) + assert x(dlg.grid_step_combo) == x(dlg.grid_minor_combo) == x(dlg.grid_major_combo) + assert x(dlg.grid_step_combo) > x(dlg.plate_combo) + dlg.plate_combo.width() sliders = (dlg.ghost_slider, dlg.plane_slider, dlg.line_slider) assert len({x(sl) for sl in sliders}) == 1 # the sliders start ... assert len({x(sl) + sl.width() for sl in sliders}) == 1 # ... and end together diff --git a/tests/test_viewer_settings_layout.py b/tests/test_viewer_settings_layout.py new file mode 100644 index 0000000..b60e841 --- /dev/null +++ b/tests/test_viewer_settings_layout.py @@ -0,0 +1,62 @@ +"""Settings -> Viewer: the Build plate box in two columns (the plate on the left, the grid's numbers +and lines on the right), and one column when the window is too narrow for both.""" + +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from printview3d.settings import Settings # noqa: E402 + + +@pytest.fixture +def app(): + from PySide6.QtWidgets import QApplication + return QApplication.instance() or QApplication([]) + + +def test_build_plate_box_has_two_columns(app, tmp_path): + from printview3d.ui.settings_dialog import SettingsDialog + dlg = SettingsDialog(Settings(str(tmp_path / "settings.json")), page="viewer") + dlg.resize(1100, 900) + dlg.show() + app.processEvents() + try: + def pos(w): + return w.mapTo(dlg, w.rect().topLeft()) + left = [dlg.bed_cb, dlg.plate_axes_cb, dlg.plate_combo, dlg.plate_size_row] + right = [dlg.grid_labels_cb, dlg.grid_step_combo, dlg.grid_minor_combo, dlg.grid_major_combo] + for a, b in zip(left, right): # row by row, side by side + assert abs(pos(a).y() + a.height() / 2 - pos(b).y() - b.height() / 2) <= 3 + assert pos(b).x() > pos(a).x() + a.width() + tops = [pos(w).y() for w in left] + assert tops == sorted(tops) and len(set(tops)) == 4 + # the right dropdowns are one column; Plate and the plate size are equally wide + assert len({pos(c).x() for c in right[1:]}) == 1 and len({c.width() for c in right[1:]}) == 1 + assert dlg.plate_combo.width() == dlg.plate_size_row.width() + finally: + dlg.close() + dlg.deleteLater() + + +def test_build_plate_columns_go_under_each_other_in_a_narrow_window(app, tmp_path): + from printview3d.ui.settings_dialog import SettingsDialog + dlg = SettingsDialog(Settings(str(tmp_path / "settings.json")), page="viewer") + dlg.show() + try: + for width, stacked in ((1100, False), (620, True), (1100, False)): + dlg.resize(width, 800) + for _ in range(4): + app.processEvents() + assert dlg.plate_columns.stacked is stacked, width + y = lambda w: w.mapTo(dlg, w.rect().topLeft()).y() + x = lambda w: w.mapTo(dlg, w.rect().topLeft()).x() + if stacked: # nothing cut off: one list, lined up + assert y(dlg.grid_labels_cb) > y(dlg.plate_size_row) + assert x(dlg.grid_minor_combo) == x(dlg.plate_combo) + else: + assert abs(y(dlg.grid_labels_cb) - y(dlg.bed_cb)) <= 3 + finally: + dlg.close() + dlg.deleteLater() From 4994924bf819f854c928c6ef869cc106aab2c110 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 22:52:56 +0100 Subject: [PATCH 3/4] Release 0.30.2: safer with crafted files, faster 3MF copies, Build plate in two columns --- CHANGELOG.ar.md | 18 ++++++++++++++++++ CHANGELOG.md | 18 ++++++++++++++++++ CHANGELOG.nl.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- src/printview3d/__init__.py | 2 +- 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.ar.md b/CHANGELOG.ar.md index 76bac34..f9644c8 100644 --- a/CHANGELOG.ar.md +++ b/CHANGELOG.ar.md @@ -1,5 +1,23 @@ # سجل التغييرات +## 0.30.2 +- **أكثر أماناً مع الملفات القادمة من الإنترنت.** أظهرت مراجعة للكود أربع طرق يمكن بها لملف مصنوع + بقصد الإضرار أن يسيء استخدام PrintView3D، وقد أُغلقت الأربع كلها: + - ملف 3MF أو glTF تشير مثلثاته إلى رؤوس غير موجودة كان يؤدي إلى توقف البرنامج فجأة. + - ملف glTF كان يستطيع أن يجعل PrintView3D يقرأ من الذاكرة ما ليس جزءاً من الملف. + - بعض ملفات 3MF وglTF لم يكن تحميلها ينتهي أبداً (أجسام تحتوي نفسها، أو يضع بعضها بعضاً بلا + نهاية). + - النص الموجود في الملف (العنوان والوصف وأسماء الأجسام) كان يُقرأ على أنه HTML بدل أن يُعرض + نصاً. + + تحصل هذه الملفات الآن على رسالة خطأ عادية، أو يُعرض نصها كما هو. ولدى Quick Look في macOS الحماية + نفسها من ملفات 3MF التي لا ينتهي تحميلها. +- **مشاريع 3MF التي فيها نسخ كثيرة من جسم واحد تُفتح أسرع**: الجسم الموضوع مرات كثيرة يُقرأ مرة + واحدة بدل مرة لكل نسخة. +- **الإعدادات ← العارض**: مربع **لوحة الطباعة** أصبح في عمودين، اللوحة في أحدهما وأرقام الشبكة + وخطوطها في الآخر، فصار ارتفاعه النصف. وفي نافذة أضيق من أن تتسع للعمودين ينتقل العمود الثاني + إلى أسفل الأول، وتُفتح الإعدادات بعرض يكفي العمودين إن سمحت الشاشة. + ## 0.30.1 - **مُصلح: ملفات G-code من Creality Print كانت بلا صورة معاينة**، في صفحة البداية وفي اللوحة الجانبية. يكتب Creality Print حجم الصورة بصيغة `300*300` أو `300 300`، ولم تكن هذه الصيغة معروفة. diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b9407..81923af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.30.2 +- **Safer with files from the internet.** A review of the code found four ways a file made to harm + could misuse PrintView3D; all four are closed: + - A 3MF or glTF file whose triangles point at corners that don't exist made the program crash. + - A glTF file could make PrintView3D read memory that isn't part of the file. + - Some 3MF and glTF files never finished loading (objects that contain themselves, or place each + other without end). + - Text in a file (its title, description, object names) was read as HTML instead of shown as text. + + Such files now get an ordinary error message, or simply show their text as it is. macOS Quick Look + has the same protection against 3MF files that never finish. +- **3MF projects with many copies of one object open faster**: an object placed many times is read + once instead of once per copy. +- **Settings → Viewer**: the **Build plate** box now has two columns, the plate on the left and the + grid's numbers and lines on the right, so it is half as tall. In a window too narrow for both, the + right column goes under the left one, and Settings opens wide enough for the two columns when the + screen allows. + ## 0.30.1 - **Fixed: Creality Print G-code had no preview picture**, on the start page and in the side panel. Creality Print writes the picture size as `300*300` or `300 300`, which wasn't recognised. diff --git a/CHANGELOG.nl.md b/CHANGELOG.nl.md index bc6acbe..404f226 100644 --- a/CHANGELOG.nl.md +++ b/CHANGELOG.nl.md @@ -1,5 +1,25 @@ # Changelog +## 0.30.2 +- **Veiliger met bestanden van internet.** Bij een controle van de code zijn vier manieren gevonden + waarop een kwaadaardig gemaakt bestand PrintView3D kon misbruiken; alle vier zijn gedicht: + - Een 3MF- of glTF-bestand waarvan de driehoeken naar hoekpunten wijzen die niet bestaan, liet het + programma crashen. + - Een glTF-bestand kon PrintView3D geheugen laten lezen dat niet bij het bestand hoort. + - Sommige 3MF- en glTF-bestanden werden nooit klaar met laden (objecten die zichzelf bevatten, of + elkaar eindeloos plaatsen). + - Tekst in een bestand (de titel, de beschrijving, namen van objecten) werd als HTML gelezen in + plaats van als tekst getoond. + + Zulke bestanden krijgen nu een gewone foutmelding, of hun tekst wordt gewoon getoond zoals die is. + Quick Look op macOS heeft dezelfde bescherming tegen 3MF-bestanden die nooit klaar zijn. +- **3MF-projecten met veel kopieën van één object openen sneller**: een object dat vaak geplaatst is, + wordt één keer gelezen in plaats van één keer per kopie. +- **Instellingen → Weergave**: het vak **Printplaat** heeft nu twee kolommen, links de plaat en rechts + de getallen en lijnen van het raster, en is daardoor half zo hoog. In een venster dat te smal is + voor beide komt de rechterkolom onder de linker, en Instellingen opent breed genoeg voor de twee + kolommen als het scherm dat toelaat. + ## 0.30.1 - **Opgelost: G-code van Creality Print had geen voorbeeldafbeelding**, op de startpagina en in het zijpaneel. Creality Print schrijft de grootte van de afbeelding als `300*300` of `300 300`, en dat diff --git a/pyproject.toml b/pyproject.toml index 82197e3..39de529 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "printview3d" -version = "0.30.1" +version = "0.30.2" description = "Lightweight viewer for STL, 3MF, G-code and FreeCAD files with one-click hand-off to your slicer; FreeCAD files go to the slicer as a model directly" readme = "README.md" license = { text = "MIT" } diff --git a/src/printview3d/__init__.py b/src/printview3d/__init__.py index 9339a92..ce1518d 100644 --- a/src/printview3d/__init__.py +++ b/src/printview3d/__init__.py @@ -1,5 +1,5 @@ """PrintView3D - a lightweight viewer for STL, 3MF and G-code files.""" -__version__ = "0.30.1" +__version__ = "0.30.2" APP_NAME = "PrintView3D" APP_ID = "PrintView3D" From 2c8085320750ac11505081e531b50835435dd396 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 23:09:55 +0100 Subject: [PATCH 4/4] Settings: as tall and wide as its pages really are when it opens The Build plate box's two columns go under each other when the window is too narrow for both, which makes the Viewer page taller. Settings still chose its height for the two-column arrangement, so on a narrow screen the page got a scroll bar (the macOS test run, whose test screen is 800 px wide, caught it). The two-column box now reports its height for a given width, and when Settings opens it is fitted once to the real sizes, after the theme's fonts and paddings are applied: wide enough for the two columns and tall enough for the tallest page, as far as the screen allows. --- src/printview3d/ui/settings_dialog.py | 121 +++++++++++++++++++++----- 1 file changed, 100 insertions(+), 21 deletions(-) diff --git a/src/printview3d/ui/settings_dialog.py b/src/printview3d/ui/settings_dialog.py index d893e92..eef5e77 100644 --- a/src/printview3d/ui/settings_dialog.py +++ b/src/printview3d/ui/settings_dialog.py @@ -665,20 +665,70 @@ def _initial_size(self): The long list of keys doesn't count: it scrolls instead.""" screen = (self.parent().screen() if self.parent() is not None else QApplication.primaryScreen()) avail = screen.availableGeometry() if screen is not None else None - tallest = max(self.pages.widget(i).widget().sizeHint().height() for i in range(self.pages.count()) - if self._page_keys[i] not in ("about", "keys")) buttons = self.findChild(QDialogButtonBox).sizeHint().height() m = self.layout().contentsMargins() # Wide enough for the widest page too (Viewer, with its two columns, in long languages). widest = max(self.pages.widget(i).widget().sizeHint().width() for i in range(self.pages.count()) if self._page_keys[i] not in ("about", "keys")) - chrome = self.width() - self.pages.width() if self.pages.width() > 0 else 0 - w = max(860, widest + self.nav.width() + m.left() + m.right() + 32, widest + chrome + 8) + beside = self.nav.width() + m.left() + m.right() + 32 # the list of pages and the margins + w = max(860, widest + beside) + if avail is not None: + w = min(w, int(avail.width() * 0.9)) + # As tall as the tallest page is at that width: a page whose columns don't fit side by side + # (Viewer on a narrow screen) puts them under each other and is taller than its size hint. + page_width = max(200, w - beside) + + def page_height(page) -> int: + page.layout().activate() + return max(page.sizeHint().height(), page.layout().totalHeightForWidth(page_width)) + + tallest = max(page_height(self.pages.widget(i).widget()) for i in range(self.pages.count()) + if self._page_keys[i] not in ("about", "keys")) h = max(580, tallest + buttons + m.top() + m.bottom() + 24) if avail is not None: - w, h = min(w, int(avail.width() * 0.9)), min(h, int(avail.height() * 0.9)) + h = min(h, int(avail.height() * 0.9)) self.resize(w, h) + def showEvent(self, e): + super().showEvent(e) + if not getattr(self, "_fitted", False): + self._fitted = True + self._fit_to_pages() + + def _fit_to_pages(self): + """Once, when Settings opens: _initial_size works from sizes estimated before the theme's + fonts and paddings are applied, and can come out a little small. Now that the real sizes are + known, widen the window until Viewer's two columns fit side by side and make it tall enough + for the tallest page, as far as the screen allows. Done before the window is drawn.""" + from PySide6.QtCore import QEvent + screen = self.screen() or QApplication.primaryScreen() + if screen is None: + return + avail = screen.availableGeometry() + max_w, max_h = int(avail.width() * 0.9), int(avail.height() * 0.9) + cols = getattr(self, "plate_columns", None) + for _ in range(4): + QApplication.sendPostedEvents(None, QEvent.LayoutRequest) # lay the pages out now + w, h = self.width(), self.height() + if cols is not None and cols.width() > 0 and cols.width() < cols.needed_width(): + w = min(max_w, w + cols.needed_width() - cols.width() + 2) + if w == self.width(): # the width is settled: now the height + view = self.pages.currentWidget().viewport() + need = 0 + for i in range(self.pages.count()): + if self._page_keys[i] in ("about", "keys"): + continue + page = self.pages.widget(i).widget() + need = max(need, page.layout().totalHeightForWidth(view.width()), + page.layout().totalMinimumSize().height()) + if need > view.height(): + h = min(max_h, h + need - view.height()) + if (w, h) == (self.width(), self.height()): + break + dx, dy = (w - self.width()) // 2, (h - self.height()) // 2 + self.resize(w, h) + self.move(max(avail.left(), self.x() - dx), max(avail.top(), self.y() - dy)) # stays centred + def show_page(self, key: str): if key in self._page_keys: self.nav.setCurrentRow(self._page_keys.index(key)) @@ -1464,16 +1514,22 @@ def mouse_rows() -> list[tuple[list[str], str]]: class _TwoColumns(QWidget): """Two groups of settings side by side while the page is wide enough for both, otherwise one under the other, so nothing is ever cut off at the window's edge. on_change(stacked) is told - which of the two it is.""" + which of the two it is. + + It places the two itself and tells the page how tall it is at a given width (heightForWidth), + so Settings can open tall enough for the arrangement it will really have: on a screen too + narrow for two columns that is the taller one.""" def __init__(self, left: QWidget, right: QWidget, gap: int = 24, on_change=None, parent=None): super().__init__(parent) self._left, self._right, self._gap, self._on_change = left, right, gap, on_change + left.setParent(self) + right.setParent(self) self._stacked = None - self._grid = QGridLayout(self) - self._grid.setContentsMargins(0, 0, 0, 0) - self._grid.setHorizontalSpacing(gap) - self._arrange(False) + policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + policy.setHeightForWidth(True) + self.setSizePolicy(policy) + self._set_stacked(False) @property def stacked(self) -> bool: @@ -1483,31 +1539,54 @@ def needed_width(self) -> int: """The width both columns need side by side.""" return self._left.sizeHint().width() + self._gap + self._right.sizeHint().width() - def _arrange(self, stacked: bool): + def _row_gap(self) -> int: + lay = self._left.layout() + gap = lay.verticalSpacing() if lay is not None else -1 + return gap if gap >= 0 else 6 + + def _set_stacked(self, stacked: bool): if stacked == self._stacked: return self._stacked = stacked if self._on_change is not None: self._on_change(stacked) - self._grid.removeWidget(self._left) - self._grid.removeWidget(self._right) - self._grid.addWidget(self._left, 0, 0) - self._grid.addWidget(self._right, 1 if stacked else 0, 0 if stacked else 1) - self._grid.setColumnStretch(0, 1 if stacked else 0) - self._grid.setColumnStretch(1, 0 if stacked else 1) self.updateGeometry() + def hasHeightForWidth(self) -> bool: + return True + + def heightForWidth(self, width: int) -> int: + hl, hr = self._left.sizeHint().height(), self._right.sizeHint().height() + return hl + self._row_gap() + hr if width < self.needed_width() else max(hl, hr) + def sizeHint(self): return QSize(self.needed_width(), max(self._left.sizeHint().height(), self._right.sizeHint().height())) def minimumSizeHint(self): - # as narrow as the wider of the two (then they are stacked), as tall as they are now - return QSize(max(self._left.minimumSizeHint().width(), self._right.minimumSizeHint().width()), - self._grid.minimumSize().height()) + # as narrow as the wider of the two: then they are one under the other + width = max(self._left.minimumSizeHint().width(), self._right.minimumSizeHint().width()) + return QSize(width, self.heightForWidth(max(width, self.width()))) def resizeEvent(self, e): super().resizeEvent(e) - self._arrange(self.width() < self.needed_width()) + self._place() + + def _place(self): + w, h = self.width(), self.height() + self._set_stacked(w < self.needed_width()) + hl, hr = self._left.sizeHint().height(), self._right.sizeHint().height() + if self._stacked: + self._left.setGeometry(0, 0, w, hl) + self._right.setGeometry(0, hl + self._row_gap(), w, hr) + return + wl = self._left.sizeHint().width() + wr = max(0, w - wl - self._gap) + if self.layoutDirection() == Qt.RightToLeft: # mirrored: the first column on the right + self._left.setGeometry(w - wl, 0, wl, h) + self._right.setGeometry(0, 0, wr, h) + else: + self._left.setGeometry(0, 0, wl, h) + self._right.setGeometry(wl + self._gap, 0, wr, h) class Segmented(QFrame):