Skip to content

Commit b9f12aa

Browse files
authored
Added examples (#572)
* Added examples * Added docs link to examples and tweaked quit code * Fixing monkey patches to keep tests from hanging
1 parent d927770 commit b9f12aa

13 files changed

Lines changed: 498 additions & 3 deletions

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ jobs:
1313
- run: |
1414
python -m pip install --upgrade pip
1515
python -m pip install -e .[dev]
16-
black --diff --check . --exclude icons_res.py
16+
black --diff --check . --exclude "(icons_res.py|examples/)"

cq_editor/main_window.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import sys
2+
from pathlib import Path
23

34
from PyQt5.QtCore import QObject, pyqtSignal
45
from PyQt5.QtGui import QPalette, QColor
56
from PyQt5.QtWidgets import (
67
QLabel,
78
QMainWindow,
9+
QMessageBox,
810
QToolBar,
911
QDockWidget,
1012
QAction,
@@ -217,9 +219,23 @@ def closeEvent(self, event):
217219

218220
if self.components["editor"].document().isModified():
219221

220-
rv = confirm(self, "Confirm close", "Close without saving?")
222+
rv = QMessageBox.warning(
223+
self,
224+
"Unsaved changes",
225+
"Save changes before closing?",
226+
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
227+
QMessageBox.Save,
228+
)
221229

222-
if rv:
230+
if rv == QMessageBox.Save:
231+
self.components["editor"].save()
232+
if self.components["editor"].document().isModified():
233+
# Save As was cancelled - abort the close.
234+
event.ignore()
235+
return
236+
event.accept()
237+
super(MainWindow, self).closeEvent(event)
238+
elif rv == QMessageBox.Discard:
223239
event.accept()
224240
super(MainWindow, self).closeEvent(event)
225241
else:
@@ -303,6 +319,16 @@ def prepare_menubar(self):
303319
for comp in self.components.values():
304320
self.prepare_menubar_component(menus, comp.menuActions())
305321

322+
# Examples submenu
323+
menu_file.addSeparator()
324+
examples_menu = menu_file.addMenu("Examples")
325+
self._populate_examples_menu(examples_menu)
326+
327+
menu_file.addSeparator()
328+
menu_file.addAction(
329+
QAction("Quit", self, shortcut="ctrl+Q", triggered=self.close)
330+
)
331+
306332
# global menu elements
307333
menu_view.addSeparator()
308334
for d in self.findChildren(QDockWidget):
@@ -505,6 +531,36 @@ def prepare_console(self):
505531
}
506532
)
507533

534+
def _examples_dir(self):
535+
# In a PyInstaller bundle examples are extracted alongside the package.
536+
# In development they live next to the cq_editor package directory.
537+
if getattr(sys, "frozen", False):
538+
return Path(sys._MEIPASS) / "examples"
539+
return Path(__file__).parent.parent / "examples"
540+
541+
def _populate_examples_menu(self, menu):
542+
examples_dir = self._examples_dir()
543+
if not examples_dir.is_dir():
544+
menu.setEnabled(False)
545+
return
546+
547+
for path in sorted(examples_dir.glob("*.py")):
548+
# Strip the leading "NN_" numbering prefix for the menu label.
549+
label = path.stem
550+
if len(label) > 3 and label[2] == "_" and label[:2].isdigit():
551+
label = label[3:]
552+
label = label.replace("_", " ").title()
553+
554+
menu.addAction(
555+
QAction(
556+
label,
557+
self,
558+
triggered=lambda checked, p=path: self.components[
559+
"editor"
560+
].load_example(str(p)),
561+
)
562+
)
563+
508564
def fill_dummy(self):
509565

510566
self.components["editor"].set_text(

cq_editor/widgets/editor.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ class Editor(CodeEditor, ComponentMixin):
8484
# Tracks whether or not the document was saved from the internal editor vs an external editor
8585
was_modified_by_self = False
8686

87+
# Set to True when a file was loaded from the examples directory; causes
88+
# Save to redirect to Save As so the originals are never overwritten.
89+
_is_example = False
90+
8791
# Helps display the completion list for the editor
8892
completion_list = None
8993

@@ -263,15 +267,32 @@ def load_from_file(self, fname):
263267

264268
self.set_text_from_file(fname)
265269
self.filename = fname
270+
self._is_example = False
266271
self.reset_modified()
267272

273+
def load_example(self, fname):
274+
"""Load a file from the examples directory.
275+
276+
Identical to load_from_file except it marks the buffer as an example
277+
so that Save redirects to Save As, keeping the originals intact.
278+
"""
279+
self.load_from_file(fname)
280+
self._is_example = True
281+
268282
def save(self):
269283
"""
270284
Saves the current document to the current filename if it exists, otherwise it triggers a
271285
save-as dialog.
272286
"""
273287

274288
if self._filename != "":
289+
if self._is_example:
290+
self.statusChanged.emit(
291+
"Examples are read-only — choose a new location to save your changes"
292+
)
293+
self.save_as()
294+
return
295+
275296
with open(self._filename, "w", encoding="utf-8", newline="") as f:
276297
f.write(self.toPlainText().replace("\n", self._eol))
277298

@@ -290,6 +311,7 @@ def save_as(self):
290311
with open(fname, "w", encoding="utf-8", newline="") as f:
291312
f.write(self.toPlainText().replace("\n", self._eol))
292313
self.filename = fname
314+
self._is_example = False
293315

294316
self.reset_modified()
295317

examples/01_hello_box.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Example 1: Hello Box
2+
#
3+
# The simplest possible CadQuery model: a rectangular box.
4+
#
5+
# Every CadQuery model starts with a Workplane. The string argument ("XY")
6+
# selects the plane the first sketch or operation is relative to:
7+
# "XY" — the horizontal plane (Z points up)
8+
# "XZ" — front face of the box
9+
# "YZ" — side face of the box
10+
#
11+
# .box(length, width, height) creates a box centered on the workplane origin.
12+
#
13+
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/
14+
15+
import cadquery as cq
16+
17+
result = cq.Workplane("XY").box(10, 10, 5)
18+
19+
show_object(result)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Example 2: Selectors and Fillets
2+
#
3+
# CadQuery uses selectors to pick specific edges, faces, or vertices from a
4+
# solid so you can apply operations to them. This example rounds the four
5+
# vertical edges of a box using a selector string.
6+
#
7+
# Common edge selectors:
8+
# "|Z" — edges parallel to the Z axis
9+
# "|X" — edges parallel to the X axis
10+
# ">Z" — the edge(s) furthest in the +Z direction
11+
# "#Z" — edges perpendicular to Z (i.e. lying in a horizontal plane)
12+
#
13+
# .fillet(radius) rounds the selected edges.
14+
# .chamfer(length) cuts a 45-degree bevel instead.
15+
#
16+
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/
17+
18+
import cadquery as cq
19+
20+
result = (
21+
cq.Workplane("XY")
22+
.box(10, 10, 5)
23+
# Select the four edges running vertically (parallel to Z) and fillet them.
24+
.edges("|Z")
25+
.fillet(1.5)
26+
)
27+
28+
show_object(result)

examples/03_extruded_profile.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Example 3: Extruded Profile
2+
#
3+
# Rather than starting with a primitive shape, you can draw a 2D profile on a
4+
# workplane and extrude it into a 3D solid. Sketch the cross-section, then
5+
# give it thickness.
6+
#
7+
# The sketch is built by chaining 2D drawing commands:
8+
# .moveTo(x, y) - move the pen without drawing
9+
# .lineTo(x, y) - draw a line to an absolute position
10+
# .close() - connect back to the starting point
11+
#
12+
# .extrude(depth) extrudes the closed profile in the workplane's normal direction.
13+
#
14+
# This example draws an L-shaped bracket profile and extrudes it.
15+
#
16+
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/
17+
18+
import cadquery as cq
19+
20+
# Dimensions (mm)
21+
flange_width = 30
22+
flange_height = 5
23+
web_height = 25
24+
web_thickness = 5
25+
26+
result = (
27+
cq.Workplane("XY")
28+
# Trace the L-shape profile clockwise starting at the origin.
29+
.moveTo(0, 0)
30+
.lineTo(flange_width, 0) # bottom edge of flange
31+
.lineTo(flange_width, flange_height) # right edge of flange
32+
.lineTo(web_thickness, flange_height) # step up to the web
33+
.lineTo(web_thickness, flange_height + web_height) # top of web
34+
.lineTo(0, flange_height + web_height) # top of web (left)
35+
.close() # back to origin
36+
.extrude(40) # give the profile a 40 mm depth
37+
)
38+
39+
show_object(result)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Example 4: Holes and Counterbores
2+
#
3+
# Holes are one of the most common features in mechanical parts. CadQuery
4+
# provides dedicated methods for the hole types a machinist would recognise:
5+
#
6+
# .hole(diameter) - straight through-hole
7+
# .cboreHole(diameter, cbore_d, cbore_depth) - counterbored hole (flat-bottomed recess)
8+
# .cskHole(diameter, csk_d, csk_angle) - countersunk hole (conical recess)
9+
#
10+
# Holes are placed at the CURRENT STACK POSITIONS. A common pattern is to
11+
# put points on a construction rectangle, select its vertices, and drill
12+
# all four at once.
13+
#
14+
# .workplane() switches the active workplane to the selected face so that
15+
# subsequent operations happen relative to it.
16+
#
17+
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/
18+
19+
import cadquery as cq
20+
21+
# Plate dimensions
22+
length = 80.0
23+
width = 60.0
24+
thickness = 10.0
25+
26+
# Fastener dimensions (M4 socket-head cap screw)
27+
bolt_dia = 4.4 # clearance hole diameter
28+
cbore_dia = 8.0 # counterbore diameter
29+
cbore_depth = 4.5 # counterbore depth
30+
31+
result = (
32+
cq.Workplane("XY")
33+
.box(length, width, thickness)
34+
35+
# Switch to the top face of the box.
36+
.faces(">Z")
37+
.workplane()
38+
39+
# Create a rectangle for construction only - its vertices define hole centres.
40+
# forConstruction=True means the rectangle is not extruded or cut; it just
41+
# acts as a layout guide for the hole locations.
42+
.rect(length - 16, width - 16, forConstruction=True)
43+
.vertices()
44+
45+
# Drill a counterbored hole at each of the four corner vertices.
46+
.cboreHole(bolt_dia, cbore_dia, cbore_depth)
47+
)
48+
49+
show_object(result)

examples/05_loft.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Example 5: Loft
2+
#
3+
# A loft connects two or more cross-section profiles on different workplanes
4+
# with a smoothly blended solid. It is useful for organic shapes, aerodynamic
5+
# forms, and transitions between different cross-sections.
6+
#
7+
# The workflow is:
8+
# 1. Sketch a profile on one workplane.
9+
# 2. Move to another workplane (offset along an axis).
10+
# 3. Sketch a second profile.
11+
# 4. Call .loft() to blend between them.
12+
#
13+
# This example transitions from a square base to a circular top, like a
14+
# funnel or adapter fitting.
15+
#
16+
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/
17+
18+
import cadquery as cq
19+
20+
base_size = 30 # side length of the square base (mm)
21+
top_radius = 10 # radius of the circular top (mm)
22+
height = 40 # overall height of the loft (mm)
23+
24+
result = (
25+
cq.Workplane("XY")
26+
27+
# First profile: a square on the XY plane at Z=0.
28+
.rect(base_size, base_size)
29+
30+
# Move up to Z=height and sketch the second profile.
31+
# workplane(offset=height) creates a new XY-parallel plane at that height.
32+
.workplane(offset=height)
33+
34+
# Second profile: a circle.
35+
.circle(top_radius)
36+
37+
# Loft between the two profiles.
38+
# ruled=False (default) gives a smooth blend.
39+
# ruled=True would give straight ruled-surface sides instead.
40+
.loft()
41+
)
42+
43+
show_object(result)

examples/06_sweep.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Example 6: Sweep
2+
#
3+
# A sweep extrudes a 2D cross-section along a 3D path (the "spine"). Unlike a
4+
# straight extrude, the path can curve, making sweep ideal for pipes, channels,
5+
# handles, and wiring conduits.
6+
#
7+
# The workflow:
8+
# 1. Build the path (spine) as a Wire using CadQuery's 2D drawing commands,
9+
# then convert it with .wire().
10+
# 2. On a workplane perpendicular to the start of the spine, draw the profile.
11+
# 3. Call .sweep(path) to extrude the profile along the spine.
12+
#
13+
# This example creates a curved pipe — a circular cross-section swept along
14+
# a quarter-circle arc.
15+
#
16+
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/
17+
18+
import cadquery as cq
19+
20+
pipe_radius = 3 # outer radius of the pipe cross-section (mm)
21+
sweep_radius = 30 # radius of the quarter-circle sweep path (mm)
22+
23+
# Build the sweep path: a quarter-circle arc in the XZ plane.
24+
# makeLine/makeArc helpers return Edge objects; Wire.assembleEdges()
25+
# joins them into a single Wire the sweep can follow.
26+
path = (
27+
cq.Workplane("XZ")
28+
.moveTo(sweep_radius, 0)
29+
.radiusArc((0, sweep_radius), -sweep_radius) # quarter-circle arc
30+
.wire()
31+
)
32+
33+
result = (
34+
# Start the profile on the XY plane at the beginning of the path.
35+
cq.Workplane("XY")
36+
.workplane(offset=0)
37+
.moveTo(sweep_radius, 0) # centre of the profile matches path start
38+
.circle(pipe_radius) # circular cross-section
39+
.sweep(path) # follow the arc
40+
)
41+
42+
show_object(result)

0 commit comments

Comments
 (0)