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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ menu or applications menu, without a file, always opens a new window.
| **Left-drag** | Rotate. Press *on the model* to rotate around that exact point; press on empty space to rotate around the model's centre. The plate always stays level. |
| **Right-drag** (or middle-drag, or Shift + left-drag) | Move (pan) |
| **Mouse wheel** | Zoom towards the mouse pointer |

While you turn, move or zoom, the pointer shows it (circling arrows, four arrows, a magnifier), and is the normal arrow again when you let go.
| **View cube** (top-right corner) | A see-through cube with a dashed ring. Click a face to jump to that view (Top, Front, Right, Back, Left or Bottom). Drag the cube to rotate, or drag the ring under it to turn without tilting. With the mouse over the cube, **arrows** and a **Home** icon appear: ◀ ▶ turn the model to the next 45° stop, ▲ ▼ tilt it 45° towards Top or Bottom, and Home goes back to the fitted Iso view. The arrows always land on one of the cube's 26 views (faces, edges, corners), so after a free drag the first click lines the view up again. View changes turn smoothly; click or drag to stop one. The part under the mouse lights up in the theme's main colour (amber in Ember). Because Top, Front and Right are on the cube and its Home icon is the Iso view, the toolbar keeps only Fit (handy when you have zoomed far away from the model) and Plate; hide the cube in Settings → Viewer and the Iso, Top, Front and Right buttons come back. The keys `1` `2` `3` `4` work either way. |

Looking from underneath? The plate gets out of the way so you can see the bottom of the part.
Expand All @@ -282,8 +284,8 @@ already has. The mouse controls are fixed.
| `Ctrl+W` | Close the file (back to the start page) |
| `Ctrl+P` | Open the file in your default slicer |
| `Ctrl+E` | Export STL (3MF projects, FreeCAD and STEP files) |
| `S` | Settings |
| `Ctrl+Shift+S` | Save a screenshot of the 3D view |
| `S` | Settings (it stays open beside the main window, which keeps working) |
| `Ctrl+Shift+S` | Save a screenshot of the 3D view. It asks where, or saves straight to a folder of your choice: **Settings → Files → Screenshots** |
| `Ctrl+T` | Next theme style (Default → Gray → Graphite → Ember), same light/dark mode |
| `Ctrl+Shift+T` | Switch the theme between light and dark |
| `F` | Fit the model in the view |
Expand Down
5 changes: 3 additions & 2 deletions docs/inspect.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ Next to the file type and size, *Preview picture* folds the picture the slicer s
## Check before printing

The **Check** button (<kbd>C</kbd>, between Plate and View) opens the **Check model** section, below
Measure, and colours what may cause trouble when printing:
Measure, and colours what may cause trouble when printing. Its **Close** button (<kbd>C</kbd> again)
turns it off:

- **Overhangs**: faces that face down and lean further from vertical than an angle (45° by default)
usually need supports. Faces resting on the plate don't count.
Expand All @@ -155,7 +156,7 @@ found (thin walls and the mesh problems; not overhangs, which most models
have). Hover over a name to see what it means and its technical term. The highlights follow the cuts and the objects you hide. Which checks are on,
the angle, the nozzle width and the loose-part size are remembered. The colours go back to their
defaults when you restart, unless you tick **Settings → Viewer → Remember the colours of the Check
section**.
section on restart** (in its **Other** box).

## Open a folder

Expand Down
6 changes: 4 additions & 2 deletions docs/use.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ menu or applications menu, without a file, always opens a new window.
| **Right-drag** (or middle-drag, or Shift + left-drag) | Move (pan) |
| **Mouse wheel** | Zoom towards the mouse pointer |

While you turn, move or zoom, the pointer shows it (circling arrows, four arrows, a magnifier), and is the normal arrow again when you let go.

Looking from underneath? The plate gets out of the way so you can see the bottom of the part.

## Keyboard
Expand All @@ -41,8 +43,8 @@ already has. The mouse controls are fixed.
| `Ctrl+W` | Close the file (back to the start page) |
| `Ctrl+P` | Open the file in your default slicer |
| `Ctrl+E` | Export STL (3MF projects, FreeCAD and STEP files) |
| `S` | Settings |
| `Ctrl+Shift+S` | Save a screenshot of the 3D view |
| `S` | Settings (it stays open beside the main window, which keeps working) |
| `Ctrl+Shift+S` | Save a screenshot of the 3D view. It asks where, or saves straight to a folder of your choice: **Settings → Files → Screenshots** |
| `Ctrl+T` | Next theme style (Default → Gray → Graphite → Ember), same light/dark mode |
| `Ctrl+Shift+T` | Switch the theme between light and dark |
| `F` | Fit the model in the view |
Expand Down
2 changes: 2 additions & 0 deletions src/printview3d/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"update_skip": "", # version the user chose to skip
"new_window": False, # Settings -> Files: True = every file in its own window
"export_mode": "dialog", # Settings -> Files: what a click on Export STL does (EXPORT_MODES)
"screenshot_auto": False, # Settings -> Files: Screenshot saves straight to screenshot_folder (no question)
"screenshot_folder": "", # ... that folder ("" = the Pictures folder)
"star_prompt_shown": False, # the one-time "star us on GitHub" message (new installs only)
"start_count": 0, # how often the main window started (first start: no questions)
"last_run_version": "", # version of the last start; "" = new install ("What's new" after updates)
Expand Down
104 changes: 102 additions & 2 deletions src/printview3d/ui/camera_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,67 @@
ANIM_FRAME_MS = 15
_EPS_STEPS = 1e-3 # "already on a stop" tolerance, in steps (~0.05°)

ZOOM_CURSOR_MS = 350 # the zoom cursor stays this long after the last wheel notch

# The cursor while the mouse works the view: turning (circling arrows), panning (four arrows),
# zooming (a magnifier). Normal pointing keeps the usual arrow.
_CURSORS: dict = {}


def drawn_cursor(kind: str):
"""The cursor for "rotate" or "zoom", drawn once: Qt has no such cursors of its own. White
with a dark edge, so it shows on the dark and the light themes and over the model."""
if kind in _CURSORS:
return _CURSORS[kind]
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QCursor, QGuiApplication, QPainter, QPainterPath, QPen, QPixmap
size = 32
screen = QGuiApplication.primaryScreen()
ratio = screen.devicePixelRatio() if screen is not None else 1.0
pm = QPixmap(int(size * ratio), int(size * ratio))
pm.setDevicePixelRatio(ratio)
pm.fill(Qt.transparent)
path = QPainterPath() # drawn as lines
heads = QPainterPath() # filled (the arrowheads)
if kind == "rotate":
# Two arcs round the middle, each ending in an arrowhead: "turn".
r = QRectF(6, 6, 20, 20)
for start in (20, 200):
arc = QPainterPath()
arc.arcMoveTo(r, start)
arc.arcTo(r, start, 130)
path.addPath(arc)
end = arc.currentPosition()
a = math.radians(start + 130)
tangent = QPointF(-math.sin(a), -math.cos(a)) # the way the arc goes on (screen y down)
normal = QPointF(math.cos(a), -math.sin(a))
tip = end + tangent * 4
head = QPainterPath(tip)
head.lineTo(end - tangent * 1 + normal * 4)
head.lineTo(end - tangent * 1 - normal * 4)
head.closeSubpath()
heads.addPath(head)
hot = (size // 2, size // 2)
else:
# A magnifier: the glass at the top left is where the zoom goes.
path.addEllipse(QRectF(4, 4, 15, 15))
path.moveTo(17, 17)
path.lineTo(27, 27)
hot = (11, 11)
painter = QPainter(pm)
painter.setRenderHint(QPainter.Antialiasing)
for color, width in ((QColor(20, 20, 20, 220), 5.0), (QColor(255, 255, 255), 2.4)):
painter.setPen(QPen(color, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
painter.setBrush(Qt.NoBrush)
painter.drawPath(path)
painter.setPen(QPen(color, width - 2.0, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
painter.setBrush(color)
painter.drawPath(heads)
painter.end()
_CURSORS[kind] = QCursor(pm, *hot)
return _CURSORS[kind]


ARROW_STEPS = {"arrow_left": (-1, 0), "arrow_right": (1, 0), "arrow_up": (0, 1), "arrow_down": (0, -1)}


Expand Down Expand Up @@ -139,6 +200,13 @@ def __init__(self, plotter, scene, on_interaction_start=None, on_interaction_end
self._press = (0, 0)
self._dragged = False
self._tip_part = None
self._working = None # the cursor shown while turning, panning, zooming

# The zoom cursor goes back to the normal one when the wheel stops.
self._zoom_cursor_timer = QTimer()
self._zoom_cursor_timer.setSingleShot(True)
self._zoom_cursor_timer.setInterval(ZOOM_CURSOR_MS)
self._zoom_cursor_timer.timeout.connect(lambda: self._work_cursor(None))

# Redraw at most once per pass of the Qt event loop.
self._render_timer = QTimer()
Expand All @@ -164,8 +232,8 @@ def __init__(self, plotter, scene, on_interaction_start=None, on_interaction_end
s.AddObserver("MiddleButtonReleaseEvent", self._button_up)
s.AddObserver("MouseMoveEvent", self._move)
s.AddObserver("LeaveEvent", lambda *_: self.leave())
s.AddObserver("MouseWheelForwardEvent", lambda *_: self._zoom(ZOOM_STEP))
s.AddObserver("MouseWheelBackwardEvent", lambda *_: self._zoom(1 / ZOOM_STEP))
s.AddObserver("MouseWheelForwardEvent", lambda *_: self._wheel(ZOOM_STEP))
s.AddObserver("MouseWheelBackwardEvent", lambda *_: self._wheel(1 / ZOOM_STEP))
# Swallow VTK's own key shortcuts (w = wireframe, e/q = quit, ...)
s.AddObserver("CharEvent", lambda *_: None)
s.AddObserver("KeyPressEvent", lambda *_: None)
Expand Down Expand Up @@ -404,6 +472,7 @@ def _button_up(self, *_):
elif self.on_cube_view:
self.on_cube_view(part) # a face name, or "home"
return
self._work_cursor(None)
if self.on_end:
self.on_end()
self._render_timer.stop()
Expand Down Expand Up @@ -439,6 +508,8 @@ def _move(self, *_):
self.last = (x, y)
if dx == 0 and dy == 0:
return
if self.mode in ("rotate", "pan"):
self._work_cursor(self.mode) # moving now: the cursor says how
if self.mode == "cube":
if not self._dragged:
if abs(x - self._press[0]) + abs(y - self._press[1]) < CLICK_SLOP_PX:
Expand Down Expand Up @@ -484,6 +555,35 @@ def _hover(self, x, y):
self._show_tip(part)
self.request_render()

def _work_cursor(self, what):
"""The cursor while the view is turned ("rotate"), panned ("pan") or zoomed ("zoom"); None:
back to the normal one for where the mouse is."""
if what == self._working:
return
self._working = what
try:
from PySide6.QtCore import Qt
widget = self.p.interactor
if what == "pan":
widget.setCursor(Qt.SizeAllCursor)
elif what in ("rotate", "zoom"):
widget.setCursor(drawn_cursor(what))
elif self.clip_handles is not None and self.clip_handles.hover:
self._set_cursor("knob")
elif self.cube is not None and self.cube.hover is not None:
widget.setCursor(Qt.SizeHorCursor if self.cube.hover == "ring" else Qt.PointingHandCursor)
else:
self._set_cursor(None)
except Exception: # noqa: BLE001 - no Qt widget (tests)
pass

def _wheel(self, factor):
"""A wheel notch: zoom, with the zoom cursor until the wheel stops (not during a drag)."""
if self.mode is None:
self._work_cursor("zoom")
self._zoom_cursor_timer.start()
self._zoom(factor)

def _set_cursor(self, what):
"""The 3D view's cursor: "knob" over a cut's knob, "drag" while moving one, otherwise
the normal one (a cross while measuring)."""
Expand Down
49 changes: 43 additions & 6 deletions src/printview3d/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from .. import updates as up
from .export_stl import ExportStlDialog
from .scene import PLATE_MODES, Scene
from .settings_dialog import SettingsDialog
from .settings_dialog import SettingsDialog, default_screenshot_folder
from . import send_dialog as sd

RESOURCES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "resources")
Expand Down Expand Up @@ -1716,7 +1716,7 @@ def retranslate_ui(self):
self.check_nozzle.set_unit(self._nozzle_unit())
self._align_check_inputs()
self.check_mesh_title.setText(tr("Mesh problems"))
self.check_close_btn.setText(tr("Close"))
self.check_close_btn.setText(self._with_key(tr("Close"), "check")) # C ends Check, as Close does
for kind, text, tip, technical in (
("overhang", tr("Overhangs"), tr("Faces that face down and lean further from vertical than the angle: "
"they usually need supports. Faces on the plate are not counted."), ""),
Expand Down Expand Up @@ -3846,9 +3846,11 @@ def save_screenshot(self):
if self.stack.currentIndex() != 1:
return
base = os.path.splitext(os.path.basename(self.current_path or "view"))[0]
start = os.path.join(self._user_folder(), base + ".png")
path, _ = QFileDialog.getSaveFileName(self, tr("Save screenshot"), start,
tr("PNG image") + " (*.png)")
path = self._auto_screenshot_path(base) if self.settings.get("screenshot_auto", False) else ""
if not path:
start = os.path.join(self._user_folder(), base + ".png")
path, _ = QFileDialog.getSaveFileName(self, tr("Save screenshot"), start,
tr("PNG image") + " (*.png)")
if path:
# The picture shows the model, not the view cube.
cube_on = self.view_cube.visible
Expand All @@ -3862,12 +3864,47 @@ def save_screenshot(self):
self.plotter.render()
self.statusBar().showMessage(tr("Saved {path}").format(path=path), 6000)

def _auto_screenshot_path(self, base: str) -> str:
"""Settings -> Files -> Screenshots, saved without asking: a new name in the chosen folder
(the file's name and the time). "" when the folder can't be made: then Screenshot asks."""
import time
folder = self.settings.get("screenshot_folder", "") or default_screenshot_folder()
try:
os.makedirs(folder, exist_ok=True)
except OSError:
return ""
stem = f"{base} {time.strftime('%Y-%m-%d %H-%M-%S')}"
path, n = os.path.join(folder, stem + ".png"), 2
while os.path.exists(path): # two in the same second
path = os.path.join(folder, f"{stem} ({n}).png")
n += 1
return path

def open_settings(self, page: str = ""):
"""Settings, not modal: the main window keeps working while it is open. Opened again
(key, menu) while it is open, the same window comes to the front, on the page asked for."""
dlg = getattr(self, "settings_dialog", None)
if dlg is not None:
if page:
dlg.show_page(page)
dlg.showNormal()
dlg.raise_()
dlg.activateWindow()
return
self._freecad_before = self.settings.get("freecad_path", "")
dlg = SettingsDialog(self.settings, self, renderer_info(self.plotter), page=page)
dlg.setModal(False)
dlg.setAttribute(Qt.WA_DeleteOnClose)
dlg.applied.connect(self._settings_applied) # Apply: shown now, the dialog stays
accepted = dlg.exec()
dlg.finished.connect(lambda result, d=dlg: self._settings_closed(d, result))
self.settings_dialog = dlg
dlg.show()

def _settings_closed(self, dlg, result: int):
"""Settings closed by OK, Cancel or its cross."""
self.settings_dialog = None
self.refresh_update_badge()
accepted = result == QDialog.Accepted
if accepted and dlg.restart_now:
# After the dialog has closed, so nothing of it is left when the window goes.
QTimer.singleShot(0, self.restart)
Expand Down
7 changes: 6 additions & 1 deletion src/printview3d/ui/print_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,12 @@ def _add_faces(self, name: str, part, mask, color: str):
a = self.p.add_mesh(mesh, color=color, name=name, reset_camera=False, pickable=False,
lighting=True, show_scalar_bar=False)
_polygon_offset_only_where_asked()
a.GetMapper().SetRelativeCoincidentTopologyPolygonOffsetParameters(-1.5, -4.0)
# A small constant pull only. The slope part of an offset grows without limit on faces
# seen edge-on: down-facing faces hidden under a finger or a lip were pulled out in front
# of the faces covering them, and showed through the model (much more on some graphics
# drivers). The highlight is the same triangles as the model, so a constant pull is
# enough to win the tie: from below it covers exactly the same pixels as before.
a.GetMapper().SetRelativeCoincidentTopologyPolygonOffsetParameters(0.0, -2.0)
a.SetUseBounds(False)
self._actors[name] = a

Expand Down
Loading
Loading