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/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/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" 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/src/printview3d/ui/settings_dialog.py b/src/printview3d/ui/settings_dialog.py index 955720d..eef5e77 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): @@ -627,15 +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() - 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")) + 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, h = min(w, int(avail.width() * 0.9)), min(h, int(avail.height() * 0.9)) + 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: + 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)) @@ -1418,6 +1511,84 @@ 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. + + 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 + policy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + policy.setHeightForWidth(True) + self.setSizePolicy(policy) + self._set_stacked(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 _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.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 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._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): """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_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.')] 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()