From f21530caeee553324da414467a1c0b3e6c2555d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:41:06 +0000 Subject: [PATCH 01/22] Add ConstraintLens architectural spec Comprehensive SPEC.md covering MVP scope, folder structure, module responsibilities, exhaustive constraint type dispatch table, event handler GC pattern, palette message contract, fixture sketch, known API landmines, and open questions to validate in Fusion. --- SPEC.md | 517 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 SPEC.md diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e915108 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,517 @@ +# 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 + +Maximum five, each with a proposed validation approach. These cannot be resolved without running code inside Fusion. + +1. **Exact panel id for the Sketch toolbar button.** Doc 2 mentions placement under the "Inspect" panel of the Solid workspace, but the canonical id (e.g. `SolidScriptsAddinsPanel` vs `SketchInspectPanel`) needs verification. **Validation:** in the Text Commands window, run `Commands.GetItemList` and grep for sketch panels; pick the one that shows when in sketch edit mode. + +2. **Whether `executeTextCommand("Sketch.ShowUnderconstrained")` requires an active sketch edit context.** Doc 2 cites `bachi.net` for the text-command output but does not confirm the precondition. **Validation:** call the command via `app.executeTextCommand` outside sketch edit; observe whether it returns the count text, an error string, or raises. Wrap accordingly. + +3. **Refresh strategy when `palette.isVisible == False`.** UP-38529 (M-8) suggests skipping pushes when not visible — but does the palette emit a `shown` event we can hook to push a delayed refresh? **Validation:** trace `palette.*` event firing during minimize/restore and document the cycle. + +4. **Stability of `entityToken` for `GeometricConstraint` objects across save-reload.** Doc 1 implies general stability via `Design.findEntityByToken`; doc 2 does not confirm this specifically for constraints. **Validation:** capture a constraint's token, save and reload the document, attempt `findEntityByToken(token)`; if it returns null, fall back to rowKey-by-position (less robust — would change `messaging.py` schema). + +5. **Whether `VerticalConstraint` is actually surfaced by the API or only created implicitly.** Neither research doc explicitly lists it as a `GeometricConstraint` subclass — but `GeometricConstraints.addVertical(line)` is documented. **Validation:** the fixture sketch (section 8) creates one via `addVertical`; the spike script then iterates `sketch.geometricConstraints` and prints each `objectType` — confirm `"adsk::fusion::VerticalConstraint"` appears. If not, update the dispatch table to mark it as "creation-only, never enumerated." + +--- + +# Three questions for the developer to answer before implementation starts + +These are decisions only the project owner can make; each would meaningfully change the spec. + +1. **Target Fusion version & Python: latest only, or compat with older installs?** + The January 2026 Fusion bumps Python from 3.12 to 3.14 and standardizes on the Qt Web Browser palette backend. If MVP targets the January 2026 build only, `Palettes.add(..., useQtWebBrowser=True)` is unconditional and we can use 3.12+ typing syntax (`list[...]`, `X | None`). If we need to support 2024-era installs, we must keep `from __future__ import annotations`, use `Optional[X]`, and gate `useQtWebBrowser` behind a version check. **Default if unanswered: latest only.** + +2. **Distribution intent: Autodesk App Store, or GitHub Releases only?** + App Store submission requires a signed installer (.msi for Windows, .pkg for macOS), screenshots, help URL, and a manual review (weeks of latency). GitHub Releases is a zip with an install snippet — fastest path, broadest dev freedom, no review. If App Store is the goal, we need to add `installer/` and `store_assets/` directories to the structure (section 3) and budget the 80–150-hour v1 effort accordingly. **Default if unanswered: GitHub Releases for MVP, App Store as a v1+ milestone.** + +3. **Workspace coverage: Solid-workspace sketches only for MVP, or include Sheet Metal / Form / Surface / Drawing sketches?** + All workspaces use the same `Sketch` class so the *scanner* code does not change, but the toolbar button placement does — each workspace needs its own command registration. Including all four workspaces adds maybe 30 lines of `lifecycle.py` plus per-workspace QA. **Default if unanswered: Solid only for MVP; document the others as a v1 polish item.** From de15db01cb628a8cf37aae9162989522ed8b0780 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:44:27 +0000 Subject: [PATCH 02/22] Lock spec decisions: latest Fusion only, GitHub Releases, Solid only Replace the three deferred forks at the end of SPEC.md with confirmed decisions and the concrete implementation rules each one implies. --- SPEC.md | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/SPEC.md b/SPEC.md index e915108..b508ac9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -503,15 +503,25 @@ Maximum five, each with a proposed validation approach. These cannot be resolved --- -# Three questions for the developer to answer before implementation starts - -These are decisions only the project owner can make; each would meaningfully change the spec. - -1. **Target Fusion version & Python: latest only, or compat with older installs?** - The January 2026 Fusion bumps Python from 3.12 to 3.14 and standardizes on the Qt Web Browser palette backend. If MVP targets the January 2026 build only, `Palettes.add(..., useQtWebBrowser=True)` is unconditional and we can use 3.12+ typing syntax (`list[...]`, `X | None`). If we need to support 2024-era installs, we must keep `from __future__ import annotations`, use `Optional[X]`, and gate `useQtWebBrowser` behind a version check. **Default if unanswered: latest only.** - -2. **Distribution intent: Autodesk App Store, or GitHub Releases only?** - App Store submission requires a signed installer (.msi for Windows, .pkg for macOS), screenshots, help URL, and a manual review (weeks of latency). GitHub Releases is a zip with an install snippet — fastest path, broadest dev freedom, no review. If App Store is the goal, we need to add `installer/` and `store_assets/` directories to the structure (section 3) and budget the 80–150-hour v1 effort accordingly. **Default if unanswered: GitHub Releases for MVP, App Store as a v1+ milestone.** - -3. **Workspace coverage: Solid-workspace sketches only for MVP, or include Sheet Metal / Form / Surface / Drawing sketches?** - All workspaces use the same `Sketch` class so the *scanner* code does not change, but the toolbar button placement does — each workspace needs its own command registration. Including all four workspaces adds maybe 30 lines of `lifecycle.py` plus per-workspace QA. **Default if unanswered: Solid only for MVP; document the others as a v1 polish item.** +# 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 the Solid workspace only (panel id to be confirmed per open question 1). 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. From 7151cd7498e8098a9af1e3583439b6ff67aefc69 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:52:52 +0000 Subject: [PATCH 03/22] Add fixture and spike-probe scripts fixture_sketch.py builds the deterministic test sketch from SPEC.md section 8 (rectangle + circle + 4 explicit constraints + 2 dimensions). spike_probe.py answers the five open questions from SPEC.md section 10 in one shot: enumerates the current workspace's panel ids, probes Sketch.ShowUnderconstrained inside vs outside sketch edit, introspects Palette event surface, dumps the full GeometricConstraint inventory with accessor types per row, and captures an entityToken for the save-reload stability test. Output is written to the OS temp dir and previewed in a message box so the user can paste it back verbatim. --- tests/fixture_sketch.py | 70 +++++++++++ tests/spike_probe.py | 272 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 tests/fixture_sketch.py create mode 100644 tests/spike_probe.py diff --git a/tests/fixture_sketch.py b/tests/fixture_sketch.py new file mode 100644 index 0000000..0f5f0f4 --- /dev/null +++ b/tests/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.py b/tests/spike_probe.py new file mode 100644 index 0000000..09aceb9 --- /dev/null +++ b/tests/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()) From 87bf2eab5d6c98afb48a031bb484b8a7f62d381f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 10:58:26 +0000 Subject: [PATCH 04/22] Scaffold ConstraintLens add-in (MVP code, awaiting PC validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements every module specified in SPEC.md sections 3-7: - ConstraintLens.py / .manifest — Fusion add-in entry point with sys.path bootstrap. - lib/lifecycle.py — command button, palette creation, all event wiring, JS->PY action handlers (selectEntities, selectConstraint, deleteConstraint, requestRefresh, paletteReady). - lib/events.py — GC-safe handler registry (landmine M-7) plus DocumentActivated / CommandTerminated / Palette incoming + closed subscriptions. - lib/dispatch.py — full 21-row constraint descriptor table with per-subtype label/chip builders; landmines M-1 (MidPoint .point guarded), M-2 (pattern stubs marked read-only) folded in. Includes a parallel dimension-kind table. - lib/scanner.py — sketch enumeration: geometric constraints, dimensions, and reconstructed implicit coincident joins (M-3). - lib/labels.py — per-sketch (token -> "Line 3") map indexed across lines/points/circles/arcs/ellipses/splines. - lib/selection.py / actions.py / tokens.py / messaging.py — viewport selection, delete via entityToken resolve, JSON wire format with isVisible gating (M-8). - palette/index.html + app.js + styles.css — vanilla-JS, no-build single-page UI matching SPEC section 7's message contract. Dark theme matches Fusion. - README.md — install + verify workflow pointing at the spike probe. Open items pending the spike-probe output: SPEC open-question 1 (_PANEL_ID is currently SolidScriptsAddinsPanel as the safe default; will move to a sketch-specific panel once the probe confirms the id) and 5 (VerticalConstraint enumeration verification). Notes: - palette/ lives inside ConstraintLens/ (the SPEC's "shipped layout"), consolidating the dev/ship split for MVP — no copy step needed. - Resource icons are not committed; Fusion falls back to a default glyph for the toolbar button until artwork is added. --- ConstraintLens/ConstraintLens.manifest | 13 + ConstraintLens/ConstraintLens.py | 33 +++ ConstraintLens/lib/__init__.py | 0 ConstraintLens/lib/actions.py | 36 +++ ConstraintLens/lib/dispatch.py | 362 +++++++++++++++++++++++++ ConstraintLens/lib/events.py | 108 ++++++++ ConstraintLens/lib/labels.py | 74 +++++ ConstraintLens/lib/lifecycle.py | 322 ++++++++++++++++++++++ ConstraintLens/lib/messaging.py | 46 ++++ ConstraintLens/lib/scanner.py | 165 +++++++++++ ConstraintLens/lib/selection.py | 29 ++ ConstraintLens/lib/tokens.py | 23 ++ ConstraintLens/palette/app.js | 276 +++++++++++++++++++ ConstraintLens/palette/index.html | 20 ++ ConstraintLens/palette/styles.css | 212 +++++++++++++++ README.md | 54 +++- 16 files changed, 1772 insertions(+), 1 deletion(-) create mode 100644 ConstraintLens/ConstraintLens.manifest create mode 100644 ConstraintLens/ConstraintLens.py create mode 100644 ConstraintLens/lib/__init__.py create mode 100644 ConstraintLens/lib/actions.py create mode 100644 ConstraintLens/lib/dispatch.py create mode 100644 ConstraintLens/lib/events.py create mode 100644 ConstraintLens/lib/labels.py create mode 100644 ConstraintLens/lib/lifecycle.py create mode 100644 ConstraintLens/lib/messaging.py create mode 100644 ConstraintLens/lib/scanner.py create mode 100644 ConstraintLens/lib/selection.py create mode 100644 ConstraintLens/lib/tokens.py create mode 100644 ConstraintLens/palette/app.js create mode 100644 ConstraintLens/palette/index.html create mode 100644 ConstraintLens/palette/styles.css diff --git a/ConstraintLens/ConstraintLens.manifest b/ConstraintLens/ConstraintLens.manifest new file mode 100644 index 0000000..cf385d8 --- /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.0", + "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..57233fe --- /dev/null +++ b/ConstraintLens/lib/dispatch.py @@ -0,0 +1,362 @@ +# 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 dataclasses import dataclass, field +from typing import Callable + +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") + l = 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} — {l}", 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 _b_offset(c, lab): + parent = _safe(lambda: c.parentCurves) + child = _safe(lambda: c.childCurves) + distance = _safe(lambda: c.distance) + n = parent.count if parent is not None else 0 + m = child.count if child is not None else 0 + expr = "?" + try: + if distance is not None: + expr = distance.expression + except Exception: + expr = "?" + return ScanResult( + f"Offset {n}→{m} curves @ {expr}", + [], # Collections, not single entities — counts surface in label. + [], + ) + + +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) ------------------------ + + +_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 describe_dimension(dim, lab: EntityLabeler) -> ScanResult: + obj_type = getattr(dim, "objectType", "") + kind_label = _DIMENSION_KINDS.get(obj_type, obj_type.split("::")[-1] or "Dimension") + e1 = _safe(lambda: dim.entityOne) + e2 = _safe(lambda: dim.entityTwo) + 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..00bb18b --- /dev/null +++ b/ConstraintLens/lib/events.py @@ -0,0 +1,108 @@ +# 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 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..80dfcf5 --- /dev/null +++ b/ConstraintLens/lib/labels.py @@ -0,0 +1,74 @@ +# 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: + return { + "token": _safe_token(entity) or "", + "kind": self.kind_for(entity), + "label": self.label_for(entity), + } diff --git a/ConstraintLens/lib/lifecycle.py b/ConstraintLens/lib/lifecycle.py new file mode 100644 index 0000000..b844c2e --- /dev/null +++ b/ConstraintLens/lib/lifecycle.py @@ -0,0 +1,322 @@ +# 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" + +# Per SPEC.md open question 1: the exact panel id is verified at runtime. +# SolidScriptsAddinsPanel exists in every install; the spike probe enumerates +# alternatives (e.g. SketchInspectPanel) for a later relocation. +_PANEL_ID = "SolidScriptsAddinsPanel" + + +# 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: + app = adsk.core.Application.get() + ui = app.userInterface + + 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 + + on_created = _CommandCreatedHandler() + cmd_def.commandCreated.add(on_created) + events._handlers.append(on_created) # pin against GC + events._subscriptions.append((cmd_def.commandCreated, on_created)) + + +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) + _button_control.isPromotedByDefault = True + _button_control.isPromoted = True + + +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) + ) + _palette.dockingState = adsk.core.PaletteDockingStates.PaletteDockStateRight + + 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 + + # 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: + row_key = payload.get("rowKey") or "" + sketch = scanner.active_sketch(app) + if sketch is None: + return + design = adsk.fusion.Design.cast(app.activeProduct) + + # Implicit-join rows: rowKey starts with "join:" + sketchPoint token. + 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 + ents = _entities_for_row(constraint) + selection.select_entities(app.userInterface, ents) + + +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 _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 on Offset/Polygon. + for coll_name in ("parentCurves", "childCurves", "lines"): + if not hasattr(constraint, coll_name): + continue + try: + coll = getattr(constraint, coll_name) + 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..71235f9 --- /dev/null +++ b/ConstraintLens/lib/messaging.py @@ -0,0 +1,46 @@ +# 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" + +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..3932163 --- /dev/null +++ b/ConstraintLens/lib/scanner.py @@ -0,0 +1,165 @@ +# 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) + 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) + 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..e500596 --- /dev/null +++ b/ConstraintLens/palette/app.js @@ -0,0 +1,276 @@ +// 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", + }; + + 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, + }; + + const els = { + root: document.getElementById("root"), + status: document.getElementById("status"), + refresh: document.getElementById("refresh"), + }; + + // --- 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, {})); + + // --- 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 onData(payload) { + state.snapshot = payload; + renderSnapshot(); + } + + function onNoActiveSketch(payload) { + state.snapshot = null; + 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); + } + + // --- 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 parts = []; + const c = snap.constraints || []; + const d = snap.dimensions || []; + const j = snap.implicitJoins || []; + + if (c.length === 0 && d.length === 0 && j.length === 0) { + parts.push(`
This sketch has no constraints or dimensions yet.
`); + } + + if (c.length) { + parts.push(`
Geometric constraints (${c.length})
`); + for (const row of c) parts.push(rowHTML(row)); + } + if (d.length) { + parts.push(`
Dimensions (${d.length})
`); + for (const row of d) parts.push(rowHTML(row)); + } + if (j.length) { + parts.push(`
Endpoint joins (${j.length})
`); + 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("; ")}
` + : ""; + + const deleteDisabled = !row.isDeletable ? "disabled" : ""; + const selectConstraintBtn = row.isPseudo + ? "" + : ``; + + return ` +
+
${escape(glyph)}
+
+
${escape(row.label || "")}
+
+ ${escape(row.kind || "")} + ${badges.join("")} +
+ ${chips ? `
${chips}
` : ""} + ${errorsHTML} +
+
+ ${selectConstraintBtn} + +
+
+ `; + } + + function chipHTML(chip) { + return `${escape(chip.label || chip.kind || "?")}`; + } + + // --- Delegated click handling --------------------------------------- + + els.root.addEventListener("click", (evt) => { + const actionEl = evt.target.closest("[data-action]"); + if (!actionEl) return; + const action = actionEl.getAttribute("data-action"); + + if (action === "deleteConstraint") { + evt.stopPropagation(); + const token = actionEl.getAttribute("data-token") || ""; + if (!token) return; + send(JS_TO_PY.deleteConstraint, { token }); + return; + } + + 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; + send(JS_TO_PY.selectEntities, { rowKey }); + 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 ---------------------------------------------------------- + + document.addEventListener("DOMContentLoaded", () => { + state.loaded = true; + send(JS_TO_PY.paletteReady, {}); + }); +})(); diff --git a/ConstraintLens/palette/index.html b/ConstraintLens/palette/index.html new file mode 100644 index 0000000..811528b --- /dev/null +++ b/ConstraintLens/palette/index.html @@ -0,0 +1,20 @@ + + + + + Constraint Lens + + + +
+
Loading…
+ +
+ +
+
Loading…
+
+ + + + diff --git a/ConstraintLens/palette/styles.css b/ConstraintLens/palette/styles.css new file mode 100644 index 0000000..5d346cc --- /dev/null +++ b/ConstraintLens/palette/styles.css @@ -0,0 +1,212 @@ +/* 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; +} + +.row-actions .btn { padding: 2px 7px; font-size: 10px; } + +.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); } diff --git a/README.md b/README.md index 6b50884..1d502b4 100644 --- a/README.md +++ b/README.md @@ -1 +1,53 @@ -# FusionConstraints \ No newline at end of file +# ConstraintLens + +A Fusion 360 add-in that docks a panel listing every constraint in the active sketch — 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 architectural specification. + +## Status + +Pre-MVP scaffold. The full module structure, dispatch table, and palette UI are in place; runtime verification of the five open questions in `SPEC.md` section 10 is still pending. + +## Install (development) + +ConstraintLens requires Fusion 360 (January 2026 release or later, Python 3.14). + +1. Clone this repository. +2. Copy or symlink the `ConstraintLens/` folder into your Fusion add-ins directory: + - **Windows**: `%APPDATA%\Autodesk\Autodesk Fusion 360\API\AddIns\` + - **macOS**: `~/Library/Application Support/Autodesk/Autodesk Fusion 360/API/AddIns/` +3. In Fusion: **Tools → Scripts and Add-Ins → Add-Ins** tab. Select **ConstraintLens** and click **Run**. Tick *Run on Startup* if you want it loaded automatically. +4. The **Constraint Lens** button appears in **Solid → Tools → Scripts and Add-Ins** (panel id is verified at runtime via the spike probe; see below). + +## Verifying the install + +Before relying on ConstraintLens, run the test scripts under `tests/`: + +1. **Fixture** — Tools → Scripts and Add-Ins → **Scripts** tab → **+** → point at `tests/fixture_sketch.py` → **Run**. Creates a deterministic sketch named `ConstraintLens_Fixture` with 4 explicit constraints, 2 dimensions, and 4 implicit endpoint joins. +2. **Spike probe** — same workflow with `tests/spike_probe.py`. Open the fixture sketch for edit first. The probe writes a full report to your OS temp directory (`constraintlens_probe.txt`) and previews it in a message box; paste that file contents back to the developer to validate the five open questions in `SPEC.md` section 10. + +## Folder structure + +``` +FusionConstraints/ +├── SPEC.md Architectural spec — read first. +├── ConstraintLens/ The Fusion add-in (drop this into AddIns/). +│ ├── ConstraintLens.manifest +│ ├── ConstraintLens.py +│ ├── lib/ Python modules (see SPEC.md section 4). +│ └── palette/ HTML/JS/CSS palette UI. +└── tests/ + ├── fixture_sketch.py Deterministic test sketch. + └── spike_probe.py API-feasibility probe (run once per Fusion update). +``` + +## Known limitations + +- No granular CAD undo for `Delete` actions in MVP — 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 expose only `Delete` (the API exposes no usable accessors). +- Assembly-level `AssemblyConstraint` (Constrain Components, January 2026 preview API) is intentionally not supported; revisit when Autodesk drops the preview disclaimer. + +## License + +TBD. From 6e7d418fe6d4e43329a7b800135abb337e2f744c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 11:28:08 +0000 Subject: [PATCH 05/22] Harden scaffold, add M-1 fixture, PC validation checklist, MIT license Hardening (caught by ruff + self-review): - dispatch.py: rename ambiguous 'l' variable; import Callable from collections.abc (Python 3.14 preferred form). - lifecycle.py: drop unused 'ui' assignment in stop(); wrap risky property writes (palette.dockingState, button.isPromoted) in try/except since some Fusion builds make these conditional. - events.py: add a public pin(event, handler) helper so other modules no longer need to poke events._handlers / _subscriptions directly; lifecycle's CommandCreated registration uses it. New artifacts: - tests/fixture_midpoint.py builds the canonical M-1 trigger (one sketch point constrained as midpoint of two lines). Lets the user verify the .point defensive guard at PC time. - PC_VALIDATION.md is the step-by-step checklist for the first PC session: install, run the spike probe, smoke-test the panel against both fixtures, exercise the landmines, and report back. - LICENSE settles the README "TBD" with MIT. All Python passes ruff (E/F/W/B/UP) and `python3 -m py_compile`. --- ConstraintLens/lib/dispatch.py | 6 +- ConstraintLens/lib/events.py | 13 ++++ ConstraintLens/lib/lifecycle.py | 22 +++---- LICENSE | 20 +++++++ PC_VALIDATION.md | 103 ++++++++++++++++++++++++++++++++ README.md | 2 +- tests/fixture_midpoint.py | 58 ++++++++++++++++++ 7 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 LICENSE create mode 100644 PC_VALIDATION.md create mode 100644 tests/fixture_midpoint.py diff --git a/ConstraintLens/lib/dispatch.py b/ConstraintLens/lib/dispatch.py index 57233fe..636af68 100644 --- a/ConstraintLens/lib/dispatch.py +++ b/ConstraintLens/lib/dispatch.py @@ -5,8 +5,8 @@ # 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 typing import Callable from .labels import EntityLabeler @@ -68,7 +68,7 @@ def _line_on_surface(c, lab, kind_label: str) -> ScanResult: errors.append("accessor unavailable: .line") if surface is None: errors.append("accessor unavailable: .planarSurface") - l = lab.label_for(line) if line else "" + 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. @@ -78,7 +78,7 @@ def _line_on_surface(c, lab, kind_label: str) -> ScanResult: except Exception: surface_label = "external surface" chips.append({"token": "", "kind": "BRepFace", "label": surface_label}) - return ScanResult(f"{kind_label} — {l}", chips, errors) + return ScanResult(f"{kind_label} — {line_label}", chips, errors) # --- Builders, in dispatch-table order ---------------------------------- diff --git a/ConstraintLens/lib/events.py b/ConstraintLens/lib/events.py index 00bb18b..20ed2e0 100644 --- a/ConstraintLens/lib/events.py +++ b/ConstraintLens/lib/events.py @@ -61,6 +61,19 @@ def notify(self, args): 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) diff --git a/ConstraintLens/lib/lifecycle.py b/ConstraintLens/lib/lifecycle.py index b844c2e..7a821ef 100644 --- a/ConstraintLens/lib/lifecycle.py +++ b/ConstraintLens/lib/lifecycle.py @@ -44,9 +44,6 @@ def start(addin_dir: str) -> None: def stop() -> None: - app = adsk.core.Application.get() - ui = app.userInterface - events.unregister_all() global _palette, _button_control, _command_definition @@ -88,10 +85,7 @@ def _ensure_command(app: adsk.core.Application, ui: adsk.core.UserInterface) -> ) _command_definition = cmd_def - on_created = _CommandCreatedHandler() - cmd_def.commandCreated.add(on_created) - events._handlers.append(on_created) # pin against GC - events._subscriptions.append((cmd_def.commandCreated, on_created)) + events.pin(cmd_def.commandCreated, _CommandCreatedHandler()) def _ensure_button(ui: adsk.core.UserInterface) -> None: @@ -112,8 +106,12 @@ def _ensure_button(ui: adsk.core.UserInterface) -> None: if existing is not None: existing.deleteMe() _button_control = panel.controls.addCommand(_command_definition) - _button_control.isPromotedByDefault = True - _button_control.isPromoted = True + 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): @@ -166,7 +164,11 @@ def _show_palette() -> None: 600, # height True, # useNewWebBrowser (Qt — required per locked decision) ) - _palette.dockingState = adsk.core.PaletteDockingStates.PaletteDockStateRight + 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) 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..d9c9e64 --- /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 `tests/fixture_sketch.py` → **Run**. A message box confirms the fixture was created with 4 constraints + 2 dimensions. +- [ ] **Double-click** the new `ConstraintLens_Fixture` sketch in the browser to enter sketch edit. +- [ ] Back to Scripts tab → **+** → point at `tests/spike_probe.py` → **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 + +- [ ] Run `tests/fixture_midpoint.py`. 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 1d502b4..23ea43a 100644 --- a/README.md +++ b/README.md @@ -50,4 +50,4 @@ FusionConstraints/ ## License -TBD. +MIT — see [`LICENSE`](./LICENSE). diff --git a/tests/fixture_midpoint.py b/tests/fixture_midpoint.py new file mode 100644 index 0000000..2b795fc --- /dev/null +++ b/tests/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()) From b54d817b63d7acd78bc9b6fa53774a42cd711204 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 19:22:22 +0000 Subject: [PATCH 06/22] Fix OffsetConstraint accessor crash, auto-load timing, and script naming - dispatch.py: OffsetConstraint.parentCurves/.childCurves return SketchCurveVector (no .count attr); switch to len() with try/except. This eliminates the ACCESSOR error badge shown in PC test 1. - lifecycle.py: _entities_for_row() iterated collections with .count + .item(i); replaced with direct iteration so SketchCurveVector is also handled without error. - palette/app.js: paletteReady was sent on DOMContentLoaded, but adsk.fusionSendData is injected by Fusion asynchronously after the page loads. Added _sendWhenReady() which polls every 100 ms (up to 5 s) until the bridge is available, fixing the "Loading..." stuck state that required a manual Refresh click. - tests/: Fusion requires each script to live in a same-named subfolder (e.g. fixture_sketch/fixture_sketch.py). Moved all three test scripts into their own folders; updated README and PC_VALIDATION paths. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- ConstraintLens/lib/dispatch.py | 12 ++++++++++-- ConstraintLens/lib/lifecycle.py | 14 ++++++++++++-- ConstraintLens/palette/app.js | 19 ++++++++++++++++++- PC_VALIDATION.md | 6 +++--- README.md | 9 +++++---- .../fixture_midpoint.py | 0 tests/{ => fixture_sketch}/fixture_sketch.py | 0 tests/{ => spike_probe}/spike_probe.py | 0 8 files changed, 48 insertions(+), 12 deletions(-) rename tests/{ => fixture_midpoint}/fixture_midpoint.py (100%) rename tests/{ => fixture_sketch}/fixture_sketch.py (100%) rename tests/{ => spike_probe}/spike_probe.py (100%) diff --git a/ConstraintLens/lib/dispatch.py b/ConstraintLens/lib/dispatch.py index 636af68..93139d3 100644 --- a/ConstraintLens/lib/dispatch.py +++ b/ConstraintLens/lib/dispatch.py @@ -204,8 +204,16 @@ def _b_offset(c, lab): parent = _safe(lambda: c.parentCurves) child = _safe(lambda: c.childCurves) distance = _safe(lambda: c.distance) - n = parent.count if parent is not None else 0 - m = child.count if child is not None else 0 + # parentCurves / childCurves return SketchCurveVector, not ObjectCollection. + # SketchCurveVector supports len() and iteration but not .count. + try: + n = len(parent) if parent is not None else 0 + except Exception: + n = 0 + try: + m = len(child) if child is not None else 0 + except Exception: + m = 0 expr = "?" try: if distance is not None: diff --git a/ConstraintLens/lib/lifecycle.py b/ConstraintLens/lib/lifecycle.py index 7a821ef..9e94fbb 100644 --- a/ConstraintLens/lib/lifecycle.py +++ b/ConstraintLens/lib/lifecycle.py @@ -312,13 +312,23 @@ def _entities_for_row(constraint) -> list: if v is not None: candidates.append(v) # Collections: parentCurves / childCurves / lines on Offset/Polygon. + # parentCurves / childCurves are SketchCurveVector (supports iteration + len, + # not .count); lines on PolygonConstraint is ObjectCollection (.count + .item). for coll_name in ("parentCurves", "childCurves", "lines"): if not hasattr(constraint, coll_name): continue try: coll = getattr(constraint, coll_name) - for i in range(coll.count): - candidates.append(coll.item(i)) + if coll is None: + continue + # Try direct iteration first (works for both SketchCurveVector and + # ObjectCollection); fall back to .item(i) loop if that fails. + 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/palette/app.js b/ConstraintLens/palette/app.js index e500596..99a9f66 100644 --- a/ConstraintLens/palette/app.js +++ b/ConstraintLens/palette/app.js @@ -268,9 +268,26 @@ } // --- 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; - send(JS_TO_PY.paletteReady, {}); + _sendWhenReady(JS_TO_PY.paletteReady, {}); }); })(); diff --git a/PC_VALIDATION.md b/PC_VALIDATION.md index d9c9e64..b972623 100644 --- a/PC_VALIDATION.md +++ b/PC_VALIDATION.md @@ -31,9 +31,9 @@ Each step has a **pass** criterion and a **failure → action** path. Paste the 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 `tests/fixture_sketch.py` → **Run**. A message box confirms the fixture was created with 4 constraints + 2 dimensions. +- [ ] **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 `tests/spike_probe.py` → **Run**. +- [ ] 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. @@ -80,7 +80,7 @@ What the probe answers, mapped back to `SPEC.md` open questions: ## 5. Exercise the landmine guards -- [ ] Run `tests/fixture_midpoint.py`. It creates `ConstraintLens_Midpoint_M1` with two midpoint constraints sharing the same sketch point — the canonical M-1 trigger configuration. +- [ ] **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: diff --git a/README.md b/README.md index 23ea43a..e9947c2 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ ConstraintLens requires Fusion 360 (January 2026 release or later, Python 3.14). Before relying on ConstraintLens, run the test scripts under `tests/`: -1. **Fixture** — Tools → Scripts and Add-Ins → **Scripts** tab → **+** → point at `tests/fixture_sketch.py` → **Run**. Creates a deterministic sketch named `ConstraintLens_Fixture` with 4 explicit constraints, 2 dimensions, and 4 implicit endpoint joins. -2. **Spike probe** — same workflow with `tests/spike_probe.py`. Open the fixture sketch for edit first. The probe writes a full report to your OS temp directory (`constraintlens_probe.txt`) and previews it in a message box; paste that file contents back to the developer to validate the five open questions in `SPEC.md` section 10. +1. **Fixture** — Tools → Scripts and Add-Ins → **Scripts** tab → **+** → point at `tests/fixture_sketch/` → **Run**. Creates a deterministic sketch named `ConstraintLens_Fixture` with 4 explicit constraints, 2 dimensions, and 4 implicit endpoint joins. +2. **Spike probe** — same workflow, point at `tests/spike_probe/`. Open the fixture sketch for edit first. The probe writes a full report to your OS temp directory (`constraintlens_probe.txt`) and previews it in a message box; paste that file contents back to the developer to validate the five open questions in `SPEC.md` section 10. ## Folder structure @@ -37,8 +37,9 @@ FusionConstraints/ │ ├── lib/ Python modules (see SPEC.md section 4). │ └── palette/ HTML/JS/CSS palette UI. └── tests/ - ├── fixture_sketch.py Deterministic test sketch. - └── spike_probe.py API-feasibility probe (run once per Fusion update). + ├── fixture_sketch/ Deterministic test sketch (point Fusion Scripts at this folder). + ├── fixture_midpoint/ M-1 landmine trigger fixture. + └── spike_probe/ API-feasibility probe (run once per Fusion update). ``` ## Known limitations diff --git a/tests/fixture_midpoint.py b/tests/fixture_midpoint/fixture_midpoint.py similarity index 100% rename from tests/fixture_midpoint.py rename to tests/fixture_midpoint/fixture_midpoint.py diff --git a/tests/fixture_sketch.py b/tests/fixture_sketch/fixture_sketch.py similarity index 100% rename from tests/fixture_sketch.py rename to tests/fixture_sketch/fixture_sketch.py diff --git a/tests/spike_probe.py b/tests/spike_probe/spike_probe.py similarity index 100% rename from tests/spike_probe.py rename to tests/spike_probe/spike_probe.py From 01e16a057572198ef79918cb75fab25fdd82019c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 19:27:15 +0000 Subject: [PATCH 07/22] Bump version to 0.1.2 for second test session https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- ConstraintLens/ConstraintLens.manifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ConstraintLens/ConstraintLens.manifest b/ConstraintLens/ConstraintLens.manifest index cf385d8..ae137b4 100644 --- a/ConstraintLens/ConstraintLens.manifest +++ b/ConstraintLens/ConstraintLens.manifest @@ -6,7 +6,7 @@ "description": { "": "Docked panel listing every sketch constraint in the active sketch — click to select, delete, and diagnose." }, - "version": "0.1.0", + "version": "0.1.2", "runOnStartup": false, "supportedOS": "windows|mac", "editEnabled": true From f04210561a20f284fccd9d3aacba9d32f54eb833 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:00:46 +0000 Subject: [PATCH 08/22] Close Q4: entityToken stable across save-reload (confirmed PC test 2) findEntityByToken returned a non-empty BaseVector after save-close-reopen. Token-based row keys in messaging.py are correct; no fallback needed. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index b508ac9..b2677a4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -497,7 +497,7 @@ Maximum five, each with a proposed validation approach. These cannot be resolved 3. **Refresh strategy when `palette.isVisible == False`.** UP-38529 (M-8) suggests skipping pushes when not visible — but does the palette emit a `shown` event we can hook to push a delayed refresh? **Validation:** trace `palette.*` event firing during minimize/restore and document the cycle. -4. **Stability of `entityToken` for `GeometricConstraint` objects across save-reload.** Doc 1 implies general stability via `Design.findEntityByToken`; doc 2 does not confirm this specifically for constraints. **Validation:** capture a constraint's token, save and reload the document, attempt `findEntityByToken(token)`; if it returns null, fall back to rowKey-by-position (less robust — would change `messaging.py` schema). +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.** Neither research doc explicitly lists it as a `GeometricConstraint` subclass — but `GeometricConstraints.addVertical(line)` is documented. **Validation:** the fixture sketch (section 8) creates one via `addVertical`; the spike script then iterates `sketch.geometricConstraints` and prints each `objectType` — confirm `"adsk::fusion::VerticalConstraint"` appears. If not, update the dispatch table to mark it as "creation-only, never enumerated." From eb88e863202da6330a216dd67d6b9b6897e838da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:04:27 +0000 Subject: [PATCH 09/22] Close Q1/Q2/Q3/Q5: all open questions resolved by spike probe (PC test 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q1: SolidScriptsAddinsPanel confirmed. SketchConstraintsPanel noted as better v1 home but not an MVP blocker. Q2: ShowUnderconstrained confirmed requires sketch edit context; returns plain string 'Under constrained points: N, under constrained curves: N'. Q3: Palette event surface is [closed, incomingFromHTML, navigatingURL] — no shown/opened event; push-on-restore via event hook is not possible. Q5: adsk::fusion::VerticalConstraint enumerated at row [1] — PASS. All five open questions in section 10 are now resolved. SPEC is complete. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- SPEC.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/SPEC.md b/SPEC.md index b2677a4..8035fd1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -489,17 +489,17 @@ Doc 1 mentions `isLightBulbOn` for highlighting. That property controls browser- ## 10. Open questions -Maximum five, each with a proposed validation approach. These cannot be resolved without running code inside Fusion. +**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.** Doc 2 mentions placement under the "Inspect" panel of the Solid workspace, but the canonical id (e.g. `SolidScriptsAddinsPanel` vs `SketchInspectPanel`) needs verification. **Validation:** in the Text Commands window, run `Commands.GetItemList` and grep for sketch panels; pick the one that shows when in sketch edit mode. +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.** Doc 2 cites `bachi.net` for the text-command output but does not confirm the precondition. **Validation:** call the command via `app.executeTextCommand` outside sketch edit; observe whether it returns the count text, an error string, or raises. Wrap accordingly. +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`.** UP-38529 (M-8) suggests skipping pushes when not visible — but does the palette emit a `shown` event we can hook to push a delayed refresh? **Validation:** trace `palette.*` event firing during minimize/restore and document the cycle. +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.** Neither research doc explicitly lists it as a `GeometricConstraint` subclass — but `GeometricConstraints.addVertical(line)` is documented. **Validation:** the fixture sketch (section 8) creates one via `addVertical`; the spike script then iterates `sketch.geometricConstraints` and prints each `objectType` — confirm `"adsk::fusion::VerticalConstraint"` appears. If not, update the dispatch table to mark it as "creation-only, never enumerated." +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. --- @@ -521,7 +521,7 @@ The three forks the spec previously deferred to the project owner have been reso - 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 the Solid workspace only (panel id to be confirmed per open question 1). Implementation rules: + 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. From cd195f630e1086041b7abe171f1d19c04056c668 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:15:15 +0000 Subject: [PATCH 10/22] Fix three bugs from PC test 1: spline selection, offset-dim highlight, lock UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — Tangent spline+line: line not highlighted on row click. JS now embeds entity tokens in data-entity-tokens on each row and sends them with the selectEntities message. Python resolves each entity directly by token (findEntityByToken → concrete typed object) rather than re-scanning accessor names. Token-based resolution is reliable for all curve types including SketchFittedSpline / SketchControlPointSpline where the accessor path could return a base-class proxy that activeSelections.add() rejects. Bug 2 — SketchOffsetCurvesDimension row shows no highlight. dispatch.py describe_dimension() now special-cases this type: iterates .curves (SketchCurveVector) and builds entity chips with tokens, so both the label and the selection payload carry the actual offset curves. lifecycle.py _entities_for_row adds 'curves' to the collection loop so the fallback path also covers this dimension type. Bug 3 — Implicit join × button shows confusing 'blocked' cursor with no explanation. Replaced the disabled × button on pseudo/implicit rows with a ⊘ lock indicator (non-interactive span) whose title tooltip reads 'Endpoint joins are shared sketch points and cannot be individually deleted'. Added .row-lock CSS class; aligned row-actions items with align-items: center. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- ConstraintLens/ConstraintLens.manifest | 2 +- ConstraintLens/lib/dispatch.py | 23 ++++++++++++++++++ ConstraintLens/lib/lifecycle.py | 33 ++++++++++++++++---------- ConstraintLens/palette/app.js | 24 +++++++++++++++---- ConstraintLens/palette/styles.css | 15 ++++++++++++ 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/ConstraintLens/ConstraintLens.manifest b/ConstraintLens/ConstraintLens.manifest index ae137b4..88549b8 100644 --- a/ConstraintLens/ConstraintLens.manifest +++ b/ConstraintLens/ConstraintLens.manifest @@ -6,7 +6,7 @@ "description": { "": "Docked panel listing every sketch constraint in the active sketch — click to select, delete, and diagnose." }, - "version": "0.1.2", + "version": "0.1.3", "runOnStartup": false, "supportedOS": "windows|mac", "editEnabled": true diff --git a/ConstraintLens/lib/dispatch.py b/ConstraintLens/lib/dispatch.py index 93139d3..bf4127b 100644 --- a/ConstraintLens/lib/dispatch.py +++ b/ConstraintLens/lib/dispatch.py @@ -350,6 +350,29 @@ def chip_for(self, _): def describe_dimension(dim, lab: EntityLabeler) -> ScanResult: obj_type = getattr(dim, "objectType", "") kind_label = _DIMENSION_KINDS.get(obj_type, obj_type.split("::")[-1] or "Dimension") + + # SketchOffsetCurvesDimension uses .curves (SketchCurveVector), not .entityOne/.entityTwo. + if obj_type == "adsk::fusion::SketchOffsetCurvesDimension": + curves = _safe(lambda: dim.curves) + expr = "?" + try: + expr = dim.parameter.expression + except Exception: + pass + chips: list[dict] = [] + n = 0 + if curves is not None: + try: + for curve in curves: + chips.append(lab.chip_for(curve)) + n += 1 + except Exception: + try: + n = len(curves) + except Exception: + pass + return ScanResult(f"{kind_label} ({n} curves) = {expr}", chips, []) + e1 = _safe(lambda: dim.entityOne) e2 = _safe(lambda: dim.entityTwo) expr = "?" diff --git a/ConstraintLens/lib/lifecycle.py b/ConstraintLens/lib/lifecycle.py index 9e94fbb..2588b2e 100644 --- a/ConstraintLens/lib/lifecycle.py +++ b/ConstraintLens/lib/lifecycle.py @@ -243,13 +243,24 @@ def _publish_active(app: adsk.core.Application) -> None: 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 "" - sketch = scanner.active_sketch(app) - if sketch is None: + if not row_key: return - design = adsk.fusion.Design.cast(app.activeProduct) - # Implicit-join rows: rowKey starts with "join:" + sketchPoint token. if row_key.startswith("join:"): point_token = row_key[len("join:"):] point = tokens.resolve(design, point_token) @@ -267,8 +278,7 @@ def _handle_select_entities(app: adsk.core.Application, payload: dict) -> None: constraint = tokens.resolve(design, row_key) if constraint is None: return - ents = _entities_for_row(constraint) - selection.select_entities(app.userInterface, ents) + selection.select_entities(app.userInterface, _entities_for_row(constraint)) def _handle_select_constraint(app: adsk.core.Application, payload: dict) -> None: @@ -311,18 +321,17 @@ def _entities_for_row(constraint) -> list: continue if v is not None: candidates.append(v) - # Collections: parentCurves / childCurves / lines on Offset/Polygon. - # parentCurves / childCurves are SketchCurveVector (supports iteration + len, - # not .count); lines on PolygonConstraint is ObjectCollection (.count + .item). - for coll_name in ("parentCurves", "childCurves", "lines"): + # 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 - # Try direct iteration first (works for both SketchCurveVector and - # ObjectCollection); fall back to .item(i) loop if that fails. + # Direct iteration works for both SketchCurveVector and ObjectCollection. try: for item in coll: candidates.append(item) diff --git a/ConstraintLens/palette/app.js b/ConstraintLens/palette/app.js index 99a9f66..9526a09 100644 --- a/ConstraintLens/palette/app.js +++ b/ConstraintLens/palette/app.js @@ -170,14 +170,26 @@ ? `
${row.errors.map(escape).join("; ")}
` : ""; - const deleteDisabled = !row.isDeletable ? "disabled" : ""; + // Entity tokens for token-based selection (bypasses accessor re-scan). + const entityTokensJson = JSON.stringify( + (row.entities || []).map(e => e.token || "").filter(t => t) + ); + const selectConstraintBtn = row.isPseudo ? "" : ``; + // Pseudo rows (implicit joins) cannot be deleted — show a lock indicator + // with an explanatory tooltip instead of a disabled × button. + const deleteBtn = row.isPseudo + ? `` + : ``; + return `
${escape(glyph)}
@@ -192,8 +204,7 @@
${selectConstraintBtn} - + ${deleteBtn}
`; @@ -230,7 +241,12 @@ const row = actionEl.closest(".row"); const rowKey = row ? row.getAttribute("data-row-key") || "" : ""; if (!rowKey) return; - send(JS_TO_PY.selectEntities, { rowKey }); + 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; } }); diff --git a/ConstraintLens/palette/styles.css b/ConstraintLens/palette/styles.css index 5d346cc..7bf036c 100644 --- a/ConstraintLens/palette/styles.css +++ b/ConstraintLens/palette/styles.css @@ -175,10 +175,25 @@ main { 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; From d335fab66b4ce1e5d18b5be28055fb2bd339e91d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:19:53 +0000 Subject: [PATCH 11/22] Add CLAUDE.md state file for cross-session continuity Captures current version (0.1.3), what's verified vs pending PC test, architecture quick-reference, resolved open questions, and v1 backlog. Update this file at the end of every session. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1146671 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# FusionConstraints — ConstraintLens Add-in + +## Current Status + +**Working on:** MVP bug-fix polish (post PC test sessions 1 & 2). +**Version:** 0.1.3 on branch `claude/fusion-constraintlens-spec-94gPu`. +**Next step:** PC test session 3 — pull 0.1.3, re-run steps 3 & 4 of `PC_VALIDATION.md` to verify the three fixes below. If clean, package for GitHub Release (zip `ConstraintLens/` folder, draft release notes). +**Blocked by:** Nothing. Waiting for PC test confirmation. + +### What changed in 0.1.3 (needs PC verification) +- **Spline+line selection fixed** — entity tokens now sent from JS on row click; Python resolves via `findEntityByToken` (concrete type) instead of re-scanning accessors (which returned a base-class proxy for splines). +- **SketchOffsetCurvesDimension highlight fixed** — `dispatch.py` now iterates `.curves` collection for this dimension type; entity chips carry tokens so selection works. +- **Implicit join lock UX** — disabled `×` replaced with `⊘` icon + tooltip: *"Endpoint joins are shared sketch points and cannot be individually deleted."* + +### What was working before 0.1.3 +- 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. +- 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). + +--- + +## 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 `SolidScriptsAddinsPanel` ("Add-ins" tab). +- **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. +2. "Show underconstrained" button (Q2 confirmed it works; enable only in sketch edit). +3. Filter / search by constraint type. +4. Constraint icons matching Fusion's own glyph set. +5. Bulk delete with confirmation. +6. Inline editable dimension expression. From 5e5d0c74d12ed6b104f0a051427033eb5d0aa1df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:28:20 +0000 Subject: [PATCH 12/22] Add PreToolUse hook: remind to update CLAUDE.md before git push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires only when there are commits to push that don't include CLAUDE.md. Injects additionalContext reminding Claude to update Current Status (Working on / Next step / Blocked by) and commit before pushing. CLAUDE.md at project root is auto-loaded by Claude Code at session start as project memory — no extra config needed for that. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- .claude/settings.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .claude/settings.json 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…" + } + ] + } + ] + } +} From 290abda26693564bd214c2bd65e582694dabef4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:30:20 +0000 Subject: [PATCH 13/22] Update CLAUDE.md: awaiting PC test 3 before GitHub Release https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1146671..711f81f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,17 +2,12 @@ ## Current Status -**Working on:** MVP bug-fix polish (post PC test sessions 1 & 2). +**Working on:** Awaiting PC test session 3 to verify 0.1.3 fixes. **Version:** 0.1.3 on branch `claude/fusion-constraintlens-spec-94gPu`. -**Next step:** PC test session 3 — pull 0.1.3, re-run steps 3 & 4 of `PC_VALIDATION.md` to verify the three fixes below. If clean, package for GitHub Release (zip `ConstraintLens/` folder, draft release notes). -**Blocked by:** Nothing. Waiting for PC test confirmation. +**Next step:** PC test session 3 — pull 0.1.3, re-run steps 3 & 4 of `PC_VALIDATION.md`. Check: (1) tangent spline+line row highlights both objects, (2) SketchOffsetCurvesDimension row highlights curves, (3) implicit join rows show ⊘ with tooltip. If all pass → package for GitHub Release. +**Blocked by:** PC test session 3 (user needs to be at the Fusion PC). -### What changed in 0.1.3 (needs PC verification) -- **Spline+line selection fixed** — entity tokens now sent from JS on row click; Python resolves via `findEntityByToken` (concrete type) instead of re-scanning accessors (which returned a base-class proxy for splines). -- **SketchOffsetCurvesDimension highlight fixed** — `dispatch.py` now iterates `.curves` collection for this dimension type; entity chips carry tokens so selection works. -- **Implicit join lock UX** — disabled `×` replaced with `⊘` icon + tooltip: *"Endpoint joins are shared sketch points and cannot be individually deleted."* - -### What was working before 0.1.3 +### What's verified working (PC tests 1 & 2) - 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. @@ -22,6 +17,11 @@ - OffsetConstraint ACCESSOR error fixed in 0.1.2. - Auto-load fixed in 0.1.2 (palette populated without Refresh click). +### What changed in 0.1.3 (needs PC test 3 verification) +- **Spline+line selection** — entity tokens sent from JS; Python resolves via `findEntityByToken`. +- **SketchOffsetCurvesDimension highlight** — `dispatch.py` iterates `.curves` collection; chips carry tokens. +- **Implicit join lock UX** — disabled `×` replaced with `⊘` + tooltip explaining why. + --- ## Project Overview From f525552501221ce91d60aba3630826f45e581340 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:54:12 +0000 Subject: [PATCH 14/22] 0.1.4: OffsetConstraint chips + SketchOffsetCurvesDimension fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OffsetConstraint row previously rendered with no entity chips (label-only "Offset 1->1 curves @ ?"). Now iterates parentCurves and childCurves via a shared _iter_curves_into_chips helper that tolerates both SketchCurveVector (iteration + len) and ObjectCollection (count + item) shapes. SketchOffsetCurvesDimension selection didn't work because the dimension doesn't reliably expose its source curves under any standard attribute name. describe_dimension now tries .curves / .parentCurves / .childCurves directly, then falls back to scanning the sketch's geometricConstraints for an OffsetConstraint whose distance.parameter shares the dimension's parameter entityToken — that OffsetConstraint's parentCurves + childCurves become the dimension's chips. CLAUDE.md updated: bumped to test session 4 pending; backlog now lists (7) sketch-to-palette reverse lookup and (8) marking invisible entities that users can't click on the canvas. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 25 +++--- ConstraintLens/ConstraintLens.manifest | 2 +- ConstraintLens/lib/dispatch.py | 105 ++++++++++++++++++------- ConstraintLens/lib/scanner.py | 2 +- 4 files changed, 92 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 711f81f..bc9f0db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,25 +2,28 @@ ## Current Status -**Working on:** Awaiting PC test session 3 to verify 0.1.3 fixes. -**Version:** 0.1.3 on branch `claude/fusion-constraintlens-spec-94gPu`. -**Next step:** PC test session 3 — pull 0.1.3, re-run steps 3 & 4 of `PC_VALIDATION.md`. Check: (1) tangent spline+line row highlights both objects, (2) SketchOffsetCurvesDimension row highlights curves, (3) implicit join rows show ⊘ with tooltip. If all pass → package for GitHub Release. -**Blocked by:** PC test session 3 (user needs to be at the Fusion PC). +**Working on:** Awaiting PC test session 4 to verify 0.1.4 fixes. +**Version:** 0.1.4 on branch `claude/fusion-constraintlens-spec-94gPu`. +**Next step:** PC test 4 — pull 0.1.4, check: (1) `OffsetConstraint` row now lists curve names as chips, (2) `SketchOffsetCurvesDimension` row highlights its curves on click. If clean → package for GitHub Release. +**Blocked by:** PC test session 4 (user needs to be at the Fusion PC). -### What's verified working (PC tests 1 & 2) +### What's verified working (PC tests 1, 2, 3) - 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. +- 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). - 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). -### What changed in 0.1.3 (needs PC test 3 verification) -- **Spline+line selection** — entity tokens sent from JS; Python resolves via `findEntityByToken`. -- **SketchOffsetCurvesDimension highlight** — `dispatch.py` iterates `.curves` collection; chips carry tokens. -- **Implicit join lock UX** — disabled `×` replaced with `⊘` + tooltip explaining why. +### What changed in 0.1.4 (needs PC test 4 verification) +- **OffsetConstraint row chips** — `_b_offset` now iterates `parentCurves` + `childCurves` via shared `_iter_curves_into_chips` helper and emits a chip per curve (was `[]`). +- **SketchOffsetCurvesDimension selection** — `describe_dimension` now tries `.curves`/`.parentCurves`/`.childCurves` directly, then falls back to finding the matching OffsetConstraint via parameter `entityToken` equality and pulling its curves. `scanner._scan_dimensions` now passes `sketch` into `describe_dimension`. + +### 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 item below. --- @@ -100,3 +103,5 @@ tests/ 4. Constraint icons matching Fusion's own glyph set. 5. Bulk delete with confirmation. 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** — Fusion creates internal control geometry for some operations (e.g. spline offsets create a hidden line that participates in a tangent constraint but isn't drawn on the canvas). Detect via `entity.isVisible` (or class-based heuristic) and badge the row / chip so users know the row references geometry they can't click. diff --git a/ConstraintLens/ConstraintLens.manifest b/ConstraintLens/ConstraintLens.manifest index 88549b8..5164b7d 100644 --- a/ConstraintLens/ConstraintLens.manifest +++ b/ConstraintLens/ConstraintLens.manifest @@ -6,7 +6,7 @@ "description": { "": "Docked panel listing every sketch constraint in the active sketch — click to select, delete, and diagnose." }, - "version": "0.1.3", + "version": "0.1.4", "runOnStartup": false, "supportedOS": "windows|mac", "editEnabled": true diff --git a/ConstraintLens/lib/dispatch.py b/ConstraintLens/lib/dispatch.py index bf4127b..f33bf42 100644 --- a/ConstraintLens/lib/dispatch.py +++ b/ConstraintLens/lib/dispatch.py @@ -200,31 +200,46 @@ def _b_symmetry(c, lab): ) -def _b_offset(c, lab): - parent = _safe(lambda: c.parentCurves) - child = _safe(lambda: c.childCurves) - distance = _safe(lambda: c.distance) - # parentCurves / childCurves return SketchCurveVector, not ObjectCollection. - # SketchCurveVector supports len() and iteration but not .count. +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: - n = len(parent) if parent is not None else 0 + for curve in coll: + chips.append(lab.chip_for(curve)) + n += 1 + return n + except TypeError: + pass except Exception: - n = 0 + return n try: - m = len(child) if child is not None else 0 + for i in range(coll.count): + chips.append(lab.chip_for(coll.item(i))) + n += 1 except Exception: - m = 0 + 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}", - [], # Collections, not single entities — counts surface in label. - [], - ) + return ScanResult(f"Offset {n}→{m} curves @ {expr}", chips, []) def _b_polygon(c, lab): @@ -347,30 +362,60 @@ def chip_for(self, _): } -def describe_dimension(dim, lab: EntityLabeler) -> ScanResult: +def _find_offset_constraint_for_dim(dim, sketch): + """Match a SketchOffsetCurvesDimension to its source OffsetConstraint by + comparing the dimension's parameter entityToken with each OffsetConstraint's + distance.parameter entityToken. Returns None if no match found.""" + if sketch is None: + return None + dim_param = _safe(lambda: dim.parameter) + if dim_param is None: + return None + dim_tok = _safe(lambda: dim_param.entityToken) + if not dim_tok: + return None + try: + gc = sketch.geometricConstraints + for i in range(gc.count): + c = gc.item(i) + if getattr(c, "objectType", "") != "adsk::fusion::OffsetConstraint": + continue + distance = _safe(lambda c=c: c.distance) + if distance is None: + continue + # OffsetConstraint.distance is a ModelParameter; compare tokens. + d_tok = _safe(lambda d=distance: d.entityToken) + if d_tok and d_tok == dim_tok: + return c + except Exception: + pass + return None + + +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 uses .curves (SketchCurveVector), not .entityOne/.entityTwo. + # 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": - curves = _safe(lambda: dim.curves) + 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 - chips: list[dict] = [] - n = 0 - if curves is not None: - try: - for curve in curves: - chips.append(lab.chip_for(curve)) - n += 1 - except Exception: - try: - n = len(curves) - except Exception: - pass return ScanResult(f"{kind_label} ({n} curves) = {expr}", chips, []) e1 = _safe(lambda: dim.entityOne) diff --git a/ConstraintLens/lib/scanner.py b/ConstraintLens/lib/scanner.py index 3932163..b4eac95 100644 --- a/ConstraintLens/lib/scanner.py +++ b/ConstraintLens/lib/scanner.py @@ -99,7 +99,7 @@ def _scan_dimensions(sketch: adsk.fusion.Sketch, lab: EntityLabeler) -> list[dic for i in range(dims.count): d = dims.item(i) try: - result = dispatch.describe_dimension(d, lab) + 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 "" From 38235e9dcf44dec326fde6ea92f19940f9c7fa95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:07:35 +0000 Subject: [PATCH 15/22] 0.1.5: multi-strategy match for SketchOffsetCurvesDimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test 4 showed the dimension still resolved 0 curves. Root cause: OffsetConstraint.distance accessor returns None in the January 2026 build (visible as "@ ?" in the offset constraint row label since 0.1.3), so my parameter-token match in 0.1.4 had nothing to compare against. _find_offset_constraint_for_dim now stacks four matching strategies and uses whichever lands first: 1. parameter entityToken (clean path when .distance works) 2. parameter name 3. positional pairing — nth SketchOffsetCurvesDimension corresponds to nth OffsetConstraint in the sketch 4. single-OffsetConstraint sketch — there's only one, use it Once the constraint is matched, parentCurves + childCurves become the dimension's entity chips and selection works through the existing token-based path. Backlog item #9 added: normalize the OffsetConstraint label so the "@ ?" cosmetic glitch goes away (pull the distance from the matched dimension's parameter instead of the broken .distance accessor). https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 20 ++++---- ConstraintLens/ConstraintLens.manifest | 2 +- ConstraintLens/lib/dispatch.py | 71 ++++++++++++++++++++------ 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc9f0db..1098c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,28 +2,29 @@ ## Current Status -**Working on:** Awaiting PC test session 4 to verify 0.1.4 fixes. -**Version:** 0.1.4 on branch `claude/fusion-constraintlens-spec-94gPu`. -**Next step:** PC test 4 — pull 0.1.4, check: (1) `OffsetConstraint` row now lists curve names as chips, (2) `SketchOffsetCurvesDimension` row highlights its curves on click. If clean → package for GitHub Release. -**Blocked by:** PC test session 4 (user needs to be at the Fusion PC). +**Working on:** Awaiting PC test session 5 to verify 0.1.5 fix. +**Version:** 0.1.5 on branch `claude/fusion-constraintlens-spec-94gPu`. +**Next step:** PC test 5 — pull 0.1.5, check that `SketchOffsetCurvesDimension` row now lists curve chips and highlights them on click. If clean → package for GitHub Release. +**Blocked by:** PC test session 5 (user needs to be at the Fusion PC). -### What's verified working (PC tests 1, 2, 3) +### What's verified working (PC tests 1, 2, 3, 4) - 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). - 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). -### What changed in 0.1.4 (needs PC test 4 verification) -- **OffsetConstraint row chips** — `_b_offset` now iterates `parentCurves` + `childCurves` via shared `_iter_curves_into_chips` helper and emits a chip per curve (was `[]`). -- **SketchOffsetCurvesDimension selection** — `describe_dimension` now tries `.curves`/`.parentCurves`/`.childCurves` directly, then falls back to finding the matching OffsetConstraint via parameter `entityToken` equality and pulling its curves. `scanner._scan_dimensions` now passes `sketch` into `describe_dimension`. +### What changed in 0.1.5 (needs PC test 5 verification) +- **SketchOffsetCurvesDimension matching, hardened** — `_find_offset_constraint_for_dim` now stacks four strategies because `OffsetConstraint.distance` is unreliable (returns None) on the January 2026 build: (1) parameter entityToken, (2) parameter name, (3) positional pairing — nth offset-dim → nth offset-constraint, (4) if there's exactly one OffsetConstraint, use it. Once a match is found, the 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 item below. +- 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 `@ ?` in the offset row label). The label-only consequence is cosmetic; functionality routes around it via the multi-strategy match above. --- @@ -105,3 +106,4 @@ tests/ 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** — Fusion creates internal control geometry for some operations (e.g. spline offsets create a hidden line that participates in a tangent constraint but isn't drawn on the canvas). Detect via `entity.isVisible` (or class-based heuristic) and badge the row / chip so users know the row references geometry they can't click. +9. **Normalize OffsetConstraint label** — current label `Offset 1→1 curves @ ?` is technical; the `@ ?` (from broken `.distance` accessor) is ugly. Replace with the offset distance from the matched SketchOffsetCurvesDimension's parameter (e.g. `Offset (1→1 curves, 30 mm)`). diff --git a/ConstraintLens/ConstraintLens.manifest b/ConstraintLens/ConstraintLens.manifest index 5164b7d..05f854b 100644 --- a/ConstraintLens/ConstraintLens.manifest +++ b/ConstraintLens/ConstraintLens.manifest @@ -6,7 +6,7 @@ "description": { "": "Docked panel listing every sketch constraint in the active sketch — click to select, delete, and diagnose." }, - "version": "0.1.4", + "version": "0.1.5", "runOnStartup": false, "supportedOS": "windows|mac", "editEnabled": true diff --git a/ConstraintLens/lib/dispatch.py b/ConstraintLens/lib/dispatch.py index f33bf42..2955f2c 100644 --- a/ConstraintLens/lib/dispatch.py +++ b/ConstraintLens/lib/dispatch.py @@ -363,32 +363,69 @@ def chip_for(self, _): def _find_offset_constraint_for_dim(dim, sketch): - """Match a SketchOffsetCurvesDimension to its source OffsetConstraint by - comparing the dimension's parameter entityToken with each OffsetConstraint's - distance.parameter entityToken. Returns None if no match found.""" + """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 - dim_param = _safe(lambda: dim.parameter) - if dim_param is None: - return None - dim_tok = _safe(lambda: dim_param.entityToken) - if not dim_tok: - 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": + 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 - distance = _safe(lambda c=c: c.distance) - if distance is None: + 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 - # OffsetConstraint.distance is a ModelParameter; compare tokens. - d_tok = _safe(lambda d=distance: d.entityToken) - if d_tok and d_tok == dim_tok: + if _safe(lambda d=d: d.name) == dim_name: return c - except Exception: - pass + + # 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 From 52382edf98e7453131060ff65b3462aad34e714c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:15:05 +0000 Subject: [PATCH 16/22] PC test 5 passed: mark all tests complete, update README for release All five PC test sessions complete. SketchOffsetCurvesDimension row now shows curve chips and highlights on click (0.1.5 confirmed). README updated from dev scaffold to user-facing install guide. CLAUDE.md reflects release-ready status. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 13 ++++++----- README.md | 69 ++++++++++++++++++++++++++++++++++--------------------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1098c44..fdc4117 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,25 +2,26 @@ ## Current Status -**Working on:** Awaiting PC test session 5 to verify 0.1.5 fix. +**Working on:** Packaging GitHub Release for v0.1.5. **Version:** 0.1.5 on branch `claude/fusion-constraintlens-spec-94gPu`. -**Next step:** PC test 5 — pull 0.1.5, check that `SketchOffsetCurvesDimension` row now lists curve chips and highlights them on click. If clean → package for GitHub Release. -**Blocked by:** PC test session 5 (user needs to be at the Fusion PC). +**Next step:** Publish GitHub Release — zip `ConstraintLens/` folder, draft release notes, tag `v0.1.5`. +**Blocked by:** Nothing — all 5 PC test sessions complete, all known bugs fixed. -### What's verified working (PC tests 1, 2, 3, 4) +### What's verified working (PC tests 1–5, all passed) - 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). -### What changed in 0.1.5 (needs PC test 5 verification) -- **SketchOffsetCurvesDimension matching, hardened** — `_find_offset_constraint_for_dim` now stacks four strategies because `OffsetConstraint.distance` is unreliable (returns None) on the January 2026 build: (1) parameter entityToken, (2) parameter name, (3) positional pairing — nth offset-dim → nth offset-constraint, (4) if there's exactly one OffsetConstraint, use it. Once a match is found, the constraint's `parentCurves` + `childCurves` become the dimension's chips. +### 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. diff --git a/README.md b/README.md index e9947c2..51cff54 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,70 @@ # ConstraintLens -A Fusion 360 add-in that docks a panel listing every constraint in the active sketch — 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. +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 architectural specification. +See [`SPEC.md`](./SPEC.md) for the full architectural specification. -## Status +## Features -Pre-MVP scaffold. The full module structure, dispatch table, and palette UI are in place; runtime verification of the five open questions in `SPEC.md` section 10 is still pending. +- **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. -## Install (development) +## Requirements -ConstraintLens requires Fusion 360 (January 2026 release or later, Python 3.14). +- Fusion 360 January 2026 release or later (Python 3.14 runtime). +- Windows or macOS. -1. Clone this repository. -2. Copy or symlink the `ConstraintLens/` folder into your Fusion add-ins directory: +## 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/` -3. In Fusion: **Tools → Scripts and Add-Ins → Add-Ins** tab. Select **ConstraintLens** and click **Run**. Tick *Run on Startup* if you want it loaded automatically. -4. The **Constraint Lens** button appears in **Solid → Tools → Scripts and Add-Ins** (panel id is verified at runtime via the spike probe; see below). - -## Verifying the install +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. -Before relying on ConstraintLens, run the test scripts under `tests/`: +## Usage -1. **Fixture** — Tools → Scripts and Add-Ins → **Scripts** tab → **+** → point at `tests/fixture_sketch/` → **Run**. Creates a deterministic sketch named `ConstraintLens_Fixture` with 4 explicit constraints, 2 dimensions, and 4 implicit endpoint joins. -2. **Spike probe** — same workflow, point at `tests/spike_probe/`. Open the fixture sketch for edit first. The probe writes a full report to your OS temp directory (`constraintlens_probe.txt`) and previews it in a message box; paste that file contents back to the developer to validate the five open questions in `SPEC.md` section 10. +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 — read first. -├── ConstraintLens/ The Fusion add-in (drop this into AddIns/). +├── SPEC.md Architectural spec. +├── ConstraintLens/ The Fusion add-in (copy this folder into AddIns/). │ ├── ConstraintLens.manifest │ ├── ConstraintLens.py -│ ├── lib/ Python modules (see SPEC.md section 4). -│ └── palette/ HTML/JS/CSS palette UI. +│ ├── lib/ Python backend modules. +│ └── palette/ HTML/JS/CSS palette UI (vanilla JS, no build step). └── tests/ - ├── fixture_sketch/ Deterministic test sketch (point Fusion Scripts at this folder). - ├── fixture_midpoint/ M-1 landmine trigger fixture. - └── spike_probe/ API-feasibility probe (run once per Fusion update). + ├── 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. ``` -## Known limitations +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` actions in MVP — Fusion's `Ctrl+Z` reverts the whole sketch-edit chunk. +- 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 expose only `Delete` (the API exposes no usable accessors). -- Assembly-level `AssemblyConstraint` (Constrain Components, January 2026 preview API) is intentionally not supported; revisit when Autodesk drops the preview disclaimer. +- `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 From da880ae243f927b4bbc7acf41d22e07f100dddcc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:15:44 +0000 Subject: [PATCH 17/22] Add .gitignore: exclude release zips, pycache, .DS_Store https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- .gitignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..803c60d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Release artifacts — built locally, uploaded to GitHub Releases +*.zip + +# Python +__pycache__/ +*.pyc +*.pyo + +# macOS +.DS_Store + +# Ruff / linter caches +.ruff_cache/ From c4513c5c106b3881950aef05bbb1cbe54b5798df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:19:37 +0000 Subject: [PATCH 18/22] Backlog #10 and #11: dimension entity labels + fully-constrained verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10: Angular/Diameter and other dim types show class-name fallback labels ("SketchLine") instead of friendly names ("Line 2") because describe_dimension assumes entityOne/entityTwo but some types use type-specific accessors. Selection still works via _entities_for_row broad scan. Fix: per-type accessor map for dimensions, mirroring geometric constraint dispatch. #11: Fully-constrained green banner is implemented correctly (app.js L128–133) but has never been exercised in a PC test — fixture sketch is under-constrained. Needs manual verification with a fully-constrained sketch. https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index fdc4117..3fbb64e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,3 +108,5 @@ tests/ 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** — Fusion creates internal control geometry for some operations (e.g. spline offsets create a hidden line that participates in a tangent constraint but isn't drawn on the canvas). Detect via `entity.isVisible` (or class-based heuristic) and badge the row / chip so users know the row references geometry they can't click. 9. **Normalize OffsetConstraint label** — current label `Offset 1→1 curves @ ?` is technical; the `@ ?` (from broken `.distance` accessor) is ugly. Replace with the offset distance from the matched SketchOffsetCurvesDimension's parameter (e.g. `Offset (1→1 curves, 30 mm)`). +10. **Dimension entity chip labels — show "Line 2" not "SketchLine"** — `describe_dimension` assumes all dimension subtypes expose `entityOne`/`entityTwo`, but some use type-specific accessors (e.g. `SketchAngularDimension` likely exposes `.lineOne`/`.lineTwo`, same pattern as geometric constraints). When `entityOne` returns `None`, chips are created with class-name fallback labels ("SketchLine", "SketchCircle") instead of friendly names ("Line 2", "Circle 1"). Selection still works because the click-time `_entities_for_row` fallback does a broad attribute scan. Fix: build a per-type accessor map for dimensions (mirroring the geometric constraint dispatch table in `dispatch.py`) so each dimension type names its own entity accessors. Verified affected: Angular, Diameter — assume others with type-specific accessors are also affected. +11. **Verify fully-constrained green status (PC test needed)** — Code review confirms correct implementation: `app.js` lines 128–133 apply CSS class `"ok"` (green `#5fb96b`) when `sketch.isFullyConstrained == True`, and the banner reads "— fully constrained". The fixture sketch is intentionally under-constrained so this has never been exercised in a PC test. To verify: create a simple fully-constrained sketch (e.g. a fixed-position rectangle with all dimensions set), open ConstraintLens, confirm the status banner turns green. If it fails, check that `scanner.py`'s `build_payload` correctly passes `bool(sketch.isFullyConstrained)`. From 457a2ecc309a1a319dc338d62f0929f23f4eb189 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:22:45 +0000 Subject: [PATCH 19/22] CLAUDE.md: v0.1.5 released, pivot to v1 backlog https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3fbb64e..d3939ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,10 @@ ## Current Status -**Working on:** Packaging GitHub Release for v0.1.5. -**Version:** 0.1.5 on branch `claude/fusion-constraintlens-spec-94gPu`. -**Next step:** Publish GitHub Release — zip `ConstraintLens/` folder, draft release notes, tag `v0.1.5`. -**Blocked by:** Nothing — all 5 PC test sessions complete, all known bugs fixed. +**Working on:** v1 polish backlog. +**Version:** 0.1.5 released — GitHub Release published with `ConstraintLens-v0.1.5.zip`. +**Next step:** Pick next backlog item (see v1 Polish Backlog below). +**Blocked by:** Nothing. ### What's verified working (PC tests 1–5, all passed) - Add-in loads, palette docks, populates without Refresh click. From bfca8f2924bf0ed67b94f7c10c7fb4ad2e751556 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:24:10 +0000 Subject: [PATCH 20/22] Mark backlog #11 verified: fully-constrained green status confirmed in PC test https://claude.ai/code/session_01GBGpa4pZXXLbrWDRJmqT5F --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d3939ad..baacf59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,4 +109,4 @@ tests/ 8. **Mark invisible / unselectable entities** — Fusion creates internal control geometry for some operations (e.g. spline offsets create a hidden line that participates in a tangent constraint but isn't drawn on the canvas). Detect via `entity.isVisible` (or class-based heuristic) and badge the row / chip so users know the row references geometry they can't click. 9. **Normalize OffsetConstraint label** — current label `Offset 1→1 curves @ ?` is technical; the `@ ?` (from broken `.distance` accessor) is ugly. Replace with the offset distance from the matched SketchOffsetCurvesDimension's parameter (e.g. `Offset (1→1 curves, 30 mm)`). 10. **Dimension entity chip labels — show "Line 2" not "SketchLine"** — `describe_dimension` assumes all dimension subtypes expose `entityOne`/`entityTwo`, but some use type-specific accessors (e.g. `SketchAngularDimension` likely exposes `.lineOne`/`.lineTwo`, same pattern as geometric constraints). When `entityOne` returns `None`, chips are created with class-name fallback labels ("SketchLine", "SketchCircle") instead of friendly names ("Line 2", "Circle 1"). Selection still works because the click-time `_entities_for_row` fallback does a broad attribute scan. Fix: build a per-type accessor map for dimensions (mirroring the geometric constraint dispatch table in `dispatch.py`) so each dimension type names its own entity accessors. Verified affected: Angular, Diameter — assume others with type-specific accessors are also affected. -11. **Verify fully-constrained green status (PC test needed)** — Code review confirms correct implementation: `app.js` lines 128–133 apply CSS class `"ok"` (green `#5fb96b`) when `sketch.isFullyConstrained == True`, and the banner reads "— fully constrained". The fixture sketch is intentionally under-constrained so this has never been exercised in a PC test. To verify: create a simple fully-constrained sketch (e.g. a fixed-position rectangle with all dimensions set), open ConstraintLens, confirm the status banner turns green. If it fails, check that `scanner.py`'s `build_payload` correctly passes `bool(sketch.isFullyConstrained)`. +11. ~~**Verify fully-constrained green status**~~ — **VERIFIED PC test (session 5+).** Banner turns green and reads "— fully constrained" correctly. From 0482bd5334cecaf04a5964dad3881e66d0548c2d Mon Sep 17 00:00:00 2001 From: ConstraintLens Dev Date: Fri, 22 May 2026 10:25:25 +0200 Subject: [PATCH 21/22] v0.1.6: button relocation, Show u/c, filter bar, label polish (#1 #2 #3 #9 #10) - #1: Move toolbar button from SolidScriptsAddinsPanel to SketchConstraintsPanel - #2: Add 'Show u/c' button calling executeTextCommand('Sketch.ShowUnderconstrained'); result surfaced as toast - #3: Add filter bar below toolbar; client-side filtering by label/kind; section headers show (N of M) counts - #9: Normalize OffsetConstraint label to 'Offset (N->M curves, expr)' via matched SketchOffsetCurvesDimension - #10: Add _DIM_ACCESSORS map in dispatch.py for per-type dimension entity accessors (Angular, Diameter, Radial, etc.) so chips show friendly names - Add .vscode/ to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + CLAUDE.md | 24 ++++--- ConstraintLens/ConstraintLens.manifest | 2 +- ConstraintLens/lib/dispatch.py | 87 +++++++++++++++++++++++++- ConstraintLens/lib/lifecycle.py | 36 +++++++++-- ConstraintLens/lib/messaging.py | 1 + ConstraintLens/lib/scanner.py | 2 + ConstraintLens/palette/app.js | 52 ++++++++++++--- ConstraintLens/palette/index.html | 8 +++ ConstraintLens/palette/styles.css | 28 +++++++++ 10 files changed, 219 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 803c60d..7b78c54 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ __pycache__/ # Ruff / linter caches .ruff_cache/ + +# IDE +.vscode/ diff --git a/CLAUDE.md b/CLAUDE.md index baacf59..bdb43a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,11 +3,11 @@ ## Current Status **Working on:** v1 polish backlog. -**Version:** 0.1.5 released — GitHub Release published with `ConstraintLens-v0.1.5.zip`. +**Version:** 0.1.6 released — backlog #1, #2, #3, #9, #10 shipped. Previous: 0.1.5 (`ConstraintLens-v0.1.5.zip` on GitHub Releases). **Next step:** Pick next backlog item (see v1 Polish Backlog below). **Blocked by:** Nothing. -### What's verified working (PC tests 1–5, all passed) +### 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. @@ -19,13 +19,18 @@ - 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 ### 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 `@ ?` in the offset row label). The label-only consequence is cosmetic; functionality routes around it via the multi-strategy match above. +- `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). --- @@ -35,7 +40,7 @@ A Fusion 360 Python add-in that docks a panel listing every constraint in the ac - **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 `SolidScriptsAddinsPanel` ("Add-ins" tab). +- **Workspace:** Solid workspace only (MVP). Button lives in `SketchConstraintsPanel` (Sketch tab → Constraints panel). - **Spec:** `SPEC.md` — complete, all 5 open questions resolved. --- @@ -99,14 +104,15 @@ tests/ ## v1 Polish Backlog (post-MVP, not started) -1. Move button to `SketchConstraintsPanel` for in-sketch discoverability. -2. "Show underconstrained" button (Q2 confirmed it works; enable only in sketch edit). -3. Filter / search by constraint type. +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. 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** — Fusion creates internal control geometry for some operations (e.g. spline offsets create a hidden line that participates in a tangent constraint but isn't drawn on the canvas). Detect via `entity.isVisible` (or class-based heuristic) and badge the row / chip so users know the row references geometry they can't click. -9. **Normalize OffsetConstraint label** — current label `Offset 1→1 curves @ ?` is technical; the `@ ?` (from broken `.distance` accessor) is ugly. Replace with the offset distance from the matched SketchOffsetCurvesDimension's parameter (e.g. `Offset (1→1 curves, 30 mm)`). -10. **Dimension entity chip labels — show "Line 2" not "SketchLine"** — `describe_dimension` assumes all dimension subtypes expose `entityOne`/`entityTwo`, but some use type-specific accessors (e.g. `SketchAngularDimension` likely exposes `.lineOne`/`.lineTwo`, same pattern as geometric constraints). When `entityOne` returns `None`, chips are created with class-name fallback labels ("SketchLine", "SketchCircle") instead of friendly names ("Line 2", "Circle 1"). Selection still works because the click-time `_entities_for_row` fallback does a broad attribute scan. Fix: build a per-type accessor map for dimensions (mirroring the geometric constraint dispatch table in `dispatch.py`) so each dimension type names its own entity accessors. Verified affected: Angular, Diameter — assume others with type-specific accessors are also affected. +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 index 05f854b..6839041 100644 --- a/ConstraintLens/ConstraintLens.manifest +++ b/ConstraintLens/ConstraintLens.manifest @@ -6,7 +6,7 @@ "description": { "": "Docked panel listing every sketch constraint in the active sketch — click to select, delete, and diagnose." }, - "version": "0.1.5", + "version": "0.1.6", "runOnStartup": false, "supportedOS": "windows|mac", "editEnabled": true diff --git a/ConstraintLens/lib/dispatch.py b/ConstraintLens/lib/dispatch.py index 2955f2c..aa457d5 100644 --- a/ConstraintLens/lib/dispatch.py +++ b/ConstraintLens/lib/dispatch.py @@ -239,7 +239,7 @@ def _b_offset(c, lab): expr = distance.expression except Exception: expr = "?" - return ScanResult(f"Offset {n}→{m} curves @ {expr}", chips, []) + return ScanResult(f"Offset ({n}→{m} curves, {expr})", chips, []) def _b_polygon(c, lab): @@ -346,6 +346,35 @@ def chip_for(self, _): # --- 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", @@ -429,6 +458,57 @@ def _find_offset_constraint_for_dim(dim, sketch): 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") @@ -455,8 +535,9 @@ def describe_dimension(dim, lab: EntityLabeler, sketch=None) -> ScanResult: pass return ScanResult(f"{kind_label} ({n} curves) = {expr}", chips, []) - e1 = _safe(lambda: dim.entityOne) - e2 = _safe(lambda: dim.entityTwo) + 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 diff --git a/ConstraintLens/lib/lifecycle.py b/ConstraintLens/lib/lifecycle.py index 2588b2e..96fbc00 100644 --- a/ConstraintLens/lib/lifecycle.py +++ b/ConstraintLens/lib/lifecycle.py @@ -13,10 +13,9 @@ _CMD_ID = "ConstraintLensShow" _PALETTE_ID = "ConstraintLensPalette" -# Per SPEC.md open question 1: the exact panel id is verified at runtime. -# SolidScriptsAddinsPanel exists in every install; the spike probe enumerates -# alternatives (e.g. SketchInspectPanel) for a later relocation. -_PANEL_ID = "SolidScriptsAddinsPanel" +# 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. @@ -205,6 +204,10 @@ def _on_palette_message(action: str, raw: str) -> None: _handle_delete(app, payload) return + if action == messaging.ACTION_SHOW_UNDERCONSTRAINED: + _handle_show_underconstrained(app) + return + # Unknown action — log and ignore (forward-compat per SPEC.md section 7). @@ -301,6 +304,31 @@ def _handle_delete(app: adsk.core.Application, payload: dict) -> None: _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 = [] diff --git a/ConstraintLens/lib/messaging.py b/ConstraintLens/lib/messaging.py index 71235f9..f755915 100644 --- a/ConstraintLens/lib/messaging.py +++ b/ConstraintLens/lib/messaging.py @@ -11,6 +11,7 @@ ACTION_SELECT_ENTITIES = "selectEntities" ACTION_SELECT_CONSTRAINT = "selectConstraint" ACTION_DELETE_CONSTRAINT = "deleteConstraint" +ACTION_SHOW_UNDERCONSTRAINED = "showUnderconstrained" PY_ACTION_DATA = "data" PY_ACTION_NO_ACTIVE_SKETCH = "noActiveSketch" diff --git a/ConstraintLens/lib/scanner.py b/ConstraintLens/lib/scanner.py index b4eac95..01a7d3d 100644 --- a/ConstraintLens/lib/scanner.py +++ b/ConstraintLens/lib/scanner.py @@ -69,6 +69,8 @@ def _scan_constraints(sketch: adsk.fusion.Sketch, lab: EntityLabeler) -> list[di 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}"] diff --git a/ConstraintLens/palette/app.js b/ConstraintLens/palette/app.js index 9526a09..be21d94 100644 --- a/ConstraintLens/palette/app.js +++ b/ConstraintLens/palette/app.js @@ -12,6 +12,7 @@ selectEntities: "selectEntities", selectConstraint: "selectConstraint", deleteConstraint: "deleteConstraint", + showUnderconstrained: "showUnderconstrained", }; const PY_TO_JS = { @@ -53,12 +54,15 @@ const state = { snapshot: null, loaded: false, + filter: "", }; const els = { root: document.getElementById("root"), status: document.getElementById("status"), refresh: document.getElementById("refresh"), + highlightUnder: document.getElementById("highlight-under"), + filter: document.getElementById("filter"), }; // --- Outgoing messages ----------------------------------------------- @@ -72,6 +76,11 @@ } 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(); + }); // --- Incoming messages ---------------------------------------------- @@ -97,11 +106,13 @@ function onData(payload) { state.snapshot = payload; + els.highlightUnder.disabled = false; renderSnapshot(); } function onNoActiveSketch(payload) { state.snapshot = null; + 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.")}
`; } @@ -115,6 +126,14 @@ 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() { @@ -132,25 +151,44 @@ : "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 = []; - const c = snap.constraints || []; - const d = snap.dimensions || []; - const j = snap.implicitJoins || []; if (c.length === 0 && d.length === 0 && j.length === 0) { - parts.push(`
This sketch has no constraints or dimensions yet.
`); + 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) { - parts.push(`
Geometric constraints (${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) { - parts.push(`
Dimensions (${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) { - parts.push(`
Endpoint joins (${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)); } diff --git a/ConstraintLens/palette/index.html b/ConstraintLens/palette/index.html index 811528b..991575a 100644 --- a/ConstraintLens/palette/index.html +++ b/ConstraintLens/palette/index.html @@ -8,9 +8,17 @@
Loading…
+
+
+ +
+
Loading…
diff --git a/ConstraintLens/palette/styles.css b/ConstraintLens/palette/styles.css index 7bf036c..3deb13e 100644 --- a/ConstraintLens/palette/styles.css +++ b/ConstraintLens/palette/styles.css @@ -225,3 +225,31 @@ main { .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); +} From b837baaa897adf0108a8dd9bfc48b8ca28b58ed3 Mon Sep 17 00:00:00 2001 From: ConstraintLens Dev Date: Fri, 22 May 2026 14:14:13 +0200 Subject: [PATCH 22/22] v0.1.7: bulk delete with confirmation, invisible entity badges (#5 #8) - #5: Checkboxes on all deletable rows; "Delete N" + "Clear" buttons appear in toolbar when selection > 0; confirm dialog notes Ctrl+Z undo; single per-row delete button removed; Python bulk handler loops deletions and returns summary toast - #8: chip_for() checks entity.isVisible; invisible chips rendered dimmed with dashed border and "hidden" badge; clicking row still reveals hidden entity (Fusion native behaviour) Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 8 ++- ConstraintLens/ConstraintLens.manifest | 2 +- ConstraintLens/lib/labels.py | 6 ++ ConstraintLens/lib/lifecycle.py | 27 +++++++++ ConstraintLens/lib/messaging.py | 1 + ConstraintLens/palette/app.js | 78 ++++++++++++++++++++------ ConstraintLens/palette/index.html | 4 ++ ConstraintLens/palette/styles.css | 34 +++++++++++ 8 files changed, 140 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bdb43a8..c88430c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Current Status **Working on:** v1 polish backlog. -**Version:** 0.1.6 released — backlog #1, #2, #3, #9, #10 shipped. Previous: 0.1.5 (`ConstraintLens-v0.1.5.zip` on GitHub Releases). +**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. @@ -24,6 +24,8 @@ - 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. @@ -108,10 +110,10 @@ tests/ 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. +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** — Fusion creates internal control geometry for some operations (e.g. spline offsets create a hidden line that participates in a tangent constraint but isn't drawn on the canvas). Detect via `entity.isVisible` (or class-based heuristic) and badge the row / chip so users know the row references geometry they can't click. +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. diff --git a/ConstraintLens/ConstraintLens.manifest b/ConstraintLens/ConstraintLens.manifest index 6839041..c4b8f01 100644 --- a/ConstraintLens/ConstraintLens.manifest +++ b/ConstraintLens/ConstraintLens.manifest @@ -6,7 +6,7 @@ "description": { "": "Docked panel listing every sketch constraint in the active sketch — click to select, delete, and diagnose." }, - "version": "0.1.6", + "version": "0.1.7", "runOnStartup": false, "supportedOS": "windows|mac", "editEnabled": true diff --git a/ConstraintLens/lib/labels.py b/ConstraintLens/lib/labels.py index 80dfcf5..baa7afd 100644 --- a/ConstraintLens/lib/labels.py +++ b/ConstraintLens/lib/labels.py @@ -67,8 +67,14 @@ def kind_for(self, entity) -> str: 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 index 96fbc00..b478363 100644 --- a/ConstraintLens/lib/lifecycle.py +++ b/ConstraintLens/lib/lifecycle.py @@ -208,6 +208,10 @@ def _on_palette_message(action: str, raw: str) -> None: _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). @@ -304,6 +308,29 @@ def _handle_delete(app: adsk.core.Application, payload: dict) -> None: _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: diff --git a/ConstraintLens/lib/messaging.py b/ConstraintLens/lib/messaging.py index f755915..7de5965 100644 --- a/ConstraintLens/lib/messaging.py +++ b/ConstraintLens/lib/messaging.py @@ -12,6 +12,7 @@ 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" diff --git a/ConstraintLens/palette/app.js b/ConstraintLens/palette/app.js index be21d94..439f393 100644 --- a/ConstraintLens/palette/app.js +++ b/ConstraintLens/palette/app.js @@ -13,6 +13,7 @@ selectConstraint: "selectConstraint", deleteConstraint: "deleteConstraint", showUnderconstrained: "showUnderconstrained", + bulkDelete: "bulkDelete", }; const PY_TO_JS = { @@ -55,6 +56,7 @@ snapshot: null, loaded: false, filter: "", + selected: new Set(), }; const els = { @@ -62,6 +64,8 @@ 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"), }; @@ -81,6 +85,20 @@ 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 ---------------------------------------------- @@ -104,14 +122,26 @@ }, }; + 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.")}
`; @@ -213,16 +243,20 @@ (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) cannot be deleted — show a lock indicator - // with an explanatory tooltip instead of a disabled × button. - const deleteBtn = row.isPseudo - ? `` - : ``; + // Pseudo rows (implicit joins) can't be checked or deleted individually. + const lockBtn = row.isPseudo + ? `` + : ""; return `
+ ${checkboxHTML}
${escape(glyph)}
${escape(row.label || "")}
@@ -242,31 +277,42 @@
${selectConstraintBtn} - ${deleteBtn} + ${lockBtn}
`; } function chipHTML(chip) { + if (chip.invisible) { + return ``; + } return `${escape(chip.label || chip.kind || "?")}`; } - // --- Delegated click handling --------------------------------------- + // --- 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 === "deleteConstraint") { - evt.stopPropagation(); - const token = actionEl.getAttribute("data-token") || ""; - if (!token) return; - send(JS_TO_PY.deleteConstraint, { token }); - return; - } - if (action === "selectConstraint") { evt.stopPropagation(); const token = actionEl.getAttribute("data-token") || ""; diff --git a/ConstraintLens/palette/index.html b/ConstraintLens/palette/index.html index 991575a..9de053c 100644 --- a/ConstraintLens/palette/index.html +++ b/ConstraintLens/palette/index.html @@ -8,6 +8,10 @@
Loading…
+ + diff --git a/ConstraintLens/palette/styles.css b/ConstraintLens/palette/styles.css index 3deb13e..109cea5 100644 --- a/ConstraintLens/palette/styles.css +++ b/ConstraintLens/palette/styles.css @@ -253,3 +253,37 @@ main { .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; +}