Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.ar.md
Original file line number Diff line number Diff line change
@@ -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`، ولم تكن هذه الصيغة معروفة.
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.nl.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 13 additions & 1 deletion packaging/macos/quicklook/Shared/ThreeMF.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand All @@ -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<String>() // the objects being placed right now

init(file: MappedFile) {
self.file = file
Expand Down Expand Up @@ -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 {
Expand All @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion src/printview3d/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 23 additions & 4 deletions src/printview3d/loaders/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]]
Expand Down
51 changes: 42 additions & 9 deletions src/printview3d/loaders/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# --------------------------------------------------------------------------
Expand All @@ -238,6 +246,10 @@ def _mesh_arrays(mesh_el) -> tuple[np.ndarray, np.ndarray]:
# - The 3MF core itself: <object type="support"> 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
Expand Down Expand Up @@ -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:
Expand All @@ -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]:
Expand All @@ -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")
Expand All @@ -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
Expand Down
Loading
Loading