diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0ca23ac --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git push*)", + "command": "BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); ORIGIN=\"origin/$BRANCH\"; if git rev-parse \"$ORIGIN\" >/dev/null 2>&1; then BASE=\"$ORIGIN\"; else BASE=$(git rev-list --max-parents=0 HEAD 2>/dev/null); fi; COUNT=$(git log \"$BASE..HEAD\" --oneline 2>/dev/null | wc -l | tr -d ' '); if [ \"$COUNT\" -gt 0 ] && ! git log \"$BASE..HEAD\" --name-only --format=\"\" 2>/dev/null | grep -q \"^CLAUDE\\.md$\"; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"REMINDER: CLAUDE.md has not been updated in the commits being pushed. Update the Current Status section (Working on / Next step / Blocked by) and commit it before pushing.\"}}'; fi", + "statusMessage": "Checking CLAUDE.md is up to date…" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b78c54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Release artifacts — built locally, uploaded to GitHub Releases +*.zip + +# Python +__pycache__/ +*.pyc +*.pyo + +# macOS +.DS_Store + +# Ruff / linter caches +.ruff_cache/ + +# IDE +.vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c88430c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# FusionConstraints — ConstraintLens Add-in + +## Current Status + +**Working on:** v1 polish backlog. +**Version:** 0.1.7 released — backlog #5, #8 shipped. Previous: 0.1.6 (backlog #1, #2, #3, #9, #10). +**Next step:** Pick next backlog item (see v1 Polish Backlog below). +**Blocked by:** Nothing. + +### What's verified working (PC tests 1–5 + v0.1.6 session) +- Add-in loads, palette docks, populates without Refresh click. +- Geometric constraints list with click-to-select, ⌖ select-constraint, × delete + auto-refresh. +- Dimensions list (Angular, Linear, Diameter, etc.) with parameter expression. +- Implicit endpoint joins as pseudo-rows with implicit badge AND ⊘ lock icon + tooltip (0.1.3). +- Tangent spline+line row highlights both objects on click (0.1.3 — token-based selection). +- OffsetConstraint row lists curve chips (0.1.4). +- SketchOffsetCurvesDimension row lists curve chips AND highlights on click (0.1.5). ✓ PC test 5 +- Sketch status banner (name, component, fully/under-constrained). +- M-1 defensive guard (MidPoint accessor) — both rows render; no crash. +- OffsetConstraint ACCESSOR error fixed in 0.1.2. +- Auto-load fixed in 0.1.2 (palette populated without Refresh click). +- Button relocated to `SketchConstraintsPanel` (Sketch tab → Constraints panel). ✓ backlog #1 +- "Show u/c" button highlights underconstrained entities on canvas via `executeTextCommand("Sketch.ShowUnderconstrained")`; shows result string as toast. ✓ backlog #2 +- Filter bar narrows rows by label or constraint type (case-insensitive, client-side); section headers show filtered count. ✓ backlog #3 +- OffsetConstraint label normalised: `Offset (1→1 curves, 30 mm)` style. ✓ backlog #9 +- Dimension entity chips show friendly names ("Line 2 → Line 3") for Angular, Diameter, Radial and other type-specific-accessor subtypes. ✓ backlog #10 +- Bulk delete: checkboxes on deletable rows, "Delete N" + "Clear" buttons in toolbar, confirm dialog, Python loops deletions. Single × button removed. ✓ backlog #5 +- Invisible entity chips (e.g. hidden spline-offset control geometry) rendered dimmed with dashed border and "hidden" badge. ✓ backlog #8 + +### What was fixed in 0.1.5 (verified PC test 5) +- **SketchOffsetCurvesDimension matching, hardened** — `_find_offset_constraint_for_dim` stacks four strategies because `OffsetConstraint.distance` returns None on the January 2026 build: (1) parameter entityToken, (2) parameter name, (3) positional pairing, (4) single-constraint fallback. Once matched, constraint's `parentCurves` + `childCurves` become the dimension's chips. + +### Known sub-issues to keep on radar +- Offset-of-spline creates internal control geometry that Fusion doesn't render. User reports a tangent constraint on a line that isn't visible on the canvas. The row still appears in ConstraintLens but the line can't be selected by the user. See backlog #8. +- `OffsetConstraint.distance` returns None in the January 2026 build. The label-only consequence is now fully resolved via the matched dimension's parameter expression (backlog #9 fix). + +--- + +## Project Overview + +A Fusion 360 Python add-in that docks a panel listing every constraint in the active sketch — with click-to-select, delete, and over/under-constrained status. Fills the UX gap of having to hunt tiny on-canvas glyphs to audit a sketch. + +- **Language:** Python 3.14 (Fusion January 2026 build), vanilla JS palette (no framework). +- **Distribution:** GitHub Releases only (zipped `ConstraintLens/` folder). No App Store. +- **Workspace:** Solid workspace only (MVP). Button lives in `SketchConstraintsPanel` (Sketch tab → Constraints panel). +- **Spec:** `SPEC.md` — complete, all 5 open questions resolved. + +--- + +## Architecture + +``` +ConstraintLens/ +├── ConstraintLens.manifest Fusion add-in manifest (id, version, runOnStartup) +├── ConstraintLens.py Entry point — delegates to lib/lifecycle only +└── lib/ + ├── lifecycle.py Command + palette creation, message routing + ├── events.py GC-safe event handler registry (M-7 guard) + ├── dispatch.py 21-row constraint type dispatch table + dimension dispatch + ├── scanner.py Sketch enumeration → JSON payload + ├── labels.py EntityLabeler: token→"Line 3" map per scan + ├── selection.py ui.activeSelections helpers + ├── actions.py delete_constraint() with isDeletable check + ├── tokens.py token_of() / resolve() wrappers + └── messaging.py palette.sendInfoToHTML / parse_incoming + M-8 guard +palette/ + ├── index.html Shell; initial "Loading…" state + ├── app.js Vanilla JS render loop + message handler + └── styles.css Dark theme matching Fusion +tests/ + ├── fixture_sketch/ Creates ConstraintLens_Fixture (4 constraints, 2 dims) + ├── spike_probe/ API feasibility probe (all 5 Qs answered — run again after Fusion updates) + └── fixture_midpoint/ M-1 trigger fixture (midpoint-to-midpoint) +``` + +### Key conventions +- **Collections:** `SketchCurveVector` (from `.parentCurves`, `.childCurves`, `.curves`) uses `len()` + iteration, not `.count`. `ObjectCollection` uses `.count` + `.item(i)`. +- **Event handlers:** Always appended to `events._handlers` list (M-7). Never instantiate a handler without pinning it. +- **Palette sends:** Always gated on `palette.isVisible` (M-8 guard in `messaging.send()`). +- **Entity selection:** JS sends `entityTokens` list; Python resolves each via `tokens.resolve()` (primary path). `_entities_for_row()` accessor re-scan is fallback only. +- **Test scripts:** Each must be in a same-named subfolder (e.g. `tests/fixture_sketch/fixture_sketch.py`) — Fusion requirement. + +--- + +## Resolved Open Questions (SPEC.md §10) + +| # | Question | Answer | +|---|---|---| +| Q1 | Panel id | `SolidScriptsAddinsPanel` confirmed. `SketchConstraintsPanel` exists for v1 relocation. | +| Q2 | ShowUnderconstrained precondition | Requires sketch edit context. Returns plain string `'Under constrained points: N, under constrained curves: N'`. | +| Q3 | Palette `shown` event | No `shown`/`opened` event exists. `commandTerminated` is the only refresh trigger after restore. | +| Q4 | entityToken stability | Stable across save-reload. `findEntityByToken` returns non-empty `BaseVector`. | +| Q5 | VerticalConstraint enumerated? | Yes — `adsk::fusion::VerticalConstraint` appears in `geometricConstraints` iteration. | + +--- + +## Known Remaining Limitations (MVP scope, documented in README) + +- No granular undo for delete — Fusion `Ctrl+Z` reverts the whole sketch-edit chunk. +- Implicit coincident joins cannot be deleted from the panel (shared `SketchPoint`, not a real constraint). +- `CircularPatternConstraint` / `RectangularPatternConstraint`: Delete only; no entity accessor. +- `AssemblyConstraint` not supported (preview API, January 2026). +- Palette has no `shown` event — stale data after minimize/restore until next `commandTerminated`. + +--- + +## v1 Polish Backlog (post-MVP, not started) + +1. ~~Move button to `SketchConstraintsPanel` for in-sketch discoverability.~~ **DONE ✓** +2. ~~"Show underconstrained" button~~ — **DONE ✓** "Show u/c" button in toolbar; calls `executeTextCommand("Sketch.ShowUnderconstrained")`; result surfaced as toast. +3. ~~Filter / search by constraint type~~ — **DONE ✓** Filter bar below toolbar; client-side filtering by label/kind; section headers show `(N of M)` when active. +4. Constraint icons matching Fusion's own glyph set. +5. ~~Bulk delete with confirmation.~~ **DONE ✓** Checkboxes on deletable rows; "Delete N" + "Clear" toolbar buttons; Ctrl+Z note in confirm dialog; single × button removed. +6. Inline editable dimension expression. +7. **Sketch-→-palette reverse lookup** — user picks an entity on the canvas, ConstraintLens scrolls to / highlights every row that references it. Lets the user start from the geometry rather than the list. +8. ~~**Mark invisible / unselectable entities**~~ — **DONE ✓** `chip_for()` checks `entity.isVisible`; invisible chips rendered dimmed + dashed border + "hidden" badge. Note: clicking a row still selects/reveals the hidden entity on canvas — this is Fusion's native behaviour. +9. ~~**Normalize OffsetConstraint label**~~ — **DONE ✓** Label is now `Offset (1→1 curves, 30 mm)` style, pulling expression from the matched SketchOffsetCurvesDimension. +10. ~~**Dimension entity chip labels — show "Line 2" not "SketchLine"**~~ — **DONE ✓** `_DIM_ACCESSORS` map added to `dispatch.py`; Angular/Diameter/Radial and others now use type-specific accessors with `entityOne`/`entityTwo` fallback. +11. ~~**Verify fully-constrained green status**~~ — **VERIFIED PC test (session 5+).** Banner turns green and reads "— fully constrained" correctly. +12. **Canvas-to-palette entity name lookup** — user clicks a sketch entity on the canvas, sees its ConstraintLens name (e.g. "Line 3") somewhere in the UI, then can type that name into the filter bar to find all rows that reference it. Complement to backlog #7 (reverse lookup that auto-scrolls); this simpler variant just exposes the name. Could be implemented as a hover tooltip on canvas selection events, a small "selected entity" readout in the palette toolbar, or by reacting to Fusion's `activeSelections` change event and displaying the resolved label. diff --git a/ConstraintLens/ConstraintLens.manifest b/ConstraintLens/ConstraintLens.manifest new file mode 100644 index 0000000..c4b8f01 --- /dev/null +++ b/ConstraintLens/ConstraintLens.manifest @@ -0,0 +1,13 @@ +{ + "autodeskProduct": "Fusion360", + "type": "addin", + "id": "8a3f4d5e-1c2b-4f6a-9e0d-7b5c8a2d1e3f", + "author": "ConstraintLens contributors", + "description": { + "": "Docked panel listing every sketch constraint in the active sketch — click to select, delete, and diagnose." + }, + "version": "0.1.7", + "runOnStartup": false, + "supportedOS": "windows|mac", + "editEnabled": true +} diff --git a/ConstraintLens/ConstraintLens.py b/ConstraintLens/ConstraintLens.py new file mode 100644 index 0000000..26c3c60 --- /dev/null +++ b/ConstraintLens/ConstraintLens.py @@ -0,0 +1,33 @@ +# ConstraintLens — Fusion 360 add-in entry point (see SPEC.md section 4). +# +# Owns only run(context) / stop(context). All logic lives under ./lib. + +import os +import sys +import traceback + +import adsk.core + +_ADDIN_DIR = os.path.dirname(os.path.realpath(__file__)) +if _ADDIN_DIR not in sys.path: + sys.path.insert(0, _ADDIN_DIR) + +from lib import lifecycle # noqa: E402 + + +def run(context): + try: + lifecycle.start(_ADDIN_DIR) + except Exception: + ui = adsk.core.Application.get().userInterface + if ui: + ui.messageBox("ConstraintLens failed to start:\n" + traceback.format_exc()) + + +def stop(context): + try: + lifecycle.stop() + except Exception: + ui = adsk.core.Application.get().userInterface + if ui: + ui.messageBox("ConstraintLens failed to stop cleanly:\n" + traceback.format_exc()) diff --git a/ConstraintLens/lib/__init__.py b/ConstraintLens/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ConstraintLens/lib/actions.py b/ConstraintLens/lib/actions.py new file mode 100644 index 0000000..94c0fe1 --- /dev/null +++ b/ConstraintLens/lib/actions.py @@ -0,0 +1,36 @@ +# lib/actions.py — destructive operations (SPEC.md section 4). + +from dataclasses import dataclass + +import adsk.core +import adsk.fusion + +from . import tokens + + +@dataclass(frozen=True) +class ActionResult: + ok: bool + message: str + + +def delete_constraint(app: adsk.core.Application, token: str) -> ActionResult: + design = adsk.fusion.Design.cast(app.activeProduct) + if not design: + return ActionResult(False, "No active design.") + entity = tokens.resolve(design, token) + if entity is None: + return ActionResult(False, "Constraint not found (token unresolved).") + try: + is_deletable = bool(entity.isDeletable) + except Exception: + is_deletable = True + if not is_deletable: + return ActionResult(False, "Constraint reports isDeletable == False.") + try: + ok = bool(entity.deleteMe()) + except Exception as exc: + return ActionResult(False, f"deleteMe() raised: {exc}") + if not ok: + return ActionResult(False, "deleteMe() returned False.") + return ActionResult(True, "Deleted.") diff --git a/ConstraintLens/lib/dispatch.py b/ConstraintLens/lib/dispatch.py new file mode 100644 index 0000000..aa457d5 --- /dev/null +++ b/ConstraintLens/lib/dispatch.py @@ -0,0 +1,556 @@ +# lib/dispatch.py — constraint type dispatch table (SPEC.md section 5). +# +# DISPATCH is keyed by objectType string. Each entry knows how to build +# a label and the entity-chip list for that subtype. Defensive guards +# implement landmines M-1 (MidPoint .point), M-2 (pattern stubs), and +# the universal "accessor raised" fallback. + +from collections.abc import Callable +from dataclasses import dataclass, field + +from .labels import EntityLabeler + + +@dataclass(frozen=True) +class ScanResult: + label: str + entities: list[dict] + errors: list[str] = field(default_factory=list) + + +Builder = Callable[[object, EntityLabeler], ScanResult] + + +@dataclass(frozen=True) +class ConstraintDescriptor: + object_type: str + kind: str + glyph: str + build: Builder + + +def _safe(getter, default=None): + try: + return getter() + except Exception: + return default + + +def _chips(lab: EntityLabeler, *entities) -> list[dict]: + return [lab.chip_for(e) for e in entities if e is not None] + + +def _two_entity(c, lab, a: str, b: str, sep: str, kind_label: str) -> ScanResult: + e1 = _safe(lambda: getattr(c, a)) + e2 = _safe(lambda: getattr(c, b)) + errors: list[str] = [] + if e1 is None: + errors.append(f"accessor unavailable: .{a}") + if e2 is None: + errors.append(f"accessor unavailable: .{b}") + l1 = lab.label_for(e1) if e1 else "" + l2 = lab.label_for(e2) if e2 else "" + return ScanResult(f"{kind_label} — {l1} {sep} {l2}", _chips(lab, e1, e2), errors) + + +def _single_line(c, lab, kind_label: str) -> ScanResult: + line = _safe(lambda: c.line) + errors = [] if line else ["accessor unavailable: .line"] + label = f"{kind_label} — {lab.label_for(line) if line else ''}" + return ScanResult(label, _chips(lab, line), errors) + + +def _line_on_surface(c, lab, kind_label: str) -> ScanResult: + line = _safe(lambda: c.line) + surface = _safe(lambda: c.planarSurface) + errors = [] + if line is None: + errors.append("accessor unavailable: .line") + if surface is None: + errors.append("accessor unavailable: .planarSurface") + line_label = lab.label_for(line) if line else "" + chips = _chips(lab, line) + if surface is not None: + # External-surface fallback per SPEC.md table row 9. + try: + comp_name = surface.body.parentComponent.name + surface_label = f"face on {comp_name}" + except Exception: + surface_label = "external surface" + chips.append({"token": "", "kind": "BRepFace", "label": surface_label}) + return ScanResult(f"{kind_label} — {line_label}", chips, errors) + + +# --- Builders, in dispatch-table order ---------------------------------- + + +def _b_horizontal(c, lab): + return _single_line(c, lab, "Horizontal") + + +def _b_vertical(c, lab): + return _single_line(c, lab, "Vertical") + + +def _b_horizontal_points(c, lab): + return _two_entity(c, lab, "pointOne", "pointTwo", "↔", "Horizontal align") + + +def _b_vertical_points(c, lab): + return _two_entity(c, lab, "pointOne", "pointTwo", "↔", "Vertical align") + + +def _b_parallel(c, lab): + return _two_entity(c, lab, "lineOne", "lineTwo", "∥", "Parallel") + + +def _b_perpendicular(c, lab): + return _two_entity(c, lab, "lineOne", "lineTwo", "⊥", "Perpendicular") + + +def _b_collinear(c, lab): + return _two_entity(c, lab, "lineOne", "lineTwo", "⋯", "Collinear") + + +def _b_coincident(c, lab): + p = _safe(lambda: c.point) + e = _safe(lambda: c.entity) + errors = [] + if p is None: + errors.append("accessor unavailable: .point") + if e is None: + errors.append("accessor unavailable: .entity") + return ScanResult( + f"Coincident — {lab.label_for(p) if p else ''} on " + f"{lab.label_for(e) if e else ''}", + _chips(lab, p, e), + errors, + ) + + +def _b_coincident_to_surface(c, lab): + p = _safe(lambda: c.point) + surface = _safe(lambda: c.surface) + errors = [] + if p is None: + errors.append("accessor unavailable: .point") + if surface is None: + errors.append("accessor unavailable: .surface") + chips = _chips(lab, p) + if surface is not None: + try: + comp_name = surface.body.parentComponent.name + surface_label = f"face on {comp_name}" + except Exception: + surface_label = "external surface" + chips.append({"token": "", "kind": "Surface", "label": surface_label}) + return ScanResult( + f"Coincident to surface — {lab.label_for(p) if p else ''}", + chips, + errors, + ) + + +def _b_tangent(c, lab): + return _two_entity(c, lab, "curveOne", "curveTwo", "⌒", "Tangent") + + +def _b_equal(c, lab): + return _two_entity(c, lab, "curveOne", "curveTwo", "=", "Equal") + + +def _b_concentric(c, lab): + return _two_entity(c, lab, "entityOne", "entityTwo", "⊙", "Concentric") + + +def _b_midpoint(c, lab): + # Landmine M-1: .point raises on midpoint-to-midpoint configurations. + p = _safe(lambda: c.point) + curve = _safe(lambda: c.midPointCurve) + errors = [] + if p is None: + errors.append("midpoint-to-midpoint: .point accessor unavailable (Fusion bug M-1)") + if curve is None: + errors.append("accessor unavailable: .midPointCurve") + return ScanResult( + f"Midpoint — {lab.label_for(p) if p else ''} mid " + f"{lab.label_for(curve) if curve else ''}", + _chips(lab, p, curve), + errors, + ) + + +def _b_symmetry(c, lab): + e1 = _safe(lambda: c.entityOne) + e2 = _safe(lambda: c.entityTwo) + sym = _safe(lambda: c.symmetryLine) + errors = [] + if e1 is None: + errors.append("accessor unavailable: .entityOne") + if e2 is None: + errors.append("accessor unavailable: .entityTwo") + if sym is None: + errors.append("accessor unavailable: .symmetryLine") + return ScanResult( + f"Symmetric — {lab.label_for(e1) if e1 else ''} ↔ " + f"{lab.label_for(e2) if e2 else ''} about " + f"{lab.label_for(sym) if sym else ''}", + _chips(lab, e1, e2, sym), + errors, + ) + + +def _iter_curves_into_chips(coll, lab, chips: list[dict]) -> int: + """Iterate a curve collection (SketchCurveVector or ObjectCollection), + append a chip for each item, return the count appended. + Tolerates both iteration patterns: direct `for x in coll` (vectors) and + `.count` + `.item(i)` (ObjectCollection).""" + if coll is None: + return 0 + n = 0 + try: + for curve in coll: + chips.append(lab.chip_for(curve)) + n += 1 + return n + except TypeError: + pass + except Exception: + return n + try: + for i in range(coll.count): + chips.append(lab.chip_for(coll.item(i))) + n += 1 + except Exception: + pass + return n + + +def _b_offset(c, lab): + parent = _safe(lambda: c.parentCurves) + child = _safe(lambda: c.childCurves) + distance = _safe(lambda: c.distance) + chips: list[dict] = [] + n = _iter_curves_into_chips(parent, lab, chips) + m = _iter_curves_into_chips(child, lab, chips) + expr = "?" + try: + if distance is not None: + expr = distance.expression + except Exception: + expr = "?" + return ScanResult(f"Offset ({n}→{m} curves, {expr})", chips, []) + + +def _b_polygon(c, lab): + lines = _safe(lambda: c.lines) + center = _safe(lambda: c.centerSketchPoint) + n = lines.count if lines is not None else 0 + return ScanResult( + f"Polygon ({n} sides) about {lab.label_for(center) if center else ''}", + _chips(lab, center), + [] if center is not None else ["accessor unavailable: .centerSketchPoint"], + ) + + +def _b_circular_pattern(c, lab): + # Landmine M-2: read-only stub. + return ScanResult("Circular pattern (read-only)", [], []) + + +def _b_rectangular_pattern(c, lab): + # Landmine M-2: read-only stub. + return ScanResult("Rectangular pattern (read-only)", [], []) + + +def _b_line_on_surface(c, lab): + return _line_on_surface(c, lab, "Line on surface") + + +def _b_line_parallel_surface(c, lab): + return _line_on_surface(c, lab, "Line ∥ surface") + + +def _b_perpendicular_surface(c, lab): + return _line_on_surface(c, lab, "Line ⊥ surface") + + +# --- The table ---------------------------------------------------------- + + +_ENTRIES: list[ConstraintDescriptor] = [ + ConstraintDescriptor("adsk::fusion::HorizontalConstraint", "HorizontalConstraint", "horizontal.svg", _b_horizontal), + ConstraintDescriptor("adsk::fusion::VerticalConstraint", "VerticalConstraint", "vertical.svg", _b_vertical), + ConstraintDescriptor("adsk::fusion::HorizontalPointsConstraint", "HorizontalPointsConstraint", "horizontal.svg", _b_horizontal_points), + ConstraintDescriptor("adsk::fusion::VerticalPointsConstraint", "VerticalPointsConstraint", "vertical.svg", _b_vertical_points), + ConstraintDescriptor("adsk::fusion::ParallelConstraint", "ParallelConstraint", "parallel.svg", _b_parallel), + ConstraintDescriptor("adsk::fusion::PerpendicularConstraint", "PerpendicularConstraint", "perpendicular.svg", _b_perpendicular), + ConstraintDescriptor("adsk::fusion::CollinearConstraint", "CollinearConstraint", "collinear.svg", _b_collinear), + ConstraintDescriptor("adsk::fusion::CoincidentConstraint", "CoincidentConstraint", "coincident.svg", _b_coincident), + ConstraintDescriptor("adsk::fusion::CoincidentToSurfaceConstraint", "CoincidentToSurfaceConstraint", "surface.svg", _b_coincident_to_surface), + ConstraintDescriptor("adsk::fusion::TangentConstraint", "TangentConstraint", "tangent.svg", _b_tangent), + ConstraintDescriptor("adsk::fusion::EqualConstraint", "EqualConstraint", "equal.svg", _b_equal), + ConstraintDescriptor("adsk::fusion::ConcentricConstraint", "ConcentricConstraint", "concentric.svg", _b_concentric), + ConstraintDescriptor("adsk::fusion::MidPointConstraint", "MidPointConstraint", "midpoint.svg", _b_midpoint), + ConstraintDescriptor("adsk::fusion::SymmetryConstraint", "SymmetryConstraint", "symmetric.svg", _b_symmetry), + ConstraintDescriptor("adsk::fusion::OffsetConstraint", "OffsetConstraint", "offset.svg", _b_offset), + ConstraintDescriptor("adsk::fusion::PolygonConstraint", "PolygonConstraint", "polygon.svg", _b_polygon), + ConstraintDescriptor("adsk::fusion::CircularPatternConstraint", "CircularPatternConstraint", "pattern.svg", _b_circular_pattern), + ConstraintDescriptor("adsk::fusion::RectangularPatternConstraint", "RectangularPatternConstraint", "pattern.svg", _b_rectangular_pattern), + ConstraintDescriptor("adsk::fusion::LineOnPlanarSurfaceConstraint", "LineOnPlanarSurfaceConstraint", "surface.svg", _b_line_on_surface), + ConstraintDescriptor("adsk::fusion::LineParallelToPlanarSurfaceConstraint", "LineParallelToPlanarSurfaceConstraint", "surface.svg", _b_line_parallel_surface), + ConstraintDescriptor("adsk::fusion::PerpendicularToSurfaceConstraint", "PerpendicularToSurfaceConstraint", "surface.svg", _b_perpendicular_surface), +] + + +DISPATCH: dict[str, ConstraintDescriptor] = {d.object_type: d for d in _ENTRIES} + + +def describe(constraint) -> tuple[ConstraintDescriptor, ScanResult]: + """Return (descriptor, scan_result) for a constraint; falls back to a generic row.""" + try: + obj_type = constraint.objectType + except Exception: + obj_type = "" + desc = DISPATCH.get(obj_type) + if desc is None: + kind = obj_type.split("::")[-1] if obj_type else "UnknownConstraint" + desc = ConstraintDescriptor(obj_type, kind, "coincident.svg", _b_unknown) + try: + return desc, desc.build(constraint, _EMPTY_LABELER) + except Exception as exc: + return desc, ScanResult(f"{desc.kind} (unreadable)", [], [f"builder raised: {exc}"]) + + +def _b_unknown(c, lab): + obj_type = getattr(c, "objectType", "?") + return ScanResult(f"Unknown constraint kind: {obj_type}", [], []) + + +# Sentinel labeler used only by describe()'s fallback path; real scans use +# a per-sketch EntityLabeler instance. +class _NullLabeler: + def label_for(self, _): + return "" + + def kind_for(self, _): + return "SketchEntity" + + def chip_for(self, _): + return {"token": "", "kind": "SketchEntity", "label": ""} + + +_EMPTY_LABELER = _NullLabeler() + + +# --- Dimension dispatch (smaller, uniform shape) ------------------------ + + +# Per-type entity accessor names for sketch dimensions. +# Types absent from this map fall back to ("entityOne", "entityTwo"). +_DIM_ACCESSORS: dict[str, tuple[str, ...]] = { + "adsk::fusion::SketchAngularDimension": ("lineOne", "lineTwo"), + "adsk::fusion::SketchConcentricCircleDimension": ("circleOne", "circleTwo"), + "adsk::fusion::SketchDiameterDimension": ("entity",), + "adsk::fusion::SketchDistanceBetweenLineAndPlanarSurfaceDimension": ("line",), + "adsk::fusion::SketchDistanceBetweenTwoLinesDimension": ("lineOne", "lineTwo"), + "adsk::fusion::SketchEllipseMajorRadiusDimension": ("ellipse",), + "adsk::fusion::SketchEllipseMinorRadiusDimension": ("ellipse",), + "adsk::fusion::SketchRadialDimension": ("entity",), +} +_DIM_ACCESSOR_DEFAULT = ("entityOne", "entityTwo") + + +def _entities_from_dim(dim, obj_type: str) -> list: + """Return sketch entities for a dimension using type-specific accessors, + falling back to entityOne/entityTwo if the primary accessors yield nothing.""" + accessor_names = _DIM_ACCESSORS.get(obj_type, _DIM_ACCESSOR_DEFAULT) + ents = [e for attr in accessor_names + for e in [_safe(lambda a=attr: getattr(dim, a, None))] + if e is not None] + if not ents and accessor_names is not _DIM_ACCESSOR_DEFAULT: + ents = [e for attr in _DIM_ACCESSOR_DEFAULT + for e in [_safe(lambda a=attr: getattr(dim, a, None))] + if e is not None] + return ents + + +_DIMENSION_KINDS: dict[str, str] = { + "adsk::fusion::SketchAngularDimension": "Angular", + "adsk::fusion::SketchConcentricCircleDimension": "Concentric circles", + "adsk::fusion::SketchDiameterDimension": "Diameter", + "adsk::fusion::SketchDistanceBetweenLineAndPlanarSurfaceDimension": "Line ↔ surface", + "adsk::fusion::SketchDistanceBetweenTwoLinesDimension": "Distance (lines)", + "adsk::fusion::SketchEllipseMajorRadiusDimension": "Ellipse major radius", + "adsk::fusion::SketchEllipseMinorRadiusDimension": "Ellipse minor radius", + "adsk::fusion::SketchLinearDimension": "Linear", + "adsk::fusion::SketchOffsetCurvesDimension": "Offset curves", + "adsk::fusion::SketchOffsetDimension": "Offset", + "adsk::fusion::SketchRadialDimension": "Radial", + "adsk::fusion::SketchTangentDistanceDimension": "Tangent distance", +} + + +def _find_offset_constraint_for_dim(dim, sketch): + """Match a SketchOffsetCurvesDimension to its source OffsetConstraint. + OffsetConstraint.distance is unreliable in the January 2026 build (often + returns None), so this stacks fallbacks: parameter entityToken, parameter + name, positional pairing of offset-dims vs offset-constraints, and finally + "just use the only one if there is only one".""" + if sketch is None: + return None + + # Collect all OffsetConstraints up front — every fallback needs them. + offset_constraints: list = [] + try: + gc = sketch.geometricConstraints + for i in range(gc.count): + c = gc.item(i) + if getattr(c, "objectType", "") == "adsk::fusion::OffsetConstraint": + offset_constraints.append(c) + except Exception: + return None + if not offset_constraints: + return None + + dim_param = _safe(lambda: dim.parameter) + dim_tok = _safe(lambda: dim_param.entityToken) if dim_param else None + dim_name = _safe(lambda: dim_param.name) if dim_param else None + + # 1. Match by parameter entityToken (clean path when .distance works). + if dim_tok: + for c in offset_constraints: + d = _safe(lambda c=c: c.distance) + if d is None: + continue + if _safe(lambda d=d: d.entityToken) == dim_tok: + return c + + # 2. Match by parameter name. + if dim_name: + for c in offset_constraints: + d = _safe(lambda c=c: c.distance) + if d is None: + continue + if _safe(lambda d=d: d.name) == dim_name: + return c + + # 3. Positional pairing: assume nth offset-dim corresponds to nth offset-constraint. + own_tok = _safe(lambda: dim.entityToken) + if own_tok: + offset_dims: list = [] + try: + dims = sketch.sketchDimensions + for i in range(dims.count): + d = dims.item(i) + if getattr(d, "objectType", "") == "adsk::fusion::SketchOffsetCurvesDimension": + offset_dims.append(d) + except Exception: + offset_dims = [] + for idx, od in enumerate(offset_dims): + if _safe(lambda od=od: od.entityToken) == own_tok and idx < len(offset_constraints): + return offset_constraints[idx] + + # 4. Last resort — if exactly one OffsetConstraint, it must be the one. + if len(offset_constraints) == 1: + return offset_constraints[0] + + return None + + +def _find_offset_dim_for_constraint(c, sketch): + """Find the SketchOffsetCurvesDimension governing OffsetConstraint c. + Mirrors _find_offset_constraint_for_dim in the reverse direction.""" + if sketch is None: + return None + offset_dims: list = [] + try: + dims = sketch.sketchDimensions + for i in range(dims.count): + d = dims.item(i) + if getattr(d, "objectType", "") == "adsk::fusion::SketchOffsetCurvesDimension": + offset_dims.append(d) + except Exception: + return None + if not offset_dims: + return None + + c_dist = _safe(lambda: c.distance) + c_tok = _safe(lambda: c_dist.entityToken) if c_dist is not None else None + c_name = _safe(lambda: c_dist.name) if c_dist is not None else None + + if c_tok: + for d in offset_dims: + if _safe(lambda d=d: d.parameter.entityToken) == c_tok: + return d + if c_name: + for d in offset_dims: + if _safe(lambda d=d: d.parameter.name) == c_name: + return d + if len(offset_dims) == 1: + return offset_dims[0] + return None + + +def patch_offset_label(result: ScanResult, c, sketch) -> ScanResult: + """Replace ', ?)' distance placeholder with the real expression from + c.distance or the matching SketchOffsetCurvesDimension.""" + if not result.label.endswith(", ?)"): + return result + d = _safe(lambda: c.distance) + expr = _safe(lambda: d.expression) if d is not None else None + if expr is None: + dim = _find_offset_dim_for_constraint(c, sketch) + if dim is not None: + expr = _safe(lambda: dim.parameter.expression) + if expr is None: + return result + new_label = result.label[:-len(", ?)")] + f", {expr})" + return ScanResult(new_label, result.entities, result.errors) + + +def describe_dimension(dim, lab: EntityLabeler, sketch=None) -> ScanResult: + obj_type = getattr(dim, "objectType", "") + kind_label = _DIMENSION_KINDS.get(obj_type, obj_type.split("::")[-1] or "Dimension") + + # SketchOffsetCurvesDimension does not expose its source curves directly + # in any reliable accessor on the dimension itself — try common attribute + # names first, then fall back to finding the matching OffsetConstraint + # by parameter and reading its parentCurves / childCurves. + if obj_type == "adsk::fusion::SketchOffsetCurvesDimension": + chips: list[dict] = [] + n = 0 + for attr in ("curves", "parentCurves", "childCurves"): + coll = _safe(lambda a=attr: getattr(dim, a, None)) + n += _iter_curves_into_chips(coll, lab, chips) + if not chips: + ofc = _find_offset_constraint_for_dim(dim, sketch) + if ofc is not None: + n += _iter_curves_into_chips(_safe(lambda: ofc.parentCurves), lab, chips) + n += _iter_curves_into_chips(_safe(lambda: ofc.childCurves), lab, chips) + expr = "?" + try: + expr = dim.parameter.expression + except Exception: + pass + return ScanResult(f"{kind_label} ({n} curves) = {expr}", chips, []) + + ents = _entities_from_dim(dim, obj_type) + e1 = ents[0] if ents else None + e2 = ents[1] if len(ents) > 1 else None + expr = "?" + try: + expr = dim.parameter.expression + except Exception: + expr = "?" + if e1 and e2: + label = f"{kind_label}: {lab.label_for(e1)} → {lab.label_for(e2)} = {expr}" + elif e1: + label = f"{kind_label}: {lab.label_for(e1)} = {expr}" + else: + label = f"{kind_label} = {expr}" + return ScanResult(label, _chips(lab, e1, e2), []) + + +def dimension_kind(obj_type: str) -> str: + return _DIMENSION_KINDS.get(obj_type, obj_type.split("::")[-1] or "Dimension") diff --git a/ConstraintLens/lib/events.py b/ConstraintLens/lib/events.py new file mode 100644 index 0000000..20ed2e0 --- /dev/null +++ b/ConstraintLens/lib/events.py @@ -0,0 +1,121 @@ +# lib/events.py — GC-safe event handler registry (SPEC.md sections 4, 6, M-7). + +import traceback + +import adsk.core + + +# Module-level pinning lists — every handler instance and every event<->handler +# pair lives here for the lifetime of the add-in. Dropping these references +# crashes Fusion silently (landmine M-7). +_handlers: list[adsk.core.EventHandler] = [] +_subscriptions: list[tuple[object, adsk.core.EventHandler]] = [] + + +class _DocumentActivatedHandler(adsk.core.DocumentEventHandler): + def __init__(self, on_change): + super().__init__() + self._on_change = on_change + + def notify(self, args): + try: + self._on_change() + except Exception: + _report(traceback.format_exc(), "documentActivated") + + +class _CommandTerminatedHandler(adsk.core.ApplicationCommandEventHandler): + def __init__(self, on_change): + super().__init__() + self._on_change = on_change + + def notify(self, args): + try: + self._on_change() + except Exception: + # Never let a handler exception escape into Fusion. + pass + + +class _PaletteIncomingHandler(adsk.core.HTMLEventHandler): + def __init__(self, on_message): + super().__init__() + self._on_message = on_message + + def notify(self, args): + try: + self._on_message(args.action, args.data) + except Exception: + _report(traceback.format_exc(), "incomingFromHTML") + + +class _PaletteClosedHandler(adsk.core.UserInterfaceGeneralEventHandler): + def __init__(self, on_closed): + super().__init__() + self._on_closed = on_closed + + def notify(self, args): + try: + self._on_closed() + except Exception: + pass + + +def pin(event, handler: adsk.core.EventHandler) -> None: + """Pin a handler to a Fusion event and keep its Python ref alive. + + Use this from any module that creates its own event handlers (e.g. + CommandCreated). Without pinning, Python GC can drop the handler ref + while the C++ side still holds a pointer — Fusion crashes silently + on the next callback (landmine M-7). + """ + event.add(handler) + _handlers.append(handler) + _subscriptions.append((event, handler)) + + +def register_app(app: adsk.core.Application, ui: adsk.core.UserInterface, on_change) -> None: + h1 = _DocumentActivatedHandler(on_change) + app.documentActivated.add(h1) + _handlers.append(h1) + _subscriptions.append((app.documentActivated, h1)) + + h2 = _CommandTerminatedHandler(on_change) + ui.commandTerminated.add(h2) + _handlers.append(h2) + _subscriptions.append((ui.commandTerminated, h2)) + + +def register_palette( + palette: adsk.core.Palette, + on_message, + on_closed, +) -> None: + h_in = _PaletteIncomingHandler(on_message) + palette.incomingFromHTML.add(h_in) + _handlers.append(h_in) + _subscriptions.append((palette.incomingFromHTML, h_in)) + + h_closed = _PaletteClosedHandler(on_closed) + palette.closed.add(h_closed) + _handlers.append(h_closed) + _subscriptions.append((palette.closed, h_closed)) + + +def unregister_all() -> None: + for event, handler in _subscriptions: + try: + event.remove(handler) + except Exception: + pass + _subscriptions.clear() + _handlers.clear() + + +def _report(message: str, context: str) -> None: + try: + ui = adsk.core.Application.get().userInterface + if ui: + ui.messageBox(f"ConstraintLens handler error ({context}):\n{message}") + except Exception: + pass diff --git a/ConstraintLens/lib/labels.py b/ConstraintLens/lib/labels.py new file mode 100644 index 0000000..baa7afd --- /dev/null +++ b/ConstraintLens/lib/labels.py @@ -0,0 +1,80 @@ +# lib/labels.py — entity-display naming for the active sketch (SPEC.md sections 4, 5). + +import adsk.fusion + + +def _safe_token(entity) -> str | None: + try: + return entity.entityToken + except Exception: + return None + + +class EntityLabeler: + """Build (token -> 'Line 3') maps once per scan of a sketch. + + Indexes lines, points, circles, arcs, ellipses, and both spline kinds. + Any entity not in those collections falls back to its bare class name. + """ + + def __init__(self, sketch: adsk.fusion.Sketch): + self._tokens: dict[str, str] = {} + self._kinds: dict[str, str] = {} + curves = sketch.sketchCurves + self._index(curves.sketchLines, "Line", "SketchLine") + self._index(sketch.sketchPoints, "Point", "SketchPoint") + self._index(curves.sketchCircles, "Circle", "SketchCircle") + self._index(curves.sketchArcs, "Arc", "SketchArc") + self._index(curves.sketchEllipses, "Ellipse", "SketchEllipse") + self._index(curves.sketchFittedSplines, "Spline", "SketchFittedSpline") + # Some Fusion builds don't expose control-point splines on the curves + # bag; guard the lookup so labeler construction never fails. + cps = getattr(curves, "sketchControlPointSplines", None) + if cps is not None: + self._index(cps, "Spline", "SketchControlPointSpline") + + def _index(self, coll, name: str, kind: str) -> None: + try: + count = coll.count + except Exception: + return + for i in range(count): + try: + ent = coll.item(i) + except Exception: + continue + tok = _safe_token(ent) + if tok: + self._tokens[tok] = f"{name} {i + 1}" + self._kinds[tok] = kind + + def label_for(self, entity) -> str: + tok = _safe_token(entity) + if tok and tok in self._tokens: + return self._tokens[tok] + try: + return entity.objectType.split("::")[-1] + except Exception: + return "" + + def kind_for(self, entity) -> str: + tok = _safe_token(entity) + if tok and tok in self._kinds: + return self._kinds[tok] + try: + return entity.objectType.split("::")[-1] + except Exception: + return "SketchEntity" + + def chip_for(self, entity) -> dict: + invisible = False + try: + invisible = not bool(entity.isVisible) + except Exception: + pass + return { + "token": _safe_token(entity) or "", + "kind": self.kind_for(entity), + "label": self.label_for(entity), + "invisible": invisible, + } diff --git a/ConstraintLens/lib/lifecycle.py b/ConstraintLens/lib/lifecycle.py new file mode 100644 index 0000000..b478363 --- /dev/null +++ b/ConstraintLens/lib/lifecycle.py @@ -0,0 +1,398 @@ +# lib/lifecycle.py — command + palette registration (SPEC.md sections 4, 6, 7). + +import os +import traceback + +import adsk.core +import adsk.fusion + +from . import actions, events, messaging, scanner, selection, tokens + + +# Command and palette ids — must be unique across all add-ins. +_CMD_ID = "ConstraintLensShow" +_PALETTE_ID = "ConstraintLensPalette" + +# SketchConstraintsPanel lives in the Sketch workspace (backlog #1 relocation). +# Falls back to a messageBox if the panel id is wrong for this Fusion build. +_PANEL_ID = "SketchConstraintsPanel" + + +# Module state — kept here, not duplicated across modules. +_addin_dir: str = "" +_palette: adsk.core.Palette | None = None +_command_definition: adsk.core.CommandDefinition | None = None +_button_control: adsk.core.ToolbarControl | None = None + + +# --- Lifecycle entry points --------------------------------------------- + + +def start(addin_dir: str) -> None: + global _addin_dir + _addin_dir = addin_dir + + app = adsk.core.Application.get() + ui = app.userInterface + + _ensure_command(app, ui) + _ensure_button(ui) + + # App-level events that signal "active sketch may have changed". + events.register_app(app, ui, _on_change) + + +def stop() -> None: + events.unregister_all() + + global _palette, _button_control, _command_definition + if _palette is not None: + try: + _palette.deleteMe() + except Exception: + pass + _palette = None + + if _button_control is not None: + try: + _button_control.deleteMe() + except Exception: + pass + _button_control = None + + if _command_definition is not None: + try: + _command_definition.deleteMe() + except Exception: + pass + _command_definition = None + + +# --- Command + button --------------------------------------------------- + + +def _ensure_command(app: adsk.core.Application, ui: adsk.core.UserInterface) -> None: + global _command_definition + existing = ui.commandDefinitions.itemById(_CMD_ID) + if existing is not None: + existing.deleteMe() + cmd_def = ui.commandDefinitions.addButtonDefinition( + _CMD_ID, + "Constraint Lens", + "Show the docked panel listing all constraints in the active sketch.", + "", # icon dir — empty for MVP; Fusion will use a default glyph. + ) + _command_definition = cmd_def + + events.pin(cmd_def.commandCreated, _CommandCreatedHandler()) + + +def _ensure_button(ui: adsk.core.UserInterface) -> None: + global _button_control + panel = ui.allToolbarPanels.itemById(_PANEL_ID) + if panel is None: + # Surface the missing panel as a visible warning, not a crash. + try: + ui.messageBox( + f"ConstraintLens: panel '{_PANEL_ID}' not found. " + "Run tests/spike_probe.py to find the correct panel id." + ) + except Exception: + pass + return + + existing = panel.controls.itemById(_CMD_ID) + if existing is not None: + existing.deleteMe() + _button_control = panel.controls.addCommand(_command_definition) + try: + _button_control.isPromotedByDefault = True + _button_control.isPromoted = True + except Exception: + # Some panels disallow promotion; the button still appears in the overflow menu. + pass + + +class _CommandCreatedHandler(adsk.core.CommandCreatedEventHandler): + def notify(self, args): + try: + _show_palette() + except Exception: + ui = adsk.core.Application.get().userInterface + if ui: + ui.messageBox("ConstraintLens command failed:\n" + traceback.format_exc()) + + +# --- Palette ------------------------------------------------------------ + + +def _show_palette() -> None: + global _palette + app = adsk.core.Application.get() + ui = app.userInterface + + if _palette is not None and _is_palette_alive(_palette): + _palette.isVisible = True + _publish_active(app) + return + + palette_html = os.path.join(_addin_dir, "palette", "index.html") + # Fusion accepts both relative paths (resolved from the add-in dir) and + # file:/// URLs. Use the relative form — simpler, works on Win and macOS. + html_url = "palette/index.html" + if not os.path.exists(palette_html): + ui.messageBox(f"ConstraintLens: palette/index.html missing at\n{palette_html}") + return + + _palette = ui.palettes.itemById(_PALETTE_ID) + if _palette is not None: + try: + _palette.deleteMe() + except Exception: + pass + _palette = None + + _palette = ui.palettes.add( + _PALETTE_ID, + "Constraint Lens", + html_url, + True, # isVisible + True, # showCloseButton + True, # isResizable + 420, # width + 600, # height + True, # useNewWebBrowser (Qt — required per locked decision) + ) + try: + _palette.dockingState = adsk.core.PaletteDockingStates.PaletteDockStateRight + except Exception: + # Some Fusion builds make this read-only on initial creation; ignore. + pass + + events.register_palette(_palette, _on_palette_message, _on_palette_closed) + _publish_active(app) + + +def _is_palette_alive(palette: adsk.core.Palette) -> bool: + try: + _ = palette.isVisible + return True + except Exception: + return False + + +# --- Message handling --------------------------------------------------- + + +def _on_palette_message(action: str, raw: str) -> None: + app = adsk.core.Application.get() + payload = messaging.parse_incoming(raw) + + if action == messaging.ACTION_PALETTE_READY or action == messaging.ACTION_REQUEST_REFRESH: + _publish_active(app) + return + + if action == messaging.ACTION_SELECT_ENTITIES: + _handle_select_entities(app, payload) + return + + if action == messaging.ACTION_SELECT_CONSTRAINT: + _handle_select_constraint(app, payload) + return + + if action == messaging.ACTION_DELETE_CONSTRAINT: + _handle_delete(app, payload) + return + + if action == messaging.ACTION_SHOW_UNDERCONSTRAINED: + _handle_show_underconstrained(app) + return + + if action == messaging.ACTION_BULK_DELETE: + _handle_bulk_delete(app, payload) + return + + # Unknown action — log and ignore (forward-compat per SPEC.md section 7). + + +def _on_palette_closed() -> None: + # No-op for MVP: subscribed events keep firing harmlessly because + # send() gates on isVisible. The palette is reopenable via the button. + pass + + +def _on_change() -> None: + app = adsk.core.Application.get() + _publish_active(app) + + +def _publish_active(app: adsk.core.Application) -> None: + if _palette is None: + return + sketch = scanner.active_sketch(app) + if sketch is None: + messaging.send(_palette, messaging.PY_ACTION_NO_ACTIVE_SKETCH, { + "reason": "Open a sketch for edit to see its constraints.", + }) + return + try: + payload = scanner.build_payload(sketch) + except Exception as exc: + messaging.send(_palette, messaging.PY_ACTION_ERROR, { + "message": f"Scan failed: {exc}", + "context": "build_payload", + }) + return + messaging.send(_palette, messaging.PY_ACTION_DATA, payload) + + +# --- Action handlers ---------------------------------------------------- + + +def _handle_select_entities(app: adsk.core.Application, payload: dict) -> None: + design = adsk.fusion.Design.cast(app.activeProduct) + entity_tokens: list[str] = payload.get("entityTokens") or [] + + # Primary path: JS sends the entity tokens it already has from the scan. + # Resolving by token returns the concrete typed object, which is more + # reliable than re-scanning accessor names (avoids the spline proxy issue). + if entity_tokens: + ents = [e for tok in entity_tokens if (e := tokens.resolve(design, tok)) is not None] + if ents: + selection.select_entities(app.userInterface, ents) + return + + # Fallback: re-derive entities from the constraint object directly. + # Covers rows whose entity chips had empty tokens (surface refs, unknowns). + row_key = payload.get("rowKey") or "" + if not row_key: + return + + if row_key.startswith("join:"): + point_token = row_key[len("join:"):] + point = tokens.resolve(design, point_token) + if point is None: + return + ents = [point] + try: + for j in range(point.connectedEntities.count): + ents.append(point.connectedEntities.item(j)) + except Exception: + pass + selection.select_entities(app.userInterface, ents) + return + + constraint = tokens.resolve(design, row_key) + if constraint is None: + return + selection.select_entities(app.userInterface, _entities_for_row(constraint)) + + +def _handle_select_constraint(app: adsk.core.Application, payload: dict) -> None: + token = payload.get("token") or "" + design = adsk.fusion.Design.cast(app.activeProduct) + entity = tokens.resolve(design, token) + if entity is None: + return + selection.select_constraint(app.userInterface, entity) + + +def _handle_delete(app: adsk.core.Application, payload: dict) -> None: + token = payload.get("token") or "" + result = actions.delete_constraint(app, token) + messaging.send(_palette, messaging.PY_ACTION_RESULT, { + "action": messaging.ACTION_DELETE_CONSTRAINT, + "ok": result.ok, + "message": result.message, + }) + _publish_active(app) + + +def _handle_bulk_delete(app: adsk.core.Application, payload: dict) -> None: + tokens: list[str] = payload.get("tokens") or [] + if not tokens: + return + ok_count = 0 + fail_count = 0 + for tok in tokens: + result = actions.delete_constraint(app, tok) + if result.ok: + ok_count += 1 + else: + fail_count += 1 + n = ok_count + fail_count + msg = (f"Deleted {ok_count} of {n} constraints." if fail_count + else f"Deleted {ok_count} constraint{'s' if ok_count != 1 else ''}.") + messaging.send(_palette, messaging.PY_ACTION_RESULT, { + "action": messaging.ACTION_BULK_DELETE, + "ok": fail_count == 0, + "message": msg, + }) + _publish_active(app) + + +def _handle_show_underconstrained(app: adsk.core.Application) -> None: + # Guard: executeTextCommand only works inside sketch edit context (M-11). + if scanner.active_sketch(app) is None: + messaging.send(_palette, messaging.PY_ACTION_RESULT, { + "action": messaging.ACTION_SHOW_UNDERCONSTRAINED, + "ok": False, + "message": "No active sketch.", + }) + return + try: + result = app.executeTextCommand("Sketch.ShowUnderconstrained") + msg = str(result).strip() if result else "No underconstrained entities." + messaging.send(_palette, messaging.PY_ACTION_RESULT, { + "action": messaging.ACTION_SHOW_UNDERCONSTRAINED, + "ok": True, + "message": msg, + }) + except Exception as exc: + messaging.send(_palette, messaging.PY_ACTION_RESULT, { + "action": messaging.ACTION_SHOW_UNDERCONSTRAINED, + "ok": False, + "message": f"Show underconstrained failed: {exc}", + }) + + +def _entities_for_row(constraint) -> list: + """Re-resolve the entities for a constraint (rather than caching tokens).""" + candidates: list = [] + names = ( + "line", "lineOne", "lineTwo", + "point", "pointOne", "pointTwo", + "entity", "entityOne", "entityTwo", + "curveOne", "curveTwo", + "midPointCurve", "symmetryLine", + "centerSketchPoint", + ) + for name in names: + if not hasattr(constraint, name): + continue + try: + v = getattr(constraint, name) + except Exception: + continue + if v is not None: + candidates.append(v) + # Collections: parentCurves / childCurves / lines (Offset/Polygon) and + # curves (SketchOffsetCurvesDimension). parentCurves / childCurves / curves + # are SketchCurveVector (iteration + len, no .count); lines is ObjectCollection. + for coll_name in ("parentCurves", "childCurves", "lines", "curves"): + if not hasattr(constraint, coll_name): + continue + try: + coll = getattr(constraint, coll_name) + if coll is None: + continue + # Direct iteration works for both SketchCurveVector and ObjectCollection. + try: + for item in coll: + candidates.append(item) + except TypeError: + for i in range(coll.count): + candidates.append(coll.item(i)) + except Exception: + continue + return candidates diff --git a/ConstraintLens/lib/messaging.py b/ConstraintLens/lib/messaging.py new file mode 100644 index 0000000..7de5965 --- /dev/null +++ b/ConstraintLens/lib/messaging.py @@ -0,0 +1,48 @@ +# lib/messaging.py — palette <-> Python wire format (SPEC.md section 7). + +import json + +import adsk.core + + +# Action names — single source of truth, mirrored in palette/app.js. +ACTION_PALETTE_READY = "paletteReady" +ACTION_REQUEST_REFRESH = "requestRefresh" +ACTION_SELECT_ENTITIES = "selectEntities" +ACTION_SELECT_CONSTRAINT = "selectConstraint" +ACTION_DELETE_CONSTRAINT = "deleteConstraint" +ACTION_SHOW_UNDERCONSTRAINED = "showUnderconstrained" +ACTION_BULK_DELETE = "bulkDelete" + +PY_ACTION_DATA = "data" +PY_ACTION_NO_ACTIVE_SKETCH = "noActiveSketch" +PY_ACTION_ERROR = "error" +PY_ACTION_RESULT = "actionResult" + + +def send(palette: adsk.core.Palette, action: str, payload: dict | None = None) -> bool: + """Send a Python->JS message; respects landmine M-8 (skip when invisible).""" + if palette is None: + return False + try: + if not palette.isVisible: + return False + except Exception: + return False + body = json.dumps(payload or {}, default=str) + try: + palette.sendInfoToHTML(action, body) + return True + except Exception: + return False + + +def parse_incoming(raw: str) -> dict: + """Parse a JSON message coming in from the palette; return {} on error.""" + if not raw: + return {} + try: + result = json.loads(raw) + return result if isinstance(result, dict) else {} + except Exception: + return {} diff --git a/ConstraintLens/lib/scanner.py b/ConstraintLens/lib/scanner.py new file mode 100644 index 0000000..01a7d3d --- /dev/null +++ b/ConstraintLens/lib/scanner.py @@ -0,0 +1,167 @@ +# lib/scanner.py — sketch enumeration (SPEC.md section 4). +# +# Walks the active sketch and produces the JSON payload defined in +# SPEC.md section 7. Never touches the palette directly. + +import adsk.core +import adsk.fusion + +from . import dispatch +from .labels import EntityLabeler +from .tokens import token_of + + +def active_sketch(app: adsk.core.Application) -> adsk.fusion.Sketch | None: + design = adsk.fusion.Design.cast(app.activeProduct) + if not design: + return None + return adsk.fusion.Sketch.cast(design.activeEditObject) + + +def build_payload(sketch: adsk.fusion.Sketch) -> dict: + """Build the full data payload for the palette.""" + lab = EntityLabeler(sketch) + + component_name = "" + try: + component_name = sketch.parentComponent.name + except Exception: + pass + + health_state = "" + try: + health_state = str(sketch.healthState) + except Exception: + pass + + error_msg = "" + try: + error_msg = sketch.errorOrWarningMessage or "" + except Exception: + pass + + return { + "sketch": { + "name": sketch.name, + "componentName": component_name, + "isFullyConstrained": bool(sketch.isFullyConstrained), + "healthState": health_state, + "errorOrWarningMessage": error_msg, + }, + "constraints": _scan_constraints(sketch, lab), + "dimensions": _scan_dimensions(sketch, lab), + "implicitJoins": _scan_implicit_joins(sketch, lab), + } + + +def _scan_constraints(sketch: adsk.fusion.Sketch, lab: EntityLabeler) -> list[dict]: + rows: list[dict] = [] + gc = sketch.geometricConstraints + for i in range(gc.count): + c = gc.item(i) + desc = dispatch.DISPATCH.get(c.objectType) + if desc is None: + kind = c.objectType.split("::")[-1] if c.objectType else "UnknownConstraint" + result = dispatch.ScanResult(f"Unknown: {c.objectType}", [], []) + glyph = "coincident.svg" + else: + kind = desc.kind + glyph = desc.glyph + try: + result = desc.build(c, lab) + if desc.kind == "OffsetConstraint": + result = dispatch.patch_offset_label(result, c, sketch) + except Exception as exc: + result = dispatch.ScanResult( + f"{desc.kind} (builder raised)", [], [f"builder raised: {exc}"] + ) + tok = token_of(c) or "" + try: + is_deletable = bool(c.isDeletable) + except Exception: + is_deletable = True + rows.append({ + "rowKey": tok, + "token": tok, + "kind": kind, + "objectType": c.objectType, + "label": result.label, + "glyph": glyph, + "entities": result.entities, + "isDeletable": is_deletable, + "isPseudo": False, + "errors": result.errors, + }) + return rows + + +def _scan_dimensions(sketch: adsk.fusion.Sketch, lab: EntityLabeler) -> list[dict]: + rows: list[dict] = [] + dims = sketch.sketchDimensions + for i in range(dims.count): + d = dims.item(i) + try: + result = dispatch.describe_dimension(d, lab, sketch) + except Exception as exc: + result = dispatch.ScanResult("Dimension (builder raised)", [], [f"builder raised: {exc}"]) + tok = token_of(d) or "" + try: + expr = d.parameter.expression + except Exception: + expr = "" + try: + is_deletable = bool(d.isDeletable) + except Exception: + is_deletable = True + rows.append({ + "rowKey": tok, + "token": tok, + "kind": dispatch.dimension_kind(d.objectType), + "objectType": d.objectType, + "label": result.label, + "glyph": "dimension.svg", + "entities": result.entities, + "parameterExpression": expr, + "isDeletable": is_deletable, + "isPseudo": False, + "errors": result.errors, + }) + return rows + + +def _scan_implicit_joins(sketch: adsk.fusion.Sketch, lab: EntityLabeler) -> list[dict]: + """Reconstruct coincident endpoint joins (landmine M-3).""" + rows: list[dict] = [] + points = sketch.sketchPoints + for i in range(points.count): + p = points.item(i) + try: + connected = p.connectedEntities + n = connected.count + except Exception: + continue + if n <= 1: + continue + entity_chips: list[dict] = [lab.chip_for(p)] + labels: list[str] = [] + for j in range(n): + try: + e = connected.item(j) + except Exception: + continue + entity_chips.append(lab.chip_for(e)) + labels.append(lab.label_for(e)) + ptok = token_of(p) or f"pt{i}" + rows.append({ + "rowKey": f"join:{ptok}", + "token": None, + "kind": "ImplicitCoincidentJoin", + "objectType": "", + "label": f"Endpoint join — {lab.label_for(p)} connects {', '.join(labels)}", + "glyph": "coincident.svg", + "entities": entity_chips, + "isDeletable": False, + "isPseudo": True, + "errors": [], + }) + return rows diff --git a/ConstraintLens/lib/selection.py b/ConstraintLens/lib/selection.py new file mode 100644 index 0000000..d36005b --- /dev/null +++ b/ConstraintLens/lib/selection.py @@ -0,0 +1,29 @@ +# lib/selection.py — viewport selection helpers (SPEC.md section 4). + +import adsk.core + + +def select_entities(ui: adsk.core.UserInterface, entities: list) -> None: + """Replace the viewport selection with the given entities.""" + sel = ui.activeSelections + sel.clear() + for ent in entities: + if ent is None: + continue + try: + sel.add(ent) + except Exception: + # Individual add failures shouldn't abort the whole selection. + pass + + +def select_constraint(ui: adsk.core.UserInterface, constraint) -> None: + """Select the constraint object itself (Delete-key affordance).""" + sel = ui.activeSelections + sel.clear() + if constraint is None: + return + try: + sel.add(constraint) + except Exception: + pass diff --git a/ConstraintLens/lib/tokens.py b/ConstraintLens/lib/tokens.py new file mode 100644 index 0000000..d78658a --- /dev/null +++ b/ConstraintLens/lib/tokens.py @@ -0,0 +1,23 @@ +# lib/tokens.py — entityToken <-> object resolution (SPEC.md section 4). + +import adsk.fusion + + +def token_of(entity) -> str | None: + try: + return entity.entityToken + except Exception: + return None + + +def resolve(design: adsk.fusion.Design, token: str): + """Return the first entity matching the token, or None.""" + if not token: + return None + try: + results = design.findEntityByToken(token) + except Exception: + return None + if not results: + return None + return results[0] diff --git a/ConstraintLens/palette/app.js b/ConstraintLens/palette/app.js new file mode 100644 index 0000000..439f393 --- /dev/null +++ b/ConstraintLens/palette/app.js @@ -0,0 +1,393 @@ +// ConstraintLens palette UI — vanilla JS, no framework, no build step. +// Mirrors the message contract in SPEC.md section 7. + +(function () { + "use strict"; + + // --- Action names — must match lib/messaging.py exactly. ------------- + + const JS_TO_PY = { + paletteReady: "paletteReady", + requestRefresh: "requestRefresh", + selectEntities: "selectEntities", + selectConstraint: "selectConstraint", + deleteConstraint: "deleteConstraint", + showUnderconstrained: "showUnderconstrained", + bulkDelete: "bulkDelete", + }; + + const PY_TO_JS = { + data: "data", + noActiveSketch: "noActiveSketch", + error: "error", + actionResult: "actionResult", + }; + + // --- Glyphs (text fallback; SVG files can replace these later). ------ + + const TYPE_GLYPHS = { + HorizontalConstraint: "—", + VerticalConstraint: "|", + HorizontalPointsConstraint: "↔", + VerticalPointsConstraint: "↕", + ParallelConstraint: "∥", + PerpendicularConstraint: "⊥", + CollinearConstraint: "⋯", + CoincidentConstraint: "●", + CoincidentToSurfaceConstraint: "▣", + TangentConstraint: "⌒", + EqualConstraint: "=", + ConcentricConstraint: "⊙", + MidPointConstraint: "◐", + SymmetryConstraint: "↔", + OffsetConstraint: "⫽", + PolygonConstraint: "⬡", + CircularPatternConstraint: "○", + RectangularPatternConstraint: "▦", + LineOnPlanarSurfaceConstraint: "▤", + LineParallelToPlanarSurfaceConstraint: "▥", + PerpendicularToSurfaceConstraint: "▧", + ImplicitCoincidentJoin: "●", + }; + + // --- State ----------------------------------------------------------- + + const state = { + snapshot: null, + loaded: false, + filter: "", + selected: new Set(), + }; + + const els = { + root: document.getElementById("root"), + status: document.getElementById("status"), + refresh: document.getElementById("refresh"), + highlightUnder: document.getElementById("highlight-under"), + bulkDelete: document.getElementById("bulk-delete"), + clearSelection: document.getElementById("clear-selection"), + filter: document.getElementById("filter"), + }; + + // --- Outgoing messages ----------------------------------------------- + + function send(action, payload) { + if (typeof adsk === "undefined" || !adsk.fusionSendData) { + console.warn("adsk.fusionSendData not present yet:", action); + return; + } + adsk.fusionSendData(action, JSON.stringify(payload || {})); + } + + els.refresh.addEventListener("click", () => send(JS_TO_PY.requestRefresh, {})); + els.highlightUnder.addEventListener("click", () => send(JS_TO_PY.showUnderconstrained, {})); + els.filter.addEventListener("input", () => { + state.filter = els.filter.value.trim().toLowerCase(); + renderSnapshot(); + }); + els.bulkDelete.addEventListener("click", () => { + const tokens = [...state.selected]; + if (tokens.length === 0) return; + const n = tokens.length; + if (!confirm(`Delete ${n} constraint${n !== 1 ? "s" : ""}?\n(Ctrl+Z in Fusion can undo sketch operations.)`)) return; + send(JS_TO_PY.bulkDelete, { tokens }); + state.selected.clear(); + updateBulkDeleteButton(); + }); + els.clearSelection.addEventListener("click", () => { + state.selected.clear(); + updateBulkDeleteButton(); + renderSnapshot(); + }); + + // --- Incoming messages ---------------------------------------------- + + // Fusion calls window.fusionJavaScriptHandler.handle(action, data). + window.fusionJavaScriptHandler = { + handle(action, data) { + let payload = {}; + try { + payload = data ? JSON.parse(data) : {}; + } catch (e) { + console.warn("malformed payload for", action, e); + } + switch (action) { + case PY_TO_JS.data: onData(payload); break; + case PY_TO_JS.noActiveSketch: onNoActiveSketch(payload); break; + case PY_TO_JS.error: onError(payload); break; + case PY_TO_JS.actionResult: onActionResult(payload); break; + default: console.log("unknown action", action, payload); + } + return "OK"; + }, + }; + + function updateBulkDeleteButton() { + const n = state.selected.size; + const show = n > 0; + els.bulkDelete.style.display = show ? "" : "none"; + els.clearSelection.style.display = show ? "" : "none"; + if (show) els.bulkDelete.textContent = `Delete ${n}`; + } + + function onData(payload) { + state.snapshot = payload; + state.selected.clear(); + updateBulkDeleteButton(); + els.highlightUnder.disabled = false; + renderSnapshot(); + } + + function onNoActiveSketch(payload) { + state.snapshot = null; + state.selected.clear(); + updateBulkDeleteButton(); + els.highlightUnder.disabled = true; + setStatus(payload.reason || "No active sketch.", "warn"); + els.root.innerHTML = `
${escape(payload.reason || "Open a sketch for edit to see its constraints.")}
`; + } + + function onError(payload) { + setStatus(`Error: ${payload.message || "unknown"}`, "error"); + } + + function onActionResult(payload) { + const cls = payload.ok ? "ok" : "error"; + showToast(payload.message || (payload.ok ? "OK" : "Failed"), cls); + } + + // --- Filtering ------------------------------------------------------- + + function matchesFilter(row, q) { + if (!q) return true; + return (row.label || "").toLowerCase().includes(q) + || (row.kind || "").toLowerCase().includes(q); + } + + // --- Rendering ------------------------------------------------------- + + function renderSnapshot() { + const snap = state.snapshot; + if (!snap) { + els.root.innerHTML = `
No data.
`; + return; + } + + const sk = snap.sketch || {}; + const fully = sk.isFullyConstrained; + const statusText = sk.name + ? `${escape(sk.name)}${sk.componentName ? " · " + escape(sk.componentName) : ""}` + + ` — ${fully ? "fully constrained" : "under-constrained"}` + : "No active sketch."; + setStatus(statusText, fully ? "ok" : "warn"); + + const q = state.filter; + const allC = snap.constraints || []; + const allD = snap.dimensions || []; + const allJ = snap.implicitJoins || []; + const c = allC.filter(r => matchesFilter(r, q)); + const d = allD.filter(r => matchesFilter(r, q)); + const j = allJ.filter(r => matchesFilter(r, q)); + + const parts = []; + + if (c.length === 0 && d.length === 0 && j.length === 0) { + const totalAll = allC.length + allD.length + allJ.length; + if (q && totalAll > 0) { + parts.push(`
No matches for “${escape(q)}”.
`); + } else { + parts.push(`
This sketch has no constraints or dimensions yet.
`); + } + } + + if (c.length) { + const label = q + ? `Geometric constraints (${c.length} of ${allC.length})` + : `Geometric constraints (${allC.length})`; + parts.push(`
${label}
`); + for (const row of c) parts.push(rowHTML(row)); + } + if (d.length) { + const label = q + ? `Dimensions (${d.length} of ${allD.length})` + : `Dimensions (${allD.length})`; + parts.push(`
${label}
`); + for (const row of d) parts.push(rowHTML(row)); + } + if (j.length) { + const label = q + ? `Endpoint joins (${j.length} of ${allJ.length})` + : `Endpoint joins (${allJ.length})`; + parts.push(`
${label}
`); + for (const row of j) parts.push(rowHTML(row)); + } + + els.root.innerHTML = parts.join(""); + } + + function rowHTML(row) { + const glyph = TYPE_GLYPHS[row.kind] || "·"; + const hasErrors = row.errors && row.errors.length > 0; + const chips = (row.entities || []).map(chipHTML).join(""); + const pseudoClass = row.isPseudo ? " pseudo" : ""; + const errorClass = hasErrors ? " has-errors" : ""; + const badges = []; + if (row.isPseudo) badges.push(`implicit`); + if (hasErrors) badges.push(`accessor`); + const errorsHTML = hasErrors + ? `
${row.errors.map(escape).join("; ")}
` + : ""; + + // Entity tokens for token-based selection (bypasses accessor re-scan). + const entityTokensJson = JSON.stringify( + (row.entities || []).map(e => e.token || "").filter(t => t) + ); + + const canCheck = !row.isPseudo && row.isDeletable && row.token; + const checked = canCheck && state.selected.has(row.token) ? " checked" : ""; + const checkboxHTML = canCheck + ? `` + : ``; + + const selectConstraintBtn = row.isPseudo + ? "" + : ``; + + // Pseudo rows (implicit joins) can't be checked or deleted individually. + const lockBtn = row.isPseudo + ? `` + : ""; + + return ` +
+ ${checkboxHTML} +
${escape(glyph)}
+
+
${escape(row.label || "")}
+
+ ${escape(row.kind || "")} + ${badges.join("")} +
+ ${chips ? `
${chips}
` : ""} + ${errorsHTML} +
+
+ ${selectConstraintBtn} + ${lockBtn} +
+
+ `; + } + + function chipHTML(chip) { + if (chip.invisible) { + return ``; + } + return `${escape(chip.label || chip.kind || "?")}`; + } + + // --- Delegated event handling --------------------------------------- + + // Checkbox changes update selection state independently of row clicks. + els.root.addEventListener("change", (evt) => { + if (!evt.target.matches(".row-check")) return; + const token = evt.target.getAttribute("data-token") || ""; + if (!token) return; + if (evt.target.checked) { + state.selected.add(token); + } else { + state.selected.delete(token); + } + updateBulkDeleteButton(); + }); + + els.root.addEventListener("click", (evt) => { + // Checkbox clicks are handled by the change listener above. + if (evt.target.matches(".row-check")) return; + + const actionEl = evt.target.closest("[data-action]"); + if (!actionEl) return; + const action = actionEl.getAttribute("data-action"); + + if (action === "selectConstraint") { + evt.stopPropagation(); + const token = actionEl.getAttribute("data-token") || ""; + if (!token) return; + send(JS_TO_PY.selectConstraint, { token }); + return; + } + + if (action === "selectEntities") { + const row = actionEl.closest(".row"); + const rowKey = row ? row.getAttribute("data-row-key") || "" : ""; + if (!rowKey) return; + let entityTokens = []; + try { + const attr = row ? row.getAttribute("data-entity-tokens") : null; + entityTokens = attr ? JSON.parse(attr) : []; + } catch (e) { entityTokens = []; } + send(JS_TO_PY.selectEntities, { rowKey, entityTokens }); + return; + } + }); + + // --- Helpers --------------------------------------------------------- + + function setStatus(text, cls) { + els.status.textContent = text; + els.status.className = "status" + (cls ? " " + cls : ""); + } + + let toastTimer = null; + function showToast(text, cls) { + let toast = document.querySelector(".toast"); + if (!toast) { + toast = document.createElement("div"); + toast.className = "toast"; + document.body.appendChild(toast); + } + toast.textContent = text; + toast.className = "toast show " + (cls || ""); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toast.className = "toast " + (cls || ""); + }, 2400); + } + + function escape(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // --- Hello ---------------------------------------------------------- + // Fusion injects adsk.fusionSendData asynchronously after DOMContentLoaded. + // Poll until the bridge is ready before firing paletteReady, so the initial + // scan happens automatically without requiring a manual Refresh click. + + function _sendWhenReady(action, payload, maxMs) { + const deadline = Date.now() + (maxMs || 5000); + function attempt() { + if (typeof adsk !== "undefined" && adsk.fusionSendData) { + adsk.fusionSendData(action, JSON.stringify(payload || {})); + return; + } + if (Date.now() < deadline) { + setTimeout(attempt, 100); + } + } + attempt(); + } + + document.addEventListener("DOMContentLoaded", () => { + state.loaded = true; + _sendWhenReady(JS_TO_PY.paletteReady, {}); + }); +})(); diff --git a/ConstraintLens/palette/index.html b/ConstraintLens/palette/index.html new file mode 100644 index 0000000..9de053c --- /dev/null +++ b/ConstraintLens/palette/index.html @@ -0,0 +1,32 @@ + + + + + Constraint Lens + + + +
+
Loading…
+ + + + +
+ +
+ +
+ +
+
Loading…
+
+ + + + diff --git a/ConstraintLens/palette/styles.css b/ConstraintLens/palette/styles.css new file mode 100644 index 0000000..109cea5 --- /dev/null +++ b/ConstraintLens/palette/styles.css @@ -0,0 +1,289 @@ +/* ConstraintLens palette — matches Fusion's dark theme. */ + +:root { + --bg: #2a2a2a; + --bg-elev: #333333; + --bg-row: #2f2f2f; + --bg-row-hover: #3a3a3a; + --fg: #e8e8e8; + --fg-muted: #9c9c9c; + --accent: #4aa3df; + --warn: #d49b3b; + --error: #d96959; + --ok: #5fb96b; + --border: #404040; + --chip-bg: #3d3d3d; + --chip-border: #525252; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + font-size: 12px; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; +} + +.toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--bg-elev); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.status { + flex: 1; + font-size: 11px; + color: var(--fg-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status.warn { color: var(--warn); } +.status.error { color: var(--error); } +.status.ok { color: var(--ok); } + +.btn { + background: var(--bg-row); + color: var(--fg); + border: 1px solid var(--border); + padding: 3px 9px; + font-size: 11px; + cursor: pointer; + border-radius: 2px; +} +.btn:hover { background: var(--bg-row-hover); } +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.btn.danger { color: var(--error); } +.btn.danger:hover { background: #4a2c2c; } + +main { + flex: 1; + overflow-y: auto; +} + +.section-header { + padding: 6px 10px; + background: var(--bg-elev); + color: var(--fg-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--border); + border-top: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 1; +} + +.row { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + background: var(--bg-row); + border-bottom: 1px solid var(--border); + cursor: pointer; +} +.row:hover { background: var(--bg-row-hover); } +.row.pseudo { opacity: 0.85; } +.row.has-errors { border-left: 3px solid var(--warn); } + +.row-glyph { + width: 18px; + height: 18px; + flex-shrink: 0; + color: var(--fg-muted); + font-size: 14px; + text-align: center; + line-height: 18px; +} + +.row-body { + flex: 1; + min-width: 0; +} + +.row-label { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + flex-wrap: wrap; +} + +.kind { + font-size: 10px; + color: var(--fg-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.badge { + font-size: 9px; + padding: 1px 5px; + border-radius: 8px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.badge.implicit { background: #4a3a1c; color: #e0b870; } +.badge.error { background: #4a2c2c; color: var(--error); } + +.chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.chip { + font-size: 10px; + padding: 1px 6px; + background: var(--chip-bg); + border: 1px solid var(--chip-border); + border-radius: 9px; + color: var(--fg); +} + +.row-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + align-items: center; +} + +.row-actions .btn { padding: 2px 7px; font-size: 10px; } + +/* Lock indicator shown on implicit-join rows instead of a disabled × button. */ +.row-lock { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 22px; + font-size: 12px; + color: var(--fg-muted); + opacity: 0.4; + cursor: default; + user-select: none; +} + +.errors { + margin-top: 4px; + font-size: 10px; + color: var(--error); +} + +.empty { + padding: 24px; + text-align: center; + color: var(--fg-muted); + font-size: 11px; + line-height: 1.5; +} + +.toast { + position: fixed; + bottom: 12px; + left: 12px; + right: 12px; + padding: 8px 12px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 3px; + font-size: 11px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; +} +.toast.show { opacity: 1; } +.toast.ok { border-color: var(--ok); } +.toast.error { border-color: var(--error); } + +.filter-bar { + padding: 5px 10px; + background: var(--bg-elev); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.filter-bar input[type="search"] { + width: 100%; + background: var(--bg-row); + border: 1px solid var(--border); + color: var(--fg); + padding: 4px 8px; + font-size: 11px; + font-family: inherit; + border-radius: 2px; + outline: none; + -webkit-appearance: none; +} + +.filter-bar input[type="search"]:focus { + border-color: var(--accent); +} + +.filter-bar input[type="search"]::placeholder { + color: var(--fg-muted); +} + +/* --- Bulk-select checkboxes ----------------------------------------- */ + +.row-check { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-top: 1px; + accent-color: var(--accent); + cursor: pointer; +} + +/* Spacer keeps alignment for rows that have no checkbox (pseudo/non-deletable). */ +.row-check-pad { + flex-shrink: 0; + width: 16px; +} + +/* --- Invisible entity chips (#8) ------------------------------------ */ + +.chip.invisible { + opacity: 0.45; + border-style: dashed; +} + +.chip-hidden { + font-size: 8px; + margin-left: 3px; + padding: 0 3px; + background: #4a2c2c; + color: var(--error); + border-radius: 3px; + vertical-align: middle; +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e5fd0c --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 ConstraintLens contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE CONNECTION WITH THE SOFTWARE. diff --git a/PC_VALIDATION.md b/PC_VALIDATION.md new file mode 100644 index 0000000..b972623 --- /dev/null +++ b/PC_VALIDATION.md @@ -0,0 +1,103 @@ +# PC validation checklist + +Step-by-step list of what to do when you sit down at the Fusion-equipped PC for the first time. Designed to take roughly 15-20 minutes if everything works first try. + +Each step has a **pass** criterion and a **failure → action** path. Paste the spike-probe output and any failures back here. + +--- + +## 0. Prerequisites + +- [ ] Fusion 360 installed and updated to the **January 2026 release or later** (Python 3.14, Qt Web Browser backend). +- [ ] This repository cloned locally. The branch `claude/fusion-constraintlens-spec-94gPu` is the latest spec + scaffold. + +--- + +## 1. Install the add-in + +- [ ] Copy or symlink the `ConstraintLens/` directory into the Fusion add-ins folder: + - **Windows**: `%APPDATA%\Autodesk\Autodesk Fusion 360\API\AddIns\ConstraintLens\` + - **macOS**: `~/Library/Application Support/Autodesk/Autodesk Fusion 360/API/AddIns/ConstraintLens/` +- [ ] In Fusion: **Tools → Scripts and Add-Ins → Add-Ins** tab. **ConstraintLens** should appear in the list. +- [ ] Select it and click **Run**. + +**Pass**: no error dialog. +**Failure → action**: paste the dialog message back. Most likely cause is a missing/malformed `ConstraintLens.manifest`. + +--- + +## 2. Run the spike probe (highest value step) + +The probe answers all five open questions in `SPEC.md` §10 in one shot. + +- [ ] Open any Fusion design (or create a new empty document). +- [ ] **Tools → Scripts and Add-Ins → Scripts** tab → **+** → point at the **folder** `tests/fixture_sketch/` → **Run**. A message box confirms the fixture was created with 4 constraints + 2 dimensions. *(Fusion requires the script to live inside a folder with the same name — `fixture_sketch/fixture_sketch.py`.)* +- [ ] **Double-click** the new `ConstraintLens_Fixture` sketch in the browser to enter sketch edit. +- [ ] Back to Scripts tab → **+** → point at the **folder** `tests/spike_probe/` → **Run**. +- [ ] The probe writes `constraintlens_probe.txt` to the OS temp directory and previews the first 1500 chars in a message box. **Open the temp file and paste its full contents back into chat.** + +**Pass**: temp file exists, probe completes without a Python traceback. +**Failure → action**: paste the traceback. + +What the probe answers, mapped back to `SPEC.md` open questions: + +| Probe section | SPEC §10 question | What I'm looking for | +|---|---|---| +| Q1 Panel ids | Q1 Panel id for the toolbar button | Whether `SketchInspectPanel` (or similar) exists when the active workspace is the sketch context; if so, I'll relocate the button. | +| Q2 ShowUnderconstrained | Q2 Text-command precondition | Whether `executeTextCommand("Sketch.ShowUnderconstrained")` returns text outside sketch edit, raises, or returns empty. | +| Q3 Palette events (static introspection) | Q3 Visibility refresh strategy | Confirms the event surface; the manual minimize/restore test in step 5 confirms behavior. | +| Q4 Token capture | Q4 entityToken stability across save-reload | The probe captures one token. After step 5 you'll save+reload and re-resolve to confirm. | +| Q5 Constraint inventory | Q5 VerticalConstraint enumeration | The probe lists `distinct objectTypes` — confirms whether `"adsk::fusion::VerticalConstraint"` appears. | + +--- + +## 3. Smoke-test the add-in against the fixture + +- [ ] Confirm the **Constraint Lens** button appears somewhere in the toolbar (current best-guess: **Solid → Tools → Scripts and Add-Ins** panel). If you don't see it, the probe's Q1 output will tell us the right panel id. +- [ ] With `ConstraintLens_Fixture` open for edit, click **Constraint Lens**. + +**Pass criteria** — the docked panel on the right should show: +- Status banner: `ConstraintLens_Fixture · — under-constrained` (orange) or `fully constrained` (green) depending on whether the fixture is fully constrained as designed. +- **Geometric constraints (4)** section with: Horizontal, Vertical, Parallel, Tangent rows. +- **Dimensions (2)** section with: Linear and Diameter rows. +- **Endpoint joins (4)** section with one row per rectangle corner (each labeled "Endpoint join — Point N connects Line A, Line B"), each with the **implicit** badge. + +**Failure → action**: screenshot the panel + paste the contents of Fusion's **Text Commands** window (`File → View → Show Text Commands`). + +--- + +## 4. Smoke-test the interactions + +- [ ] **Click a constraint row** — the referenced geometry should highlight (turn blue) in the viewport. +- [ ] **Click the ⌖ button** on a constraint row — selects the constraint object itself (so you can use Fusion's Delete key). +- [ ] **Click the × button** on a row — the constraint should disappear and the list should refresh automatically. A toast appears at the bottom of the panel. +- [ ] Try the × on an implicit-join row — the button should be disabled (these aren't real constraints; you cannot delete them). +- [ ] Click **Refresh** in the toolbar — manual re-scan; should be a no-op if nothing changed. + +**Failure → action**: note exactly which interaction misbehaves. + +--- + +## 5. Exercise the landmine guards + +- [ ] **Scripts tab → + → `tests/fixture_midpoint/`** → **Run**. It creates `ConstraintLens_Midpoint_M1` with two midpoint constraints sharing the same sketch point — the canonical M-1 trigger configuration. +- [ ] Open that sketch for edit. The panel should show two MidPoint rows. **If one of them carries an `accessor` badge with an error message, M-1 is real and the defensive guard worked.** If not, M-1 doesn't trigger via this configuration on the January 2026 build — note that finding. +- [ ] **Test palette visibility (Q3)** — close the panel via its X button, then click **Constraint Lens** in the toolbar again. The panel should reopen with the current sketch data. +- [ ] **Test entityToken stability (Q4)** — from the spike probe output, copy the captured `entityToken`. Save the document. Close the document tab; reopen it. Open Fusion's **Text Commands** window and run: + ``` + > Python: adsk.fusion.Design.cast(adsk.core.Application.get().activeProduct).findEntityByToken('') + ``` + Pass if it returns a non-empty list with a `MidPointConstraint`-or-similar object. Fail if it returns empty. + +--- + +## 6. What to paste back + +When you have time, paste back: + +1. The full contents of `%TEMP%\constraintlens_probe.txt` (Windows) or `/tmp/constraintlens_probe.txt` (macOS). +2. The result of the entityToken save-reload test (step 5 last item). +3. Any unexpected error dialog text or behavior from steps 1, 3, 4. +4. Optionally: a screenshot of the panel against the fixture sketch. + +I'll fold the findings into a single corrective commit (most likely a panel-id swap and any small adjustments revealed by the probe). diff --git a/README.md b/README.md index 6b50884..51cff54 100644 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ -# FusionConstraints \ No newline at end of file +# ConstraintLens + +A Fusion 360 add-in that docks a panel listing every sketch constraint — with click-to-select, delete, and over/under-constrained status. Closes the long-standing UX gap of having to hunt tiny on-canvas glyphs to audit a sketch. + +See [`SPEC.md`](./SPEC.md) for the full architectural specification. + +## Features + +- **All constraint types listed** — 21 geometric constraint subtypes (Parallel, Perpendicular, Coincident, Tangent, Equal, Concentric, Midpoint, Symmetric, Offset, Polygon, Circular/Rectangular Pattern, and more), plus all sketch dimension types (Linear, Angular, Radial, Diameter, Offset Curves, etc.). +- **Implicit endpoint joins** — reconstructed from shared `SketchPoint` instances and shown as pseudo-rows with an "implicit" badge and lock indicator. +- **Click row → select entities** — highlights the constraint's referenced geometry in the Fusion viewport. +- **⌖ Select constraint** button — selects the constraint object itself so you can use Fusion's native Delete key. +- **× Delete** button per row — calls `constraint.deleteMe()`, disabled when `isDeletable == False`. +- **Sketch status banner** — shows sketch name, component, fully/under-constrained state, and any `healthState` warning. +- **Auto-refresh** — updates on every `commandTerminated` event (after every sketch edit) without manual intervention; plus a manual **Refresh** button as backstop. +- **Graceful empty state** — shows "No active sketch" when no sketch is being edited. + +## Requirements + +- Fusion 360 January 2026 release or later (Python 3.14 runtime). +- Windows or macOS. + +## Install + +1. Download the latest `ConstraintLens-vX.Y.Z.zip` from [**Releases**](../../releases). +2. Extract the `ConstraintLens/` folder. +3. Copy it into your Fusion add-ins directory: + - **Windows**: `%APPDATA%\Autodesk\Autodesk Fusion 360\API\AddIns\` + - **macOS**: `~/Library/Application Support/Autodesk/Autodesk Fusion 360/API/AddIns/` +4. In Fusion: **Tools → Scripts and Add-Ins → Add-Ins** tab → select **ConstraintLens** → **Run**. + - Tick **Run on Startup** to load it automatically on every Fusion launch. +5. The **Constraint Lens** button appears in **Solid → Tools → Scripts and Add-Ins** panel. + +## Usage + +1. Open a Fusion design and enter a sketch for editing (double-click a sketch in the browser). +2. Click **Constraint Lens** in the toolbar. A docked palette opens listing every constraint and dimension. +3. Click any row to select the referenced geometry in the viewport. +4. Use **×** to delete a constraint. The list refreshes automatically. +5. Click **⌖** to select the constraint object itself, then press `Delete` in Fusion for an alternative delete path. + +## Folder structure + +``` +FusionConstraints/ +├── SPEC.md Architectural spec. +├── ConstraintLens/ The Fusion add-in (copy this folder into AddIns/). +│ ├── ConstraintLens.manifest +│ ├── ConstraintLens.py +│ ├── lib/ Python backend modules. +│ └── palette/ HTML/JS/CSS palette UI (vanilla JS, no build step). +└── tests/ + ├── fixture_sketch/ Deterministic test sketch — 4 constraints, 2 dims, 4 implicit joins. + ├── fixture_midpoint/ Triggers the M-1 midpoint-to-midpoint landmine for defensive testing. + └── spike_probe/ API-feasibility probe; re-run after each Fusion update. +``` + +To run a test script: **Tools → Scripts and Add-Ins → Scripts → +** → point at the subfolder → **Run**. + +## Known limitations (MVP scope) + +- No granular CAD undo for **Delete** — Fusion's `Ctrl+Z` reverts the whole sketch-edit chunk. +- Implicit coincident endpoint joins cannot be deleted from the panel (they are shared `SketchPoint` instances, not true constraints). +- `CircularPatternConstraint` and `RectangularPatternConstraint` rows show only **Delete** — the API exposes no usable entity accessors for these types. +- `AssemblyConstraint` (Constrain Components, January 2026 preview API) is not supported; revisit when Autodesk drops the preview disclaimer. +- Palette data may be stale after minimizing/restoring the palette window — use **Refresh** or perform any sketch action to trigger a rescan. +- `OffsetConstraint` label shows `@ ?` for the distance because `OffsetConstraint.distance` returns `None` in the January 2026 build. The distance is correctly shown on the `SketchOffsetCurvesDimension` row. + +## License + +MIT — see [`LICENSE`](./LICENSE). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..8035fd1 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,527 @@ +# ConstraintLens — SPEC.md + +> Architectural specification for the ConstraintLens Fusion 360 add-in. This document is finalized before any production code is written; the implementer should be able to execute it without revisiting design decisions. + +--- + +## 1. Problem statement + +Fusion 360's desktop sketch environment surfaces constraints only as tiny on-canvas glyphs with no list view, making non-trivial sketches painful to audit, repair, and de-clutter. The Fusion Python API fully exposes geometric constraints, dimensions, and entity references — everything needed to build the missing list — but no third-party add-in does this today. ConstraintLens fills the gap with a docked HTML palette that lists every constraint in the active sketch and lets the user click-select, delete, filter, and diagnose them. + +--- + +## 2. Scope: MVP vs deferred + +### MVP (target: ~30 hours of build, single developer + Claude Code) +- Dockable HTML palette registered under **Solid → Sketch → Inspect** panel, shown via a single command button "Constraint Lens". +- Live list of every `GeometricConstraint` in the **active sketch** (the one currently in edit mode). One row per constraint with: type icon, type name, human-readable label, involved entity chips. +- Live list of every `SketchDimension` in the active sketch (separate tab/section), with parameter expression shown read-only for MVP. +- Reconstructed list of **implicit coincident endpoint joins** via `SketchPoint.connectedEntities`, rendered as pseudo-rows with a distinct "implicit" badge. +- **Click row → select referenced entities** in the viewport via `ui.activeSelections`. +- **Click row's "Select constraint" affordance → select the constraint object itself** (so the user can use Fusion's native Delete key as an alternative path). +- **Delete button per row** invoking `constraint.deleteMe()`; disabled when `isDeletable == False`. +- Sketch status banner: `isFullyConstrained` + `healthState` + `errorOrWarningMessage`. +- Auto-refresh on `ui.commandTerminated` and `app.documentActivated`; manual "Refresh" button as backstop. +- Graceful "No active sketch" state. +- Distributed as a zipped add-in folder + README install snippet for `~/Autodesk/Autodesk Fusion 360/API/AddIns/`. + +### Deferred (v1 polish, v2 premium — not in MVP) +- Filter / group / search controls (type, entity, redundant heuristic). +- Bulk delete with confirmation prompt. +- Hover-preview highlight via `CustomGraphics` overlay. +- Plugin-level undo stack (snapshot constraint set before destructive ops). +- Joint / AsBuiltJoint list panel. +- Editable dimension expression inline. +- Toggle for `sketch.areConstraintsShown` (hide native glyphs). +- "Show underconstrained" button wrapping `executeTextCommand("Sketch.ShowUnderconstrained")`. +- `AssemblyConstraint` (Constrain Components, January 2026 preview API) — **explicitly deferred until Autodesk drops the preview disclaimer.** +- App Store submission with installer (.msi / .pkg), screenshots, help URL. +- Multi-sketch / document-wide constraint browser. +- Redundant-constraint detector and auto-fix assistant. + +--- + +## 3. Folder & file structure + +``` +FusionConstraints/ +├── SPEC.md +├── README.md +├── ConstraintLens/ +│ ├── ConstraintLens.manifest +│ ├── ConstraintLens.py +│ ├── resources/ +│ │ ├── ConstraintLens/ +│ │ │ ├── 16x16.png +│ │ │ ├── 32x32.png +│ │ │ └── 64x64.png +│ │ └── glyphs/ +│ │ ├── parallel.svg +│ │ ├── perpendicular.svg +│ │ ├── horizontal.svg +│ │ ├── vertical.svg +│ │ ├── coincident.svg +│ │ ├── tangent.svg +│ │ ├── equal.svg +│ │ ├── collinear.svg +│ │ ├── concentric.svg +│ │ ├── midpoint.svg +│ │ ├── symmetric.svg +│ │ ├── offset.svg +│ │ ├── polygon.svg +│ │ ├── pattern.svg +│ │ └── surface.svg +│ └── lib/ +│ ├── __init__.py +│ ├── lifecycle.py +│ ├── events.py +│ ├── dispatch.py +│ ├── scanner.py +│ ├── labels.py +│ ├── selection.py +│ ├── actions.py +│ ├── tokens.py +│ └── messaging.py +├── palette/ +│ ├── index.html +│ ├── app.js +│ └── styles.css +└── tests/ + └── fixture_sketch.py +``` + +Path notes: +- The add-in root is `ConstraintLens/` (single directory, name-matches the `.py` and `.manifest` per Autodesk add-in convention). +- The palette HTML is served from inside the add-in folder via the local `palette/` directory copy at deploy time; during development the manifest entry resolves to the sibling `palette/` directory using a relative path. Keep it inside `ConstraintLens/palette/` for the shipped artifact — the top-level `palette/` is the source of truth. +- The shipped layout collapses the top-level `palette/` into `ConstraintLens/palette/`; the build step is a recursive copy (no bundler, no minifier — vanilla JS, single page). + +--- + +## 4. Module responsibilities + +**`ConstraintLens/ConstraintLens.manifest`** — Autodesk add-in manifest JSON. Declares add-in id, name, version, runs-on-startup flag, and the supported platforms. Owns nothing else; do not put logic here. + +**`ConstraintLens/ConstraintLens.py`** — Add-in entry point. Implements only `run(context)` and `stop(context)`. Delegates immediately to `lib.lifecycle.start()` and `lib.lifecycle.stop()`. Does NOT contain command definitions, event handler classes, or palette logic. + +**`lib/lifecycle.py`** — Owns command creation, palette creation, and panel-button registration. Calls `events.register_all()` on start, `events.unregister_all()` on stop, and registers a single `Commands.add("ConstraintLensShow")` toolbar button under `SolidScriptsAddinsPanel` (Sketch workspace) and `SolidCreatePanel` (Model workspace) — exact panel id confirmed at runtime spike. Does NOT iterate constraints or build UI content. + +**`lib/events.py`** — Owns all event handler classes (subclassing `adsk.core.*EventHandler`) AND the module-level `_handlers: list[adsk.core.EventHandler]` list that keeps Python refs alive against GC. Exports `register_all(app, ui)` / `unregister_all(app, ui)`. Each handler's `notify()` delegates to a free function elsewhere (typically `scanner.publish_active_sketch(palette)`). Does NOT scan sketches or talk to the palette directly. + +**`lib/dispatch.py`** — Owns the single source of truth for the **constraint type dispatch table** (section 5). Exports `DISPATCH: dict[str, ConstraintDescriptor]` keyed by `objectType` string (e.g. `"adsk::fusion::ParallelConstraint"`). Each descriptor declares accessor names, label-builder callable, glyph filename, and known-bug guard. Does NOT execute the lookups — `scanner.py` does. + +**`lib/scanner.py`** — Owns enumeration. Walks `sketch.geometricConstraints`, `sketch.sketchDimensions`, and `sketch.sketchPoints` (for implicit joins), applies the `dispatch.DISPATCH` table, and emits a JSON-serializable payload via `messaging.build_data_payload()`. Returns plain dicts; never touches the palette. Does NOT cache between calls in MVP — a re-scan on every event is acceptable for the sketch sizes encountered. + +**`lib/labels.py`** — Owns entity-display naming. Exports `EntityLabeler(sketch)` which on construction builds (token → "Line 3") maps by walking `sketchCurves.sketchLines`, `sketchPoints`, `sketchCurves.sketchCircles`, `sketchCurves.sketchArcs`, `sketchCurves.sketchEllipses`, `sketchCurves.sketchFittedSplines`, `sketchCurves.sketchControlPointSplines`. Pure data; one instance per scan. + +**`lib/selection.py`** — Owns viewport selection. Exports `select_entities(ui, entities: list)` and `select_constraint(ui, constraint)`. Always clears `ui.activeSelections` first; wraps `.add()` calls in a single batch to minimize repaint flicker. Does NOT delete or modify entities. + +**`lib/actions.py`** — Owns destructive operations. Exports `delete_constraint(constraint_token: str) -> ActionResult` and (deferred) `bulk_delete(tokens)`. Resolves the token via `tokens.resolve()`, checks `isDeletable`, invokes `deleteMe()`, returns success/failure with a message. Does NOT broadcast back to the palette — the caller triggers a refresh. + +**`lib/tokens.py`** — Owns `entityToken` resolution. Exports `token_of(entity) -> str` (returns `entity.entityToken`) and `resolve(design, token: str) -> object | None` (uses `Design.findEntityByToken`, returns first element or `None`). Centralized so the rest of the code never touches the underlying API directly. + +**`lib/messaging.py`** — Owns the palette ↔ Python wire format (section 7). Exports `send_to_palette(palette, action: str, payload: dict)`, `parse_from_palette(action: str, raw_json: str) -> dict`, and the JSON schemas as constants. JSON-serializes via `json.dumps(payload, default=str)`. Does NOT contain business logic. + +**`palette/index.html`** — Single-page palette shell. Loads `app.js` and `styles.css`. Provides root `
` and the `adsk.fusionSendData` plumbing. No logic beyond bootstrap. + +**`palette/app.js`** — Vanilla JS (no framework, no build step). Owns: render loop, message handler for `Python → JS` actions, click delegation to the appropriate `JS → Python` action via `adsk.fusionSendData(action, JSON.stringify(payload))`. Single global `state` object holding the latest snapshot from Python. No virtual DOM library — direct innerHTML diffing is fine at MVP scale. + +**`palette/styles.css`** — Owns visual styling. Matches Fusion's dark palette by default; CSS variables for theming. No logic. + +**`tests/fixture_sketch.py`** — Owns the dev smoke fixture (section 8). Stand-alone Fusion script (not part of the add-in) that builds a deterministic sketch. Run via Scripts & Add-Ins → Scripts → Run. + +--- + +## 5. Constraint type dispatch table + +Every `GeometricConstraint` subtype listed below is exhaustively covered. The dispatch table key is the `objectType` string returned by the API (e.g. `"adsk::fusion::ParallelConstraint"`); the human-readable label is built by `labels.EntityLabeler` plus the per-row template shown in the "Label template" column. + +**Important: the two research docs conflict on accessor naming.** Doc 1 implies a universal `entityOne` / `entityTwo` pair on `GeometricConstraint`. Doc 2 — which is correct against the Autodesk API reference — shows that each subtype has its own accessors (`lineOne`/`lineTwo`, `curveOne`/`curveTwo`, `point`/`entity`, etc.). **The dispatch table follows doc 2.** Any code that tries `hasattr(c, 'entityOne')` is a bug per this spec. + +| # | `objectType` | API class | Entity accessors (return type) | Label template | Known bugs / edge cases | +|---|---|---|---|---|---| +| 1 | `adsk::fusion::HorizontalConstraint` | `HorizontalConstraint` | `.line` (SketchLine) | `Horizontal — {line}` | none | +| 2 | `adsk::fusion::VerticalConstraint` | `VerticalConstraint` | `.line` (SketchLine) | `Vertical — {line}` | **Not enumerated in either research doc but documented in the Fusion API; treat as a peer of `HorizontalConstraint`.** Flag for runtime verification (open question 5). | +| 3 | `adsk::fusion::HorizontalPointsConstraint` | `HorizontalPointsConstraint` | `.pointOne`, `.pointTwo` (SketchPoint) | `Horizontal align — {p1} ↔ {p2}` | none | +| 4 | `adsk::fusion::VerticalPointsConstraint` | `VerticalPointsConstraint` | `.pointOne`, `.pointTwo` (SketchPoint) | `Vertical align — {p1} ↔ {p2}` | none | +| 5 | `adsk::fusion::ParallelConstraint` | `ParallelConstraint` | `.lineOne`, `.lineTwo` (SketchLine) | `Parallel — {l1} ∥ {l2}` | none | +| 6 | `adsk::fusion::PerpendicularConstraint` | `PerpendicularConstraint` | `.lineOne`, `.lineTwo` (SketchLine) | `Perpendicular — {l1} ⊥ {l2}` | none | +| 7 | `adsk::fusion::CollinearConstraint` | `CollinearConstraint` | `.lineOne`, `.lineTwo` (SketchLine) | `Collinear — {l1} ⋯ {l2}` | none | +| 8 | `adsk::fusion::CoincidentConstraint` | `CoincidentConstraint` | `.point` (SketchPoint), `.entity` (SketchEntity — line, circle, arc, ellipse, spline, point) | `Coincident — {point} on {entity}` | This is **the explicit coincident constraint only**; endpoint "joins" between curves are not stored here — see implicit-joins row at the bottom. | +| 9 | `adsk::fusion::CoincidentToSurfaceConstraint` | `CoincidentToSurfaceConstraint` | `.point` (SketchPoint), `.surface` (BRepFace or ConstructionPlane) | `Coincident to surface — {point}` | Surface may be in another component; resolve `surface.body.parentComponent` for display, fall back to `"external surface"`. | +| 10 | `adsk::fusion::TangentConstraint` | `TangentConstraint` | `.curveOne`, `.curveTwo` (SketchCurve) | `Tangent — {c1} ⌒ {c2}` | none | +| 11 | `adsk::fusion::EqualConstraint` | `EqualConstraint` | `.curveOne`, `.curveTwo` (SketchCurve — line/arc/circle pair, mixing requires matching kinds) | `Equal — {c1} = {c2}` | none | +| 12 | `adsk::fusion::ConcentricConstraint` | `ConcentricConstraint` | `.entityOne`, `.entityTwo` (SketchCircle, SketchArc, or SketchEllipse) | `Concentric — {e1} ⊙ {e2}` | This subtype **does** use `entityOne/entityTwo` (one of the few that match doc 1's claim). | +| 13 | `adsk::fusion::MidPointConstraint` | `MidPointConstraint` | `.point` (SketchPoint), `.midPointCurve` (SketchCurve) | `Midpoint — {point} mid {curve}` | **Known bug** (doc 2): `.point` raises for midpoint-to-midpoint configurations. **Defensive pattern: see section 9 landmine M-1.** | +| 14 | `adsk::fusion::SymmetryConstraint` | `SymmetryConstraint` | `.entityOne`, `.entityTwo` (SketchCurve or SketchPoint), `.symmetryLine` (SketchLine) | `Symmetric — {e1} ↔ {e2} about {symLine}` | none | +| 15 | `adsk::fusion::OffsetConstraint` | `OffsetConstraint` | `.parentCurves` (ObjectCollection), `.childCurves` (ObjectCollection), `.distance` (ModelParameter) | `Offset {n}→{m} curves @ {distance.expression}` | Collections, not single entities — render counts in the label, expand only on row click. | +| 16 | `adsk::fusion::PolygonConstraint` | `PolygonConstraint` | `.lines` (ObjectCollection of SketchLine), `.centerSketchPoint` (SketchPoint) | `Polygon ({n} sides) about {center}` | Inscribed vs circumscribed is not exposed via API; do not attempt to display it. | +| 17 | `adsk::fusion::CircularPatternConstraint` | `CircularPatternConstraint` | **No usable accessors** — read-only stub (doc 2, confirmed by Brian Ekins on Autodesk forum). | `Circular pattern (read-only)` | Only `deleteMe()` works. Disable "Select entities" button for this row; "Delete" remains enabled. | +| 18 | `adsk::fusion::RectangularPatternConstraint` | `RectangularPatternConstraint` | **No usable accessors** — read-only stub. | `Rectangular pattern (read-only)` | Same handling as #17. | +| 19 | `adsk::fusion::LineOnPlanarSurfaceConstraint` | `LineOnPlanarSurfaceConstraint` | `.line` (SketchLine), `.planarSurface` (BRepFace or ConstructionPlane) | `Line on surface — {line}` | Surface may be external; same fallback as #9. | +| 20 | `adsk::fusion::LineParallelToPlanarSurfaceConstraint` | `LineParallelToPlanarSurfaceConstraint` | `.line`, `.planarSurface` | `Line ∥ surface — {line}` | same | +| 21 | `adsk::fusion::PerpendicularToSurfaceConstraint` | `PerpendicularToSurfaceConstraint` | `.line`, `.planarSurface` | `Line ⊥ surface — {line}` | same | +| **PSEUDO** | *(implicit coincident endpoint join)* | reconstructed from `SketchPoint.connectedEntities` | `point` (the shared SketchPoint), `entities` (list of SketchCurves whose endpoints share `point`) | `Endpoint join — {point} connects {curve list}` (badge: "implicit") | **Not a real `GeometricConstraint`.** Surface only when `SketchPoint.connectedEntities.count > 1`. Has no `entityToken` of its own — use the SketchPoint's token prefixed with `"join:"` as the row key. **`deleteMe()` is not applicable** — deletion would require breaking the shared point, which is not safe via API. Disable Delete for these rows. | + +**Label template substitutions** (resolved by `labels.EntityLabeler`): +- `{line}` → e.g. `"Line 3"` (1-indexed by position in `sketch.sketchCurves.sketchLines`). +- `{point}` → e.g. `"Point 7"` (1-indexed in `sketch.sketchPoints`). +- `{e1}`, `{c1}` etc. → kind-aware name (e.g. `"Circle 2"`, `"Arc 4"`). +- `{distance.expression}` → the parameter expression string, e.g. `"10 mm"`. + +**Dimension subclasses** are enumerated separately (via `sketch.sketchDimensions`) and follow a parallel but simpler dispatch in `dispatch.py`: +`SketchAngularDimension`, `SketchConcentricCircleDimension`, `SketchDiameterDimension`, `SketchDistanceBetweenLineAndPlanarSurfaceDimension`, `SketchDistanceBetweenTwoLinesDimension`, `SketchEllipseMajorRadiusDimension`, `SketchEllipseMinorRadiusDimension`, `SketchLinearDimension`, `SketchOffsetCurvesDimension`, `SketchOffsetDimension`, `SketchRadialDimension`, `SketchTangentDistanceDimension`. Each exposes `.parameter.expression` (read-only display in MVP), plus `.entityOne` / `.entityTwo` (kinds vary). Build the same row schema. + +--- + +## 6. Event handler registration pattern + +The Fusion API uses C++ event objects bridged to Python. If a Python handler instance is dropped, the C++ side holds a dangling reference and the next callback crashes Fusion silently. **Always pin handlers in a module-level list.** + +### Pattern + +```python +# lib/events.py +import adsk.core, adsk.fusion + +_handlers: list[adsk.core.EventHandler] = [] +_subscriptions: list[tuple[adsk.core.Event, adsk.core.EventHandler]] = [] + + +class _DocumentActivatedHandler(adsk.core.DocumentEventHandler): + def __init__(self, on_change): + super().__init__() + self._on_change = on_change + + def notify(self, args: adsk.core.DocumentEventArgs): + try: + self._on_change() + except Exception: + import traceback + adsk.core.Application.get().userInterface.messageBox( + "ConstraintLens handler error:\n" + traceback.format_exc() + ) + + +class _CommandTerminatedHandler(adsk.core.ApplicationCommandEventHandler): + def __init__(self, on_change): + super().__init__() + self._on_change = on_change + + def notify(self, args: adsk.core.ApplicationCommandEventArgs): + try: + self._on_change() + except Exception: + pass # never let a handler exception escape into Fusion + + +def register_all(app: adsk.core.Application, ui: adsk.core.UserInterface, on_change) -> None: + h1 = _DocumentActivatedHandler(on_change) + app.documentActivated.add(h1) + _handlers.append(h1) + _subscriptions.append((app.documentActivated, h1)) + + h2 = _CommandTerminatedHandler(on_change) + ui.commandTerminated.add(h2) + _handlers.append(h2) + _subscriptions.append((ui.commandTerminated, h2)) + + +def unregister_all() -> None: + for event, handler in _subscriptions: + try: + event.remove(handler) + except Exception: + pass + _subscriptions.clear() + _handlers.clear() +``` + +### Which events to subscribe to and when + +| Event | Subscribe in | Why | Cost of firing | +|---|---|---|---| +| `app.documentActivated` | `lifecycle.start()` | Active sketch may change when user switches documents. | Cheap (sketch may be `None` — handler short-circuits). | +| `ui.commandTerminated` | `lifecycle.start()` | Fires after every Fusion command, including sketch edits. The reliable "something changed" signal — Autodesk does not expose a `sketchModified` event. | Re-scans the active sketch; acceptable for MVP because sketches are bounded in size. | +| `palette.closed` | `lifecycle.start()` (on the palette object itself) | Stops sending updates when the user closes the palette. | One-shot. | +| `palette.navigatingURL` | `lifecycle.start()` | Intercept any link clicks in the HTML so they open in the system browser rather than the embedded Qt browser. | Rare. | +| `palette.incomingFromHTML` | `lifecycle.start()` | Receives `JS → Python` messages (see section 7). | One per user action. | + +**Do NOT subscribe to** `app.documentSaving`, `app.documentSaved`, or per-sketch entity events — none of them fire reliably on the constraint-list edits that matter, and `commandTerminated` already covers the cases. + +**Lifetime rule:** handlers are created in `register_all()` and only ever destroyed in `unregister_all()`. Never re-instantiate a handler mid-session; replace the callback target via a closure capturing a mutable reference instead. + +--- + +## 7. Palette HTML ↔ Python message contract + +Direction notation: **JS→PY** = JavaScript calls `adsk.fusionSendData(action, jsonString)`, handled in Python via `palette.incomingFromHTML`. **PY→JS** = Python calls `palette.sendInfoToHTML(action, jsonString)`, received in JS via `window.fusionJavaScriptHandler.handle(action, data)`. + +All payloads are JSON. Unknown action names are logged and ignored (forward-compat). + +### JS → PY actions + +| Action | Direction | Payload schema | Notes | +|---|---|---|---| +| `paletteReady` | JS→PY | `{}` | Sent once on palette load. Python responds with a `data` push. | +| `requestRefresh` | JS→PY | `{}` | Manual refresh button. Python re-scans and pushes `data`. | +| `selectEntities` | JS→PY | `{"rowKey": ""}` | Selects the referenced entities in the viewport. | +| `selectConstraint` | JS→PY | `{"token": ""}` | Selects the constraint object itself (not valid for pseudo rows — JS must not send these). | +| `deleteConstraint` | JS→PY | `{"token": ""}` | Deletes via `constraint.deleteMe()`. Python pushes `data` after. | +| `openLogConsole` | JS→PY | `{}` | (Deferred) opens a Python-side debug log dump in a message box. | + +### PY → JS actions + +| Action | Direction | Payload schema | Notes | +|---|---|---|---| +| `data` | PY→JS | see below | Full snapshot. Sent on `paletteReady`, `requestRefresh`, every subscribed event, and after any destructive action. | +| `noActiveSketch` | PY→JS | `{"reason": ""}` | Sent when `design.activeEditObject` is not a `Sketch`. Palette renders an empty state. | +| `error` | PY→JS | `{"message": "", "context": ""}` | Recoverable errors only — fatal errors fall back to `ui.messageBox`. | +| `actionResult` | PY→JS | `{"action": "deleteConstraint", "ok": true, "message": ""}` | Optional toast; the subsequent `data` push is the authoritative source of truth. | + +### `data` payload schema + +```json +{ + "sketch": { + "name": "Sketch1", + "componentName": "Body1", + "isFullyConstrained": false, + "healthState": "WarningHealthState", + "errorOrWarningMessage": "Under-constrained: 2 points free" + }, + "constraints": [ + { + "rowKey": "abc123def456", + "token": "abc123def456", + "kind": "ParallelConstraint", + "objectType": "adsk::fusion::ParallelConstraint", + "label": "Parallel — Line 2 ∥ Line 4", + "glyph": "parallel.svg", + "entities": [ + {"token": "...", "kind": "SketchLine", "label": "Line 2"}, + {"token": "...", "kind": "SketchLine", "label": "Line 4"} + ], + "isDeletable": true, + "isPseudo": false, + "errors": [] + } + ], + "dimensions": [ + { + "rowKey": "...", + "token": "...", + "kind": "SketchLinearDimension", + "label": "Linear: Line 1 → Line 3 = 40 mm", + "parameterExpression": "40 mm", + "isDeletable": true, + "errors": [] + } + ], + "implicitJoins": [ + { + "rowKey": "join:", + "token": null, + "kind": "ImplicitCoincidentJoin", + "label": "Endpoint join — Point 5 connects Line 1, Line 2", + "entities": [ + {"token": "...", "kind": "SketchPoint", "label": "Point 5"}, + {"token": "...", "kind": "SketchLine", "label": "Line 1"}, + {"token": "...", "kind": "SketchLine", "label": "Line 2"} + ], + "isDeletable": false, + "isPseudo": true, + "errors": [] + } + ] +} +``` + +The `errors` array on a row is non-empty when an accessor raised (e.g. `MidPointConstraint.point` bug, see section 9). The row is still rendered so the user can delete it; the entity chips are replaced by an "accessor error" placeholder. + +### Why this contract is shaped this way + +- **All actions are token-based**, never positional. Lists may re-order between scans; positional indices would break delete-after-refresh. +- **Pseudo rows use `rowKey` only**, never `token`, because they don't have one. JS code branches on `isPseudo` rather than `token == null`. +- **Python always pushes a fresh `data` after every destructive action.** JS never mutates its local state speculatively — fewer race conditions, no rollback logic to write. + +--- + +## 8. Fixture sketch script + +The fixture creates a deterministic sketch in 30 seconds with: a rectangle (4 lines, shared endpoints), a circle, **4 explicit geometric constraint types** (Horizontal, Vertical, Parallel, Tangent), and **2 dimensions** (linear width, circle diameter). Save as `tests/fixture_sketch.py`, then in Fusion: **Tools → Scripts and Add-Ins → Scripts → +/folder → point at this file → Run**. + +```python +# tests/fixture_sketch.py +# Run from Fusion: Tools → Scripts and Add-Ins → Scripts → Run. +# Creates a fully-known fixture sketch for ConstraintLens dev iteration. + +import adsk.core +import adsk.fusion +import traceback + + +def run(context): + ui = None + try: + app = adsk.core.Application.get() + ui = app.userInterface + design = adsk.fusion.Design.cast(app.activeProduct) + if not design: + ui.messageBox("Open a Fusion design (not a drawing) first.") + return + + root = design.rootComponent + sketch = root.sketches.add(root.xYConstructionPlane) + sketch.name = "ConstraintLens_Fixture" + + # Drawing the rectangle as four lines with shared endpoints, + # so we get the implicit coincident-join behavior to test against. + lines = sketch.sketchCurves.sketchLines + P = adsk.core.Point3D.create + bottom = lines.addByTwoPoints(P(0, 0, 0), P(4, 0, 0)) + right = lines.addByTwoPoints(bottom.endSketchPoint, P(4, 2, 0)) + top = lines.addByTwoPoints(right.endSketchPoint, P(0, 2, 0)) + left = lines.addByTwoPoints(top.endSketchPoint, bottom.startSketchPoint) + + # Circle, positioned to allow a tangent with the top edge. + circles = sketch.sketchCurves.sketchCircles + circle = circles.addByCenterRadius(P(2.0, 1.4, 0), 0.5) + + # Four explicit geometric constraint subtypes. + gc = sketch.geometricConstraints + gc.addHorizontal(bottom) # HorizontalConstraint + gc.addVertical(left) # VerticalConstraint + gc.addParallel(top, bottom) # ParallelConstraint + gc.addTangent(circle, top) # TangentConstraint + + # Two dimensions: one linear (rectangle width), one diameter (circle). + dims = sketch.sketchDimensions + dims.addDistanceDimension( + bottom.startSketchPoint, + bottom.endSketchPoint, + adsk.fusion.DimensionOrientations.HorizontalDimensionOrientation, + P(2.0, -0.7, 0), + ) + dims.addDiameterDimension(circle, P(3.2, 2.0, 0)) + + ui.messageBox( + "ConstraintLens fixture created.\n" + f"Sketch: {sketch.name}\n" + f"Geometric constraints: {sketch.geometricConstraints.count}\n" + f"Dimensions: {sketch.sketchDimensions.count}\n" + f"Fully constrained: {sketch.isFullyConstrained}" + ) + except Exception: + if ui: + ui.messageBox("Failed:\n" + traceback.format_exc()) +``` + +After running: open the sketch for edit (so `design.activeEditObject` is the sketch), then click **Constraint Lens** in the Sketch panel. You should see exactly: 4 explicit geometric constraint rows, 2 dimension rows, and 4 implicit coincident-join pseudo-rows (one per rectangle corner). + +--- + +## 9. Known API landmines + +Each landmine is named (M-N) so code comments can reference it. + +### M-1 — `MidPointConstraint.point` raises on midpoint-to-midpoint +Documented in research doc 2. The scanner must guard every accessor lookup: + +```python +def _safe(getter, default=None): + try: + return getter() + except Exception: + return default + +point = _safe(lambda: constraint.point) +curve = _safe(lambda: constraint.midPointCurve) +errors = [] +if point is None: + errors.append("midpoint-to-midpoint: .point accessor unavailable (Fusion bug)") +``` + +The row is still emitted; the palette renders `` for the missing chip. + +### M-2 — `CircularPatternConstraint` / `RectangularPatternConstraint` are read-only stubs +Per Brian Ekins (cited in doc 2). No entity accessors are usable. The dispatch descriptor sets `entities=[]` and the palette disables the "Select entities" button for these row kinds — only "Delete" remains active. + +### M-3 — Coincident endpoint joins are not constraints +Per Brian Ekins (doc 2). They are shared `SketchPoint` instances. The scanner walks `sketch.sketchPoints` and emits a pseudo-row whenever `point.connectedEntities.count > 1`. Pseudo rows: +- Use `rowKey = "join:" + point.entityToken`. +- Have `token = null`. +- Have `isDeletable = false` (deleting the shared point is destructive and out of MVP scope). + +### M-4 — `AssemblyConstraint` is preview API +Per the January 2026 Fusion API "What's New" page (doc 2). MVP **does not import, scan, or expose** `AssemblyConstraint`. Track the disclaimer on every Fusion release; revisit when removed. + +### M-5 — `SketchConstraints` selection filter omits dimensions +Per doc 1 / doc 2. ConstraintLens does not rely on this filter; the scanner reads `sketchDimensions` directly from the `Sketch` object, so this landmine cannot affect us. Documented here so a future contributor doesn't try to "simplify" by using the filter. + +### M-6 — Iterators are `.item(i)`, not `[i]` +The collection classes (`GeometricConstraints`, `SketchPoints`, etc.) support `for x in coll` and `coll.item(i)` and `coll.count`, but **not subscripting** (`coll[0]` raises). Spec: use `for` loops; reach for `.item(i)` only when index access is required (e.g. building 1-based labels — `for i in range(coll.count): coll.item(i)`). + +### M-7 — Handlers freed by GC crash Fusion silently +See section 6. **Single defensive pattern:** the module-level `_handlers` list in `lib/events.py`. No exceptions. + +### M-8 — `Palette.sendInfoToHTML` may freeze Fusion if the data panel is being browsed (UP-38529) +Per doc 2. Defensive pattern: gate every `sendInfoToHTML` call on `palette.isVisible == True`. Skip the push otherwise; the next event refreshes on its own. + +### M-9 — `Sketch.ShowUnderconstrained` only returns a text summary +Per doc 2. MVP does not parse this output; the deferred underconstrained button surfaces the raw text in a banner. Do not attempt to map counts back to entities — there is no API surface for it. + +### M-10 — No granular CAD undo for constraint operations +Per doc 2. MVP does not implement an undo stack; delete actions show a confirmation dialog in v1, not MVP. Document this in the README user-facing notes so users know to use Fusion's `Ctrl+Z` (which undoes the whole sketch-edit chunk, not the single constraint delete). + +### M-11 — `executeTextCommand` must run inside a sketch edit context +Empirically required for `Sketch.ShowUnderconstrained`. The deferred button enables only when `design.activeEditObject is a Sketch`. See open question 2. + +### M-12 — Python version churn +Per doc 2 — Fusion has gone 3.7 → 3.9.7 → 3.12 → 3.14 in roughly four release cycles. Spec rules: ship `.py` source only (no `.pyc`); no native extensions; no dependencies outside the Python stdlib and the Autodesk-provided `adsk.*` modules. + +### M-13 — Doc-1 / Doc-2 conflict on accessor naming +Doc 1 says `entityOne` / `entityTwo` exist on every `GeometricConstraint`. Doc 2 (and the Autodesk reference) shows accessor names differ by subtype. **Resolution: follow doc 2.** This spec's dispatch table (section 5) is authoritative; any code resembling `getattr(c, 'entityOne', None)` as a generic accessor strategy is incorrect. + +### M-14 — Doc-1 / Doc-2 conflict on highlight mechanism +Doc 1 mentions `isLightBulbOn` for highlighting. That property controls browser-tree visibility, not selection highlight. **Resolution:** use `ui.activeSelections.add(...)` exclusively (doc 2). `isLightBulbOn` is not referenced anywhere in MVP code. + +--- + +## 10. Open questions + +**All five questions resolved by PC test sessions 1–2 (spike probe output received 2026-05-21).** + +1. ~~**Exact panel id for the Sketch toolbar button.**~~ **RESOLVED (PC test session 2).** `SolidScriptsAddinsPanel` ('Add-ins', always visible) is confirmed working. `SketchConstraintsPanel` ('CONSTRAINTS', visible during sketch edit) also appears in the panel list and is a better home for v1 discoverability — relocating the button there is a v1 polish item, not an MVP blocker. + +2. ~~**Whether `executeTextCommand("Sketch.ShowUnderconstrained")` requires an active sketch edit context.**~~ **RESOLVED (PC test session 2).** Confirmed requires sketch edit context. Returns `'Under constrained points: N, under constrained curves: N'` as a plain string. The deferred underconstrained button should be enabled only when `design.activeEditObject` is a `Sketch`, and should surface the raw string in a banner without attempting to parse it. + +3. ~~**Refresh strategy when `palette.isVisible == False`.**~~ **RESOLVED (PC test session 2).** Palette event surface is `['closed', 'incomingFromHTML', 'navigatingURL']` — no `shown` or `opened` event exists. Push-on-restore is not possible via an event hook. Current strategy stands: gate every `sendInfoToHTML` call on `palette.isVisible == True` (M-8 guard); the next `commandTerminated` event after the palette is restored will trigger a fresh scan automatically. + +4. ~~**Stability of `entityToken` for `GeometricConstraint` objects across save-reload.**~~ **RESOLVED — PASS (PC test session 2).** `Design.findEntityByToken` returned a non-empty `BaseVector` after save-close-reopen of the document. Token-based row keys in `messaging.py` are correct; no positional fallback is needed. + +5. ~~**Whether `VerticalConstraint` is actually surfaced by the API or only created implicitly.**~~ **RESOLVED — PASS (PC test session 2).** `adsk::fusion::VerticalConstraint` appeared at row [1] in the spike probe's `geometricConstraints` enumeration of `ConstraintLens_Fixture`. The dispatch table entry is correct; no change needed. + +--- + +# Confirmed decisions (locked) + +The three forks the spec previously deferred to the project owner have been resolved. + +1. **Target Fusion version & Python — latest only.** + ConstraintLens targets the January 2026 Fusion build and later, running on Python 3.14. Implementation rules that follow from this: + - `Palettes.add(..., useQtWebBrowser=True)` is called unconditionally; no CEF fallback path is written. + - Type annotations use 3.12+ syntax: `list[X]`, `dict[K, V]`, `X | None`. **Do not** add `from __future__ import annotations` or import `Optional`/`List` from `typing`. + - `match` statements are permitted in `dispatch.py` if they read cleaner than the descriptor table; the descriptor table remains the source of truth either way. + +2. **Distribution — GitHub Releases only for MVP.** + The deliverable is a zipped `ConstraintLens/` add-in folder published as a GitHub Release, plus a README install snippet pointing at `~/Autodesk/Autodesk Fusion 360/API/AddIns/`. Implementation rules: + - No `installer/`, `store_assets/`, or signing directories in the repo. + - No App Store metadata, help-URL pages, or screenshot kit. + - The README install snippet must cover both Windows and macOS path conventions. + - App Store submission is explicitly out of MVP scope; revisit only after the deferred v1 features in section 2 land. + +3. **Workspace coverage — Solid sketches only.** + The toolbar button is registered in `SolidScriptsAddinsPanel` ('Add-ins', always visible). A v1 polish item is to also register in `SketchConstraintsPanel` for in-sketch discoverability. Implementation rules: + - `lifecycle.py` registers exactly one command-button placement; do not loop over workspaces. + - The scanner is intentionally workspace-agnostic (it operates on the `Sketch` passed in), so future workspaces are an additive change to `lifecycle.py` only. + - Sheet Metal / Form / Surface / Drawing support is a v1 polish item; do not write feature flags for it in MVP. diff --git a/tests/fixture_midpoint/fixture_midpoint.py b/tests/fixture_midpoint/fixture_midpoint.py new file mode 100644 index 0000000..2b795fc --- /dev/null +++ b/tests/fixture_midpoint/fixture_midpoint.py @@ -0,0 +1,58 @@ +# tests/fixture_midpoint.py +# Exercises landmine M-1 — MidPointConstraint.point raising on +# midpoint-to-midpoint configurations. Creates the canonical setup +# referenced in the Autodesk forum thread (one sketch point constrained +# as the midpoint of two different lines). If M-1 triggers, the +# ConstraintLens panel must render the row with an "accessor error" +# badge rather than crashing. + +import adsk.core +import adsk.fusion +import traceback + + +def run(context): + ui = None + try: + app = adsk.core.Application.get() + ui = app.userInterface + design = adsk.fusion.Design.cast(app.activeProduct) + if not design: + ui.messageBox("Open a Fusion design first.") + return + + root = design.rootComponent + sketch = root.sketches.add(root.xYConstructionPlane) + sketch.name = "ConstraintLens_Midpoint_M1" + + lines = sketch.sketchCurves.sketchLines + P = adsk.core.Point3D.create + + # Two crossing lines whose midpoints should coincide at the origin. + line_a = lines.addByTwoPoints(P(-2, 0, 0), P(2, 0, 0)) + line_b = lines.addByTwoPoints(P(0, -1.5, 0), P(0, 1.5, 0)) + + # Free sketch point at the crossing. + mid_point = sketch.sketchPoints.add(P(0, 0, 0)) + + gc = sketch.geometricConstraints + # Point is the midpoint of line A. + gc.addMidPoint(mid_point, line_a) + # Same point is the midpoint of line B — the configuration that + # Brian Ekins flagged on the Autodesk forum as triggering the + # .point accessor exception on the second constraint. + gc.addMidPoint(mid_point, line_b) + + ui.messageBox( + "Midpoint M-1 fixture created.\n\n" + f"Sketch: {sketch.name}\n" + f"Geometric constraints: {sketch.geometricConstraints.count}\n" + f"Fully constrained: {sketch.isFullyConstrained}\n\n" + "Open the sketch for edit and click Constraint Lens. The two " + "midpoint rows should both render — one of them may show an " + "'accessor' badge if M-1 triggers. Either outcome confirms the " + "defensive guard works; a Fusion crash here would be the bug." + ) + except Exception: + if ui: + ui.messageBox("Failed:\n" + traceback.format_exc()) diff --git a/tests/fixture_sketch/fixture_sketch.py b/tests/fixture_sketch/fixture_sketch.py new file mode 100644 index 0000000..0f5f0f4 --- /dev/null +++ b/tests/fixture_sketch/fixture_sketch.py @@ -0,0 +1,70 @@ +# tests/fixture_sketch.py +# Run from Fusion: Tools -> Scripts and Add-Ins -> Scripts -> +/folder -> +# point at this file -> Run. +# Creates a fully-known fixture sketch for ConstraintLens dev iteration. +# +# Produces (per SPEC.md section 8): +# - 4 lines (rectangle, shared endpoints -> 4 implicit coincident joins) +# - 1 circle +# - 4 explicit geometric constraints: Horizontal, Vertical, Parallel, Tangent +# - 2 dimensions: linear (rectangle width), diameter (circle) + +import adsk.core +import adsk.fusion +import traceback + + +def run(context): + ui = None + try: + app = adsk.core.Application.get() + ui = app.userInterface + design = adsk.fusion.Design.cast(app.activeProduct) + if not design: + ui.messageBox("Open a Fusion design (not a drawing) first.") + return + + root = design.rootComponent + sketch = root.sketches.add(root.xYConstructionPlane) + sketch.name = "ConstraintLens_Fixture" + + # Rectangle as four lines with shared endpoints — this gives us + # the implicit coincident-join behavior to test against. + lines = sketch.sketchCurves.sketchLines + P = adsk.core.Point3D.create + bottom = lines.addByTwoPoints(P(0, 0, 0), P(4, 0, 0)) + right = lines.addByTwoPoints(bottom.endSketchPoint, P(4, 2, 0)) + top = lines.addByTwoPoints(right.endSketchPoint, P(0, 2, 0)) + left = lines.addByTwoPoints(top.endSketchPoint, bottom.startSketchPoint) + + # Circle positioned to allow a tangent with the top edge. + circles = sketch.sketchCurves.sketchCircles + circle = circles.addByCenterRadius(P(2.0, 1.4, 0), 0.5) + + # Four explicit geometric constraint subtypes. + gc = sketch.geometricConstraints + gc.addHorizontal(bottom) # HorizontalConstraint + gc.addVertical(left) # VerticalConstraint + gc.addParallel(top, bottom) # ParallelConstraint + gc.addTangent(circle, top) # TangentConstraint + + # Two dimensions: one linear (rectangle width), one diameter (circle). + dims = sketch.sketchDimensions + dims.addDistanceDimension( + bottom.startSketchPoint, + bottom.endSketchPoint, + adsk.fusion.DimensionOrientations.HorizontalDimensionOrientation, + P(2.0, -0.7, 0), + ) + dims.addDiameterDimension(circle, P(3.2, 2.0, 0)) + + ui.messageBox( + "ConstraintLens fixture created.\n" + f"Sketch: {sketch.name}\n" + f"Geometric constraints: {sketch.geometricConstraints.count}\n" + f"Dimensions: {sketch.sketchDimensions.count}\n" + f"Fully constrained: {sketch.isFullyConstrained}" + ) + except Exception: + if ui: + ui.messageBox("Failed:\n" + traceback.format_exc()) diff --git a/tests/spike_probe/spike_probe.py b/tests/spike_probe/spike_probe.py new file mode 100644 index 0000000..09aceb9 --- /dev/null +++ b/tests/spike_probe/spike_probe.py @@ -0,0 +1,272 @@ +# tests/spike_probe.py +# Run from Fusion: Tools -> Scripts and Add-Ins -> Scripts -> Run. +# +# Probes the five open questions from SPEC.md section 10 in a single shot. +# Prerequisite: run fixture_sketch.py first, then open the resulting sketch +# (named "ConstraintLens_Fixture") for edit. The probe also works on any +# other open sketch — it falls back to "first sketch in root" when nothing +# is currently being edited. +# +# Output: a message-box summary plus a full text file written to the OS +# temp directory. Paste the temp file contents back to the developer. + +import os +import tempfile +import traceback + +import adsk.core +import adsk.fusion + + +# --- Probes -------------------------------------------------------------- + + +def probe_q1_panels(ui: adsk.core.UserInterface, out: list[str]) -> None: + """Open question 1 — find the right panel id for the toolbar button.""" + out.append("=" * 72) + out.append("Q1 Panel ids visible in the current workspace") + out.append("=" * 72) + try: + ws = ui.activeWorkspace + out.append(f" active workspace: id={ws.id!r} name={ws.name!r}") + out.append(f" total panels: {ws.toolbarPanels.count}") + for i in range(ws.toolbarPanels.count): + p = ws.toolbarPanels.item(i) + visible = "visible" if p.isVisible else "hidden" + out.append(f" [{i:>2}] {visible:>7} {p.id!r:40} {p.name!r}") + except Exception as exc: + out.append(f" ERROR: {exc}") + out.append("") + out.append(" Hint: re-run this probe with the Sketch toolbar active") + out.append(" (open any sketch for edit first) to see SketchInspectPanel etc.") + + +def probe_q2_underconstrained(app: adsk.core.Application, out: list[str]) -> None: + """Open question 2 — Sketch.ShowUnderconstrained precondition.""" + out.append("") + out.append("=" * 72) + out.append("Q2 Sketch.ShowUnderconstrained behavior in current context") + out.append("=" * 72) + design = adsk.fusion.Design.cast(app.activeProduct) + edit_obj = design.activeEditObject if design else None + in_sketch_edit = isinstance(edit_obj, adsk.fusion.Sketch) + out.append(f" in sketch edit mode: {in_sketch_edit}") + try: + result = app.executeTextCommand("Sketch.ShowUnderconstrained") + out.append(f" return value (repr): {result!r}") + except Exception as exc: + out.append(f" raised: {type(exc).__name__}: {exc}") + + +def probe_q3_palette_events(out: list[str]) -> None: + """Open question 3 — palette visibility event surface.""" + out.append("") + out.append("=" * 72) + out.append("Q3 Palette visibility events (static introspection)") + out.append("=" * 72) + # We cannot create a real palette here without polluting state, so we + # just enumerate event-related attributes on the Palette class via the + # API metadata we can reach. + try: + # Reach the class via a known import; introspect callable attrs. + cls = adsk.core.Palette + attrs = sorted( + name for name in dir(cls) + if "event" in name.lower() or name in {"closed", "navigatingURL", "incomingFromHTML"} + ) + out.append(f" Palette event-like attrs: {attrs}") + except Exception as exc: + out.append(f" introspection failed: {exc}") + out.append(" Manual follow-up: install the add-in, minimize/restore the") + out.append(" palette, and observe which of these fire (Text Commands log).") + + +def _accessor_kinds(c) -> list[str]: + """Return ['name=Type', ...] for every accessor on c that yields something.""" + names = ( + "line", "lineOne", "lineTwo", + "point", "pointOne", "pointTwo", + "entity", "entityOne", "entityTwo", + "curveOne", "curveTwo", + "midPointCurve", "symmetryLine", + "surface", "planarSurface", + "distance", "centerSketchPoint", + "parentCurves", "childCurves", "lines", + ) + out = [] + for n in names: + if not hasattr(c, n): + continue + try: + v = getattr(c, n) + except Exception as e: + out.append(f"{n}=") + continue + if v is None: + out.append(f"{n}=None") + else: + out.append(f"{n}={type(v).__name__}") + return out + + +def _pick_probe_sketch(design: adsk.fusion.Design) -> adsk.fusion.Sketch | None: + sketch = adsk.fusion.Sketch.cast(design.activeEditObject) + if sketch: + return sketch + # Prefer the fixture, fall back to first sketch in root with constraints. + root = design.rootComponent + for i in range(root.sketches.count): + s = root.sketches.item(i) + if s.name == "ConstraintLens_Fixture": + return s + for i in range(root.sketches.count): + s = root.sketches.item(i) + if s.geometricConstraints.count > 0: + return s + return root.sketches.item(0) if root.sketches.count else None + + +def probe_q5_constraint_inventory(app: adsk.core.Application, out: list[str]) -> None: + """Open question 5 + general inventory.""" + out.append("") + out.append("=" * 72) + out.append("Q5 GeometricConstraint inventory on probe sketch") + out.append("=" * 72) + design = adsk.fusion.Design.cast(app.activeProduct) + if not design: + out.append(" no active design") + return + sketch = _pick_probe_sketch(design) + if not sketch: + out.append(" no usable sketch found — run fixture_sketch.py first") + return + edit_obj = design.activeEditObject + being_edited = isinstance(edit_obj, adsk.fusion.Sketch) and edit_obj.entityToken == sketch.entityToken + out.append(f" sketch: {sketch.name!r} (being edited: {being_edited})") + out.append(f" isFullyConstrained: {sketch.isFullyConstrained}") + try: + out.append(f" healthState: {sketch.healthState}") + out.append(f" errorOrWarningMessage: {sketch.errorOrWarningMessage!r}") + except Exception as exc: + out.append(f" health probe raised: {exc}") + + out.append("") + out.append(" geometricConstraints:") + seen: set[str] = set() + gc = sketch.geometricConstraints + for i in range(gc.count): + c = gc.item(i) + seen.add(c.objectType) + try: + deletable = c.isDeletable + except Exception: + deletable = "?" + accessors = ", ".join(_accessor_kinds(c)) or "" + out.append(f" [{i:>2}] {c.objectType:55} del={deletable} {{ {accessors} }}") + out.append(f" distinct objectTypes: {sorted(seen)}") + + out.append("") + out.append(" sketchDimensions:") + dims = sketch.sketchDimensions + for i in range(dims.count): + d = dims.item(i) + try: + expr = d.parameter.expression + except Exception as e: + expr = f"" + out.append(f" [{i:>2}] {d.objectType:55} expr={expr!r}") + + out.append("") + out.append(" sketchPoints with connectedEntities.count > 1 (implicit joins):") + sp = sketch.sketchPoints + join_count = 0 + for i in range(sp.count): + p = sp.item(i) + try: + n = p.connectedEntities.count + except Exception: + n = -1 + if n > 1: + join_count += 1 + kinds = [] + for j in range(min(n, 6)): + kinds.append(type(p.connectedEntities.item(j)).__name__) + out.append(f" [{i:>2}] connects {n}: {kinds}") + out.append(f" total implicit-join points: {join_count}") + + +def probe_q4_token(app: adsk.core.Application, out: list[str]) -> None: + """Open question 4 — capture a constraint entityToken for save-reload test.""" + out.append("") + out.append("=" * 72) + out.append("Q4 Capture constraint entityToken for save-reload stability test") + out.append("=" * 72) + design = adsk.fusion.Design.cast(app.activeProduct) + if not design: + out.append(" no design") + return + sketch = _pick_probe_sketch(design) + if not sketch or sketch.geometricConstraints.count == 0: + out.append(" no sketch with constraints — run fixture_sketch.py first") + return + c = sketch.geometricConstraints.item(0) + try: + token = c.entityToken + except Exception as exc: + out.append(f" c.entityToken raised: {exc}") + return + out.append(f" picked: constraint [0] of {sketch.name!r} type={c.objectType}") + out.append(f" entityToken (len={len(token)}):") + out.append(f" {token}") + try: + resolved = design.findEntityByToken(token) + n = len(resolved) if resolved else 0 + first_kind = type(resolved[0]).__name__ if n else "" + out.append(f" same-session resolve: count={n} first_type={first_kind}") + except Exception as exc: + out.append(f" same-session resolve raised: {exc}") + out.append("") + out.append(" ACTION FOR DEVELOPER:") + out.append(" 1. Save the document.") + out.append(" 2. Close the document tab; reopen it.") + out.append(" 3. Re-run this probe.") + out.append(" 4. Paste the entityToken above into a Text Commands cell:") + out.append(" > Python: app.activeProduct.findEntityByToken('')") + out.append(" — record whether it returns the same constraint.") + + +# --- Entry point ---------------------------------------------------------- + + +def run(context): + ui = None + try: + app = adsk.core.Application.get() + ui = app.userInterface + out: list[str] = [] + out.append("ConstraintLens spike probe — paste the contents of the") + out.append("temp file (path shown at the end) back to the developer.") + out.append("") + probe_q1_panels(ui, out) + probe_q2_underconstrained(app, out) + probe_q3_palette_events(out) + probe_q5_constraint_inventory(app, out) + probe_q4_token(app, out) + + text = "\n".join(out) + out_path = os.path.join(tempfile.gettempdir(), "constraintlens_probe.txt") + try: + with open(out_path, "w", encoding="utf-8") as f: + f.write(text) + preview = text if len(text) <= 1500 else text[:1500] + "\n...[truncated; see full file]" + ui.messageBox( + f"Probe complete.\n\nFull output written to:\n{out_path}\n\n" + f"--- preview ---\n\n{preview}" + ) + except Exception: + ui.messageBox( + "Probe complete (could not write temp file):\n\n" + text[:3000] + ) + except Exception: + if ui: + ui.messageBox("Probe failed:\n" + traceback.format_exc())