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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ jobs:
- run: |
python -m pip install --upgrade pip
python -m pip install -e .[dev]
black --diff --check . --exclude icons_res.py
black --diff --check . --exclude "(icons_res.py|examples/)"
60 changes: 58 additions & 2 deletions cq_editor/main_window.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import sys
from pathlib import Path

from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtGui import QPalette, QColor
from PyQt5.QtWidgets import (
QLabel,
QMainWindow,
QMessageBox,
QToolBar,
QDockWidget,
QAction,
Expand Down Expand Up @@ -217,9 +219,23 @@ def closeEvent(self, event):

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

rv = confirm(self, "Confirm close", "Close without saving?")
rv = QMessageBox.warning(
self,
"Unsaved changes",
"Save changes before closing?",
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
QMessageBox.Save,
)

if rv:
if rv == QMessageBox.Save:
self.components["editor"].save()
if self.components["editor"].document().isModified():
# Save As was cancelled - abort the close.
event.ignore()
return
event.accept()
super(MainWindow, self).closeEvent(event)
elif rv == QMessageBox.Discard:
event.accept()
super(MainWindow, self).closeEvent(event)
else:
Expand Down Expand Up @@ -303,6 +319,16 @@ def prepare_menubar(self):
for comp in self.components.values():
self.prepare_menubar_component(menus, comp.menuActions())

# Examples submenu
menu_file.addSeparator()
examples_menu = menu_file.addMenu("Examples")
self._populate_examples_menu(examples_menu)

menu_file.addSeparator()
menu_file.addAction(
QAction("Quit", self, shortcut="ctrl+Q", triggered=self.close)
)

# global menu elements
menu_view.addSeparator()
for d in self.findChildren(QDockWidget):
Expand Down Expand Up @@ -505,6 +531,36 @@ def prepare_console(self):
}
)

def _examples_dir(self):
# In a PyInstaller bundle examples are extracted alongside the package.
# In development they live next to the cq_editor package directory.
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS) / "examples"
return Path(__file__).parent.parent / "examples"

def _populate_examples_menu(self, menu):
examples_dir = self._examples_dir()
if not examples_dir.is_dir():
menu.setEnabled(False)
return

for path in sorted(examples_dir.glob("*.py")):
# Strip the leading "NN_" numbering prefix for the menu label.
label = path.stem
if len(label) > 3 and label[2] == "_" and label[:2].isdigit():
label = label[3:]
label = label.replace("_", " ").title()

menu.addAction(
QAction(
label,
self,
triggered=lambda checked, p=path: self.components[
"editor"
].load_example(str(p)),
)
)

def fill_dummy(self):

self.components["editor"].set_text(
Expand Down
22 changes: 22 additions & 0 deletions cq_editor/widgets/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ class Editor(CodeEditor, ComponentMixin):
# Tracks whether or not the document was saved from the internal editor vs an external editor
was_modified_by_self = False

# Set to True when a file was loaded from the examples directory; causes
# Save to redirect to Save As so the originals are never overwritten.
_is_example = False

# Helps display the completion list for the editor
completion_list = None

Expand Down Expand Up @@ -263,15 +267,32 @@ def load_from_file(self, fname):

self.set_text_from_file(fname)
self.filename = fname
self._is_example = False
self.reset_modified()

def load_example(self, fname):
"""Load a file from the examples directory.

Identical to load_from_file except it marks the buffer as an example
so that Save redirects to Save As, keeping the originals intact.
"""
self.load_from_file(fname)
self._is_example = True

def save(self):
"""
Saves the current document to the current filename if it exists, otherwise it triggers a
save-as dialog.
"""

if self._filename != "":
if self._is_example:
self.statusChanged.emit(
"Examples are read-only — choose a new location to save your changes"
)
self.save_as()
return

with open(self._filename, "w", encoding="utf-8", newline="") as f:
f.write(self.toPlainText().replace("\n", self._eol))

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

self.reset_modified()

Expand Down
19 changes: 19 additions & 0 deletions examples/01_hello_box.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Example 1: Hello Box
#
# The simplest possible CadQuery model: a rectangular box.
#
# Every CadQuery model starts with a Workplane. The string argument ("XY")
# selects the plane the first sketch or operation is relative to:
# "XY" — the horizontal plane (Z points up)
# "XZ" — front face of the box
# "YZ" — side face of the box
#
# .box(length, width, height) creates a box centered on the workplane origin.
#
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/

import cadquery as cq

result = cq.Workplane("XY").box(10, 10, 5)

show_object(result)
28 changes: 28 additions & 0 deletions examples/02_selectors_and_fillets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Example 2: Selectors and Fillets
#
# CadQuery uses selectors to pick specific edges, faces, or vertices from a
# solid so you can apply operations to them. This example rounds the four
# vertical edges of a box using a selector string.
#
# Common edge selectors:
# "|Z" — edges parallel to the Z axis
# "|X" — edges parallel to the X axis
# ">Z" — the edge(s) furthest in the +Z direction
# "#Z" — edges perpendicular to Z (i.e. lying in a horizontal plane)
#
# .fillet(radius) rounds the selected edges.
# .chamfer(length) cuts a 45-degree bevel instead.
#
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/

import cadquery as cq

result = (
cq.Workplane("XY")
.box(10, 10, 5)
# Select the four edges running vertically (parallel to Z) and fillet them.
.edges("|Z")
.fillet(1.5)
)

show_object(result)
39 changes: 39 additions & 0 deletions examples/03_extruded_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Example 3: Extruded Profile
#
# Rather than starting with a primitive shape, you can draw a 2D profile on a
# workplane and extrude it into a 3D solid. Sketch the cross-section, then
# give it thickness.
#
# The sketch is built by chaining 2D drawing commands:
# .moveTo(x, y) - move the pen without drawing
# .lineTo(x, y) - draw a line to an absolute position
# .close() - connect back to the starting point
#
# .extrude(depth) extrudes the closed profile in the workplane's normal direction.
#
# This example draws an L-shaped bracket profile and extrudes it.
#
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/

import cadquery as cq

# Dimensions (mm)
flange_width = 30
flange_height = 5
web_height = 25
web_thickness = 5

result = (
cq.Workplane("XY")
# Trace the L-shape profile clockwise starting at the origin.
.moveTo(0, 0)
.lineTo(flange_width, 0) # bottom edge of flange
.lineTo(flange_width, flange_height) # right edge of flange
.lineTo(web_thickness, flange_height) # step up to the web
.lineTo(web_thickness, flange_height + web_height) # top of web
.lineTo(0, flange_height + web_height) # top of web (left)
.close() # back to origin
.extrude(40) # give the profile a 40 mm depth
)

show_object(result)
49 changes: 49 additions & 0 deletions examples/04_holes_and_counterbores.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Example 4: Holes and Counterbores
#
# Holes are one of the most common features in mechanical parts. CadQuery
# provides dedicated methods for the hole types a machinist would recognise:
#
# .hole(diameter) - straight through-hole
# .cboreHole(diameter, cbore_d, cbore_depth) - counterbored hole (flat-bottomed recess)
# .cskHole(diameter, csk_d, csk_angle) - countersunk hole (conical recess)
#
# Holes are placed at the CURRENT STACK POSITIONS. A common pattern is to
# put points on a construction rectangle, select its vertices, and drill
# all four at once.
#
# .workplane() switches the active workplane to the selected face so that
# subsequent operations happen relative to it.
#
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/

import cadquery as cq

# Plate dimensions
length = 80.0
width = 60.0
thickness = 10.0

# Fastener dimensions (M4 socket-head cap screw)
bolt_dia = 4.4 # clearance hole diameter
cbore_dia = 8.0 # counterbore diameter
cbore_depth = 4.5 # counterbore depth

result = (
cq.Workplane("XY")
.box(length, width, thickness)

# Switch to the top face of the box.
.faces(">Z")
.workplane()

# Create a rectangle for construction only - its vertices define hole centres.
# forConstruction=True means the rectangle is not extruded or cut; it just
# acts as a layout guide for the hole locations.
.rect(length - 16, width - 16, forConstruction=True)
.vertices()

# Drill a counterbored hole at each of the four corner vertices.
.cboreHole(bolt_dia, cbore_dia, cbore_depth)
)

show_object(result)
43 changes: 43 additions & 0 deletions examples/05_loft.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Example 5: Loft
#
# A loft connects two or more cross-section profiles on different workplanes
# with a smoothly blended solid. It is useful for organic shapes, aerodynamic
# forms, and transitions between different cross-sections.
#
# The workflow is:
# 1. Sketch a profile on one workplane.
# 2. Move to another workplane (offset along an axis).
# 3. Sketch a second profile.
# 4. Call .loft() to blend between them.
#
# This example transitions from a square base to a circular top, like a
# funnel or adapter fitting.
#
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/

import cadquery as cq

base_size = 30 # side length of the square base (mm)
top_radius = 10 # radius of the circular top (mm)
height = 40 # overall height of the loft (mm)

result = (
cq.Workplane("XY")

# First profile: a square on the XY plane at Z=0.
.rect(base_size, base_size)

# Move up to Z=height and sketch the second profile.
# workplane(offset=height) creates a new XY-parallel plane at that height.
.workplane(offset=height)

# Second profile: a circle.
.circle(top_radius)

# Loft between the two profiles.
# ruled=False (default) gives a smooth blend.
# ruled=True would give straight ruled-surface sides instead.
.loft()
)

show_object(result)
42 changes: 42 additions & 0 deletions examples/06_sweep.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Example 6: Sweep
#
# A sweep extrudes a 2D cross-section along a 3D path (the "spine"). Unlike a
# straight extrude, the path can curve, making sweep ideal for pipes, channels,
# handles, and wiring conduits.
#
# The workflow:
# 1. Build the path (spine) as a Wire using CadQuery's 2D drawing commands,
# then convert it with .wire().
# 2. On a workplane perpendicular to the start of the spine, draw the profile.
# 3. Call .sweep(path) to extrude the profile along the spine.
#
# This example creates a curved pipe — a circular cross-section swept along
# a quarter-circle arc.
#
# For more examples and the full API reference, see https://cadquery.readthedocs.io/en/latest/

import cadquery as cq

pipe_radius = 3 # outer radius of the pipe cross-section (mm)
sweep_radius = 30 # radius of the quarter-circle sweep path (mm)

# Build the sweep path: a quarter-circle arc in the XZ plane.
# makeLine/makeArc helpers return Edge objects; Wire.assembleEdges()
# joins them into a single Wire the sweep can follow.
path = (
cq.Workplane("XZ")
.moveTo(sweep_radius, 0)
.radiusArc((0, sweep_radius), -sweep_radius) # quarter-circle arc
.wire()
)

result = (
# Start the profile on the XY plane at the beginning of the path.
cq.Workplane("XY")
.workplane(offset=0)
.moveTo(sweep_radius, 0) # centre of the profile matches path start
.circle(pipe_radius) # circular cross-section
.sweep(path) # follow the arc
)

show_object(result)
Loading
Loading