Files
fluencyCAD/agent.md

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__) 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)MoveToDetectedShapeTopoDS_Face_sBRepAdaptor_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

# 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)