# 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 ```bash # 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__)` with `logging.DEBUG` level ### OCC / OCP - Always use `is not None` for OCP objects — `TopoDS_Shape.__bool__` can be falsy even for valid shapes - `BRepBuilderAPI_MakeFace.Add(wire)` expects a `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` → cast via `_TopoDS.Wire_s(wire.Reversed())` - Face normal direction: check `face.Orientation()` vs `TopAbs_REVERSED` — a REVERSED face's outward normal is the NEGATION of the surface axis - `TopoDS_Wire_s(shape)`, `TopoDS_Face_s(shape)` — use `_s` suffix 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())` **BEFORE** `add_body()` — the new body must not be in the target set - Cut targets the **source body** (`sketch._source_body_id` from face pick), not `bodies[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_geometry` and `_loop_signed_area`) ### Sketch / Solvers - `python_solvespace` has 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 (always `dragged`). 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_Surface` plane 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/except` so a bad entry (dangling id, corrupted geometry) doesn't crash the entire paint event - `_point_world()` and `_entity_anchor()` return `None` (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 via `isNull()` in PySide6 → always use `is not None` for `Optional[QPoint]` - **`QMouseEvent`/`QPainterPath`**: live in `PySide6.QtGui` (NOT `QtCore`) - **`BRepBuilderAPI_MakeFace.Add()`**: needs `TopoDS_Wire`. `wire.Reversed()` returns `TopoDS_Shape` — cast via `TopoDS_Wire_s()` - **python_solvespace**: NO entity/constraint remove API — workaround via `_rebuild_solver()`. Parameters read via `solver.params(handle.params)` → returns `(x, y)` tuple - **OCGeometryKernel.extrude**: unwraps `OCCGeometryObject`, raw `TopoDS_Shape`, or cadquery `Workplane`. Always use `is not None` for the shape (not truthiness) - **Sketch._source_body_id**: dynamic attribute set on `Sketch` dataclass, set during face-pick flow - **`_get_shape(obj)`**: returns `obj.shape.wrapped` for `OCCGeometryObject`, `obj.shape` for raw shapes, `None` for empty. Use `is not None` guards everywhere --- ## Memory / Agent Context This project has extensive Pi memory (hermes-memory) for: - `project="fluency"` with `target="failure"`: bugs, fixes, corrections, insights - `project="fluency"` with `target="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_geometry` fix - "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 ```python # 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 ``` ```python # 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) ```