11 KiB
Fluency CAD — Agent Guide
Project Overview
Fluency CAD 2.0 is a parametric CAD application built on OpenCASCADE Technology (OCCT) with a modern pygfx-based 3D renderer. It provides 2D sketching with SolveSpace constraint solving, boolean operations, STEP/IGES/STL import/export, and exact BRep geometry.
- Language: Python 3.10+
- GUI: PySide6 (Qt6)
- Geometry Kernel: OCP (cadquery-ocp — OpenCASCADE Python bindings)
- Constraint Solver: python_solvespace
- Renderer: pygfx (WebGPU) + OCCRenderer (native OCC AIS display)
Architecture
src/fluency/
├── __init__.py # Package entry (version, imports)
├── main.py # Application entry point, MainWindow, Sketch2DWidget (~5000 lines)
├── sketch_solver.py # SolveSpace constraint solver wrapper (legacy)
├── geometry/
│ └── base.py # Abstract interfaces: GeometryKernel, SketchInterface, data classes
├── geometry_occ/
│ ├── kernel.py # OCGeometryKernel (extrude, boolean, fillet, import/export, mesh)
│ └── sketch.py # OCCSketch with SolveSpace integration (face detection, constraints)
├── models/
│ └── data_model.py # Project, Component, Sketch, Body dataclasses
├── rendering/
│ ├── base.py # Abstract Renderer interface
│ ├── occ_renderer.py # OCC AIS renderer (preferred — smooth BRep display)
│ └── pygfx_renderer.py # Legacy pygfx renderer
├── utils/ # Utility modules
└── widgets/ # Custom widgets
tests/
└── test_geometry.py # Comprehensive test suite (52+ tests)
Key Classes & Responsibilities
| Class | File | Purpose |
|---|---|---|
OCGeometryKernel |
kernel.py |
OCC shape ops: extrude, boolean, fillet, mesh, import/export |
OCCSketch |
sketch.py |
2D sketch with SolveSpace solver, face detection, workplane |
OCCSketchEntity |
sketch.py |
Entity (point/line/circle/arc) with solver handle, is_construction, is_external |
Sketch2DWidget |
main.py |
Qt widget for interactive 2D sketching (draw, snap, constrain) |
MainWindow |
main.py |
Main application window, toolbars, 3D viewer, operations |
OCCRenderer |
occ_renderer.py |
Native OCC AIS display (shaded + edges, face pick) |
Sketch |
data_model.py |
Data model: workplane, occ_sketch ref, source_body_id |
Body |
data_model.py |
3D solid with geometry, visibility, render object |
Component |
data_model.py |
Container for sketches and bodies |
Project |
data_model.py |
Top-level container with kernel |
Data Flow
User draws in Sketch2DWidget
→ OCCSketch entities created in solver
→ Constraint solving (python_solvespace)
→ OCCSketch.get_geometry() → detect_faces() → build_face_geometry()
→ OCGeometryKernel.extrude() → BRepPrimAPI_MakePrism
→ Boolean operations → Body added to Component
→ OCCRenderer.add_shape() → AIS display
Development Commands
# Install editable
pip install -e ".[dev]"
# Run app
python -m fluency.main
# Run tests (52 tests)
python -m pytest tests/test_geometry.py -v
# Run single test
python -m pytest tests/test_geometry.py::TestOCCSketch::test_workplane_extrude_with_hole -xvs
# Quck geometry test (raw OCC, no Qt)
python -c "from fluency.geometry_occ.sketch import OCCSketch; ..."
Code Conventions
General
- Line length: 100 chars (black/ruff config)
- Target Python: 3.10+ (uses
from __future__ import annotations, walrus, pattern matching) - Docstrings: Google/NumPy style preferred
- Logging:
logger = logging.getLogger(__name__)withlogging.DEBUGlevel
OCC / OCP
- Always use
is not Nonefor OCP objects —TopoDS_Shape.__bool__can be falsy even for valid shapes BRepBuilderAPI_MakeFace.Add(wire)expects aTopoDS_Wire.wire.Reversed()returnsTopoDS_Shape→ cast via_TopoDS.Wire_s(wire.Reversed())- Face normal direction: check
face.Orientation()vsTopAbs_REVERSED— a REVERSED face's outward normal is the NEGATION of the surface axis TopoDS_Wire_s(shape),TopoDS_Face_s(shape)— use_ssuffix from OCP for downcasts- Mesh:
BRepMesh_IncrementalMesh(shape, tol, False, 0.15, True)— default deflection 0.15 rad for smooth curves
Extrude / Cut Workflow
- Snapshot
list(self._current_component.bodies.items())BEFOREadd_body()— the new body must not be in the target set - Cut targets the source body (
sketch._source_body_idfrom face pick), notbodies[0] - The fix: apply boolean to target geometry, then remove tool body
- Plain extrude with holes: inner wires must have OPPOSITE geometric winding to the outer wire (see
build_face_geometryand_loop_signed_area)
Sketch / Solvers
python_solvespacehas NO remove API for entities/constraints. Deleting requires: drop from_points/_lines, prune_constraint_log,_rebuild_solver()(recreates entire system),_rebuild_labels(), re-solve_constraint_log: each entry is{"type": str, "ids": tuple[int,...], "params": tuple, "labels": set[str]}- Constraint labels: stored on point entities for paintEvent rendering; rebuilt via
_rebuild_labels() - Line constraints (
horizontal/vertical/parallel/perpendicular) need the line's solver handle, not a point's. Use_find_line_sketch_entity()to get the correct handle - External entities (underlay):
is_external=True,is_construction=True, fixed in solver (alwaysdragged). Stored in_external_entity_ids, excluded from_line_segments(),get_polygon_points(),get_closed_loops(),detect_faces(),get_geometry()
Face Detection
get_closed_loops(): uses snapped-coordinate graph (_SNAP_TOL = 1e-4) from line endpoint adjacency. Only accepts simple cycles (all nodes degree 2)detect_faces(): even-odd nesting rule via_loop_contains. Even depth = outer boundary, odd = hole_loop_rep_point: midpoint between centroid and first vertex. Fragile — can land inside a nested shape for certain geometries (e.g., a small hole near the centroid's direction from the first vertex)_loop_signed_area: shoelace formula for polygons,πr²(positive = CCW) for circles
Rendering
- OCCRenderer is the main renderer (not pygfx). Uses
AIS_Shape,V3d_Viewer,AIS_InteractiveContext - Face pick:
pick_planar_face(x, y)→MoveTo→DetectedShape→TopoDS_Face_s→BRepAdaptor_Surfaceplane check - Highlight:
highlight_face(face)creates a transparent AIS overlay;clear_face_highlight()removes it - Preview:
preview_shape(shape)for live transparent extrude preview - Navigation: Left=orbit, Middle=pan, Wheel=zoom. Right is RESERVED — check user before reassigning
Paint-Event Safety
- Every constraint-tag rendering loop wraps each entry in
try/exceptso a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event _point_world()and_entity_anchor()returnNone(not raise) for malformed input
Known Bugs & Fix Patterns
1. Hole Orientation in Extrusion (FIXED 2026-07-03)
Symptom: Inner shapes (circle/triangle/slot) inside a rectangle become solid islands instead of holes when extruding, depending on drag direction.
Root Cause: wire_loop in build_face_geometry unconditionally reversed hole wires (w.Reversed()). When the outer polygon was CW-winding (e.g., dragging from top-left to bottom-right), the reversed inner had the SAME effective direction as the outer, making OCC treat it as solid.
Fix: Added _loop_signed_area() to compute geometric winding. Hole wires are only reversed when their natural winding matches the outer's (ensuring opposite winding for holes).
Relevant code: sketch.py, build_face_geometry() and _loop_signed_area()
2. _loop_rep_point Fragility (KNOWN)
Symptom: Face detection fails when a nested shape contains the outer loop's representative point (midpoint between centroid and first vertex).
Would-be fix: Use a guaranteed-interior point (maximum inscribed circle center or perturbed centroid) instead of the centroid-first-vertex midpoint.
3. Extrude Cut / Target Selection (FIXED 2026-06-29)
Symptom: Cut created a separate "cavity-shaped" body next to the original instead of modifying the target.
Fix: Boolean result stored on TARGET body geometry; tool body removed from component. Auto-target via sketch._source_body_id.
4. Workplane Preservation (FIXED 2026-06-29)
Symptom: Sketch placed on a face lost its workplane after being added to component.
Fix: Copy occ_sketch workplane fields into Sketch dataclass BEFORE apply_workplane().
API Quirks
QPoint(0,0): falsy viaisNull()in PySide6 → always useis not NoneforOptional[QPoint]QMouseEvent/QPainterPath: live inPySide6.QtGui(NOTQtCore)BRepBuilderAPI_MakeFace.Add(): needsTopoDS_Wire.wire.Reversed()returnsTopoDS_Shape— cast viaTopoDS_Wire_s()- python_solvespace: NO entity/constraint remove API — workaround via
_rebuild_solver(). Parameters read viasolver.params(handle.params)→ returns(x, y)tuple - OCGeometryKernel.extrude: unwraps
OCCGeometryObject, rawTopoDS_Shape, or cadqueryWorkplane. Always useis not Nonefor the shape (not truthiness) - Sketch._source_body_id: dynamic attribute set on
Sketchdataclass, set during face-pick flow _get_shape(obj): returnsobj.shape.wrappedforOCCGeometryObject,obj.shapefor raw shapes,Nonefor empty. Useis not Noneguards everywhere
Memory / Agent Context
This project has extensive Pi memory (hermes-memory) for:
project="fluency"withtarget="failure": bugs, fixes, corrections, insightsproject="fluency"withtarget="memory": conventions, decisions, workflow patterns- Available skills:
fix-cad-app-pipeline,refactor-from-cadquery-to-ocp
Key memory queries for debugging:
- "hole orientation" →
_loop_signed_area/build_face_geometryfix - "extrude cut auto-target" → cut/target body fix
- "workplane preservation" → _add_sketch_to_component fix
- "_loop_rep_point" → face detection fragility
- "paint-event safety" → try/except per entry pattern
- "solver rebuild" → delete workflow via _rebuild_solver
- "face pick origin" → pick_planar_face face bbox centre
Testing Patterns
# Direct OCC test (no Qt)
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace
from OCP.gp import gp_Pnt
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.GProp import GProp_GProps; from OCP.BRepGProp import BRepGProp
# Build test shape, extrude, verify volume
mp = BRepBuilderAPI_MakePolygon(); ...
g = GProp_GProps(); BRepGProp.VolumeProperties_s(shape, g)
assert abs(g.Mass() - expected) < 0.1
# Sketch-based test
from fluency.geometry_occ.sketch import OCCSketch
from fluency.geometry_occ.kernel import OCGeometryKernel
sk = OCCSketch()
# ... add points, lines, circles ...
sk.solve()
geom = sk.get_geometry()
solid = OCGeometryKernel().extrude(geom, 10.0)