diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 3bd4b28..10fc8ec 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,15 +4,11 @@
-
+
-
-
-
-
-
+
@@ -459,7 +455,15 @@
1785697123545
-
+
+
+ 1785701099363
+
+
+
+ 1785701099363
+
+
@@ -480,7 +484,6 @@
-
@@ -505,6 +508,7 @@
-
+
+
\ No newline at end of file
diff --git a/src/fluency/geometry_occ/kernel.py b/src/fluency/geometry_occ/kernel.py
index 1cb4615..77fc37e 100644
--- a/src/fluency/geometry_occ/kernel.py
+++ b/src/fluency/geometry_occ/kernel.py
@@ -75,7 +75,7 @@ class OCGeometryKernel(GeometryKernel):
return OCCGeometryObject(edge, {"type": "circle"})
def create_arc(
- self, center: Point2D, radius: float, start_angle: float, end_angle: float
+ self, center: Point2D, radius: float , start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
import math
diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py
index be159d3..9c78af2 100644
--- a/src/fluency/ui/main_window.py
+++ b/src/fluency/ui/main_window.py
@@ -1528,6 +1528,20 @@ class MainWindow(QMainWindow):
self._btn_con_horiz.setChecked(True)
elif mode == "constrain_vertical":
self._btn_con_vert.setChecked(True)
+ elif mode == "constrain_perpendicular":
+ self._btn_con_perp.setChecked(True)
+ elif mode == "constrain_parallel":
+ self._btn_con_par.setChecked(True)
+ elif mode == "constrain_distance":
+ self._btn_con_dist.setChecked(True)
+ elif mode == "constrain_midpoint":
+ self._btn_con_mid.setChecked(True)
+ elif mode == "constrain_ptline":
+ self._btn_con_ptline.setChecked(True)
+ elif mode == "constrain_symmetric":
+ self._btn_con_sym.setChecked(True)
+ elif mode == "constrain_diameter":
+ self._btn_con_diameter.setChecked(True)
def _on_construct_change(self, checked):
"""Handle the "Cstrct" toolbar button.
diff --git a/src/fluency/ui/sketch_widget.py b/src/fluency/ui/sketch_widget.py
index cc6fe87..c73460e 100644
--- a/src/fluency/ui/sketch_widget.py
+++ b/src/fluency/ui/sketch_widget.py
@@ -98,6 +98,7 @@ class Sketch2DWidget(QWidget):
constrain_done = Signal()
sketch_updated = Signal()
# Emitted when the SolveSpace solver returns a non-OKAY result
+ constraint_failed = Signal(int, str) # (constraint_log_index, failure_reason)
# (INCONSISTENT, DIDNT_CONVERGE, or an exception). The payload is
# a short human-readable explanation; the main window hooks this up
# to the status bar so the user sees the failure instead of the
@@ -163,6 +164,13 @@ class Sketch2DWidget(QWidget):
self._snap_distance: int = 10
self._angle_steps: int = 15
+ # Constraint status indicators
+ self._constraint_status: Dict[int, Tuple[str, float]] = {}
+ self._inconsistent_constraints: set[int] = set()
+ self._failed_constraint_idx: Optional[int] = None
+ self._failed_constraint_reason: str = ""
+ self._global_inconsistent: bool = False
+
self._zoom: float = 1.0
self._offset: QPoint = QPoint(0, 0)
self._panning: bool = False
@@ -1588,6 +1596,47 @@ class Sketch2DWidget(QWidget):
new_arcs.append((centre_ent, new_radius, start_ent, end_ent, new_sweep))
self._arcs = new_arcs
+ def _update_constraint_status(self, status: str, message: str, log_index: int = -1) -> None:
+ """Update the visual status indicator for constraint solving results."""
+ import time
+ now = time.time()
+ # Keep only recent statuses (last 5 seconds)
+ self._constraint_status = {
+ k: (v[0], now)
+ for k, v in self._constraint_status.items()
+ if now - v[1] < 5.0
+ }
+ if log_index >= 0:
+ self._constraint_status[log_index] = (status, now)
+ # Track inconsistent/failed constraints separately for highlighting
+ # Only track if log_index is valid (>= 0) to avoid corrupting state
+ if "inconsistent" in status.lower() or status == "FAILED":
+ if log_index >= 0:
+ self._inconsistent_constraints.add(log_index)
+ # Also track the most recent failed constraint index for highlighting
+ self._failed_constraint_idx = log_index
+ else:
+ # Global inconsistent mode - no specific constraint, but solver is in bad state
+ self._global_inconsistent = True
+ elif status == "OKAY":
+ if log_index >= 0:
+ self._inconsistent_constraints.discard(log_index)
+ # Clear failed constraint when resolved (only if log_index was valid)
+ if log_index == self._failed_constraint_idx:
+ self._failed_constraint_idx = None
+ else:
+ # Global OKAY - clear global inconsistent mode
+ self._global_inconsistent = False
+
+ def mark_constraint_failed(self, log_index: int, reason: str) -> None:
+ """Mark a specific constraint as failed so it can be highlighted."""
+ self._failed_constraint_idx = log_index
+ self._failed_constraint_reason = reason
+ # Also add to inconsistent set for orange indicator
+ self._inconsistent_constraints.add(log_index)
+ # Emit signal so the user can remove this constraint via context menu
+ self.constraint_failed.emit(log_index, reason)
+
def _solve_and_sync(self) -> bool:
"""Solve constraints, sync positions, update UI. Returns True if solved OK.
@@ -1597,15 +1646,25 @@ class Sketch2DWidget(QWidget):
:attr:`OCCSketch.last_solve_status`. The main window hooks this
to the status bar so the user sees the failure instead of the
geometry silently staying put.
+
+ Also updates the visual constraint status indicator in the top-left
+ corner and marks any failed constraints for highlighting.
"""
if not self._sketch:
return True
ok = self._sketch.solve()
+ failure_reason = getattr(self._sketch, "last_solve_status", "Unknown error")
if not ok:
# Surface the failure to the user. Status text comes from
# OCCSketch.last_solve_status (e.g. "inconsistent: the new
# constraint conflicts with existing constraints").
- self.solver_warning.emit(f"Solver failed — {self._sketch.last_solve_status}")
+ self.solver_warning.emit(f"Solver failed — {failure_reason}")
+ # Update visual indicator for inconsistent/failed constraints
+ # Pass -1 to indicate this is a global status, not tied to a specific constraint log entry
+ self._update_constraint_status("INCONSISTENT", failure_reason, log_index=-1)
+ else:
+ # OKAY - clear any previous inconsistent status
+ self._update_constraint_status("OKAY", "Constraints satisfied")
self._sync_solved_positions()
self.update()
return ok
@@ -2313,6 +2372,80 @@ class Sketch2DWidget(QWidget):
self._solve_and_sync()
self.update()
+ # Add option to delete the failed constraint (right-click anywhere when constraint failed)
+ if self._failed_constraint_idx is not None:
+ delete_failed = menu.addAction("Delete Failed Constraint")
+ if action == delete_failed:
+ self._delete_failed_constraint()
+ return
+
+ def _get_failed_constraint_entities(self) -> List[Any]:
+ """Get the entities involved in the failed constraint for highlighting."""
+ if self._failed_constraint_idx is None or self._sketch is None:
+ return []
+ try:
+ entry = self._sketch._constraint_log[self._failed_constraint_idx]
+ ids = entry.get("ids", ())
+ entities = []
+ for eid in ids:
+ entity = self._sketch._entities.get(eid)
+ if entity is not None:
+ entities.append(entity)
+ return entities
+ except (IndexError, KeyError):
+ return []
+
+ def _highlight_failed_constraint(self, painter: QPainter) -> None:
+ """Draw a visual highlight around the failed constraint using the given painter."""
+ # Safety check: only highlight if we have a valid, non-negative index
+ if self._failed_constraint_idx is None or self._failed_constraint_idx < 0:
+ return
+ entities = self._get_failed_constraint_entities()
+ if not entities:
+ return
+ # Bright red glow effect
+ pen = QPen(QColor("#ef4444"), 3, Qt.DashDotLine)
+ brush = QBrush(QColor("rgba(255,0,0,100)") if hasattr(QColor, "fromRgb") else QColor("#ef4444"))
+ painter.setPen(pen)
+ painter.setBrush(brush)
+ for entity in entities:
+ xy = self._flat_xy(entity.geometry)
+ if xy is None:
+ continue
+ x, y = xy
+ screen_pos = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
+ # Draw a pulsing circle around each entity
+ radius = 10
+ painter.drawEllipse(screen_pos, radius, radius)
+
+ def _delete_failed_constraint(self):
+ """Delete the currently failed constraint and recompute."""
+ idx = self._failed_constraint_idx
+ if idx is None or idx < 0:
+ # Clear global inconsistent mode if no specific constraint to delete
+ self._global_inconsistent = False
+ return
+ # If no sketch exists, just clear the tracking state (user can right-click to delete)
+ if self._sketch is None:
+ self._failed_constraint_idx = None
+ self._failed_constraint_reason = ""
+ self._inconsistent_constraints.discard(idx)
+ self._global_inconsistent = False
+ return
+ # Save state before deleting
+ if self._undo_manager:
+ self._undo_manager.save_state()
+ ok = self._sketch.remove_constraint_at(idx)
+ logger.info(f"Deleted failed constraint #{idx}; recompute solved={ok}")
+ self._failed_constraint_idx = None
+ self._failed_constraint_reason = ""
+ self._inconsistent_constraints.discard(idx)
+ self._hovered_constraint_idx = -1
+ self._rebuild_from_sketch()
+ self._solve_and_sync()
+ self.sketch_updated.emit()
+ self.update()
+
def _delete_hovered_constraint(self):
"""Delete the hovered constraint (by log index) and recompute the rest."""
idx = self._hovered_constraint_idx
@@ -3407,6 +3540,53 @@ class Sketch2DWidget(QWidget):
painter.setRenderHint(QPainter.Antialiasing)
painter.fillRect(self.rect(), QColor("#1e1e2e"))
+ # ── Constraint status indicator (top-left corner) ──
+ # Shows orange text for inconsistent constraints, bright red when a constraint failed.
+ has_status = self._inconsistent_constraints or self._failed_constraint_idx is not None or self._global_inconsistent
+
+ if has_status:
+ colors: List[QColor] = []
+ status_text: List[str] = []
+
+ # Orange indicator for inconsistent constraints
+ if self._inconsistent_constraints:
+ count = len(self._inconsistent_constraints)
+ status_text.append(f"⚠ {count} inconsistent")
+ colors.append(QColor("#fb923c")) # orange-400
+
+ # Bright red indicator for failed constraint
+ if self._failed_constraint_idx is not None:
+ reason = self._failed_constraint_reason[:50] + "..." if len(self._failed_constraint_reason) > 50 else self._failed_constraint_reason
+ status_text.append(f"❌ Failed: {reason}")
+ colors.append(QColor("#ef4444")) # red-500
+
+ # Red indicator for global solver inconsistency (no specific constraint)
+ if self._global_inconsistent:
+ status_text.append("⚠ Solver inconsistent")
+ colors.append(QColor("#ef4444")) # red-500
+
+ if status_text:
+ # Draw status text in top-left corner with colored background bar
+ font_metrics = QFontMetrics(self.font())
+ y_pos = 12
+
+ for i, line in enumerate(status_text):
+ text_width = font_metrics.horizontalAdvance(line)
+ # Background bar with color gradient effect
+ bg_rect = QRect(8, y_pos - 4, max(text_width + 30, 150), font_metrics.height() + 6)
+ painter.setPen(Qt.NoPen)
+ brush = QBrush(colors[i] if i < len(colors) else QColor("#fb923c"))
+ painter.setBrush(brush)
+ painter.drawRoundedRect(bg_rect, 8, 8)
+
+ # Text
+ painter.setPen(QColor("#1e1e2e"))
+ painter.drawText(bg_rect, Qt.AlignLeft | Qt.AlignVCenter, line)
+ y_pos += font_metrics.height() + 4
+
+ # ── Highlight failed constraint (drawn after all geometry) ──
+ self._highlight_failed_constraint(painter)
+
# ── Grid (fixed 10mm world-units spacing) ──
# Minor grid: 10 world-unit (10mm) spacing.
# Major grid: 100 world-unit (100mm) spacing drawn bolder.