# What the Fusion API actually does

Everything here was **measured in a running Fusion 360 (2704.1.23)** while building
this add-in, not taken from the API reference. Several of these contradict the
reference or are simply undocumented, and each one cost real debugging time. They are
recorded so the next person — or the next me — does not pay for them twice.

The measurements were taken through the dev bridge (see the README): a resident
add-in that runs jobs on Fusion's main thread, so an experiment can be scripted and
repeated instead of clicked.

---

## Mouse input

**Mouse events do fire in a command dialog.** This was the make-or-break unknown for
the whole add-in and nothing in the documentation promises it. `Command.mouseDown`,
`mouseDrag`, `mouseUp`, `mouseMove` and `keyDown` all deliver while the dialog is
open — counted in one session: 33 down/drag, 33 up, 2961 move.

**`mouseDrag` does not reliably set `args.button`.** Gating painting on
`args.button & LeftMouseButton` inside a drag handler discards the entire drag, and
only the initial click paints — which looks exactly like "dragging is broken". Latch
the button on `mouseDown` and treat a move-with-button-down as a drag.

**There is no way to consume an event.** `MouseEventArgs` in this version exposes
`button`, `clicks`, `wheelDelta`, `keyboardModifiers`, `position`, `viewportPosition`
and `viewport` — and no `isHandled` flag. An add-in cannot stop Fusion's own
interpretation of a gesture.

`keyboardModifiers` is a bitmask against `KeyboardModifiers.ShiftKeyboardModifier`
and friends; `MouseButtons.LeftMouseButton` is `1`, also a bitfield.

---

## Turning a cursor position into a ray

**`Viewport.hitTest` does not exist** in this version. Picking is done with
`MeshBody.calculateCollisionsWithRay(origin, dir)` for mesh bodies and
`Component.findBRepUsingRay(origin, dir, entityType, tol, visibleOnly, hitPoints)`
for solids. **`findBRepUsingRay` takes positional arguments only** — the binding
raises `TypeError` on keywords.

Unprojecting the cursor has two traps, both silent:

**1. `viewToModelSpace` returns a point at *target* depth.** A ray started there
misses everything between it and the camera — that is, the half of the part you are
looking at. Symptom: painting the near side of a cylinder scores zero hits while the
far side works. Pull the origin back to one focal length in front of the eye:

```python
along  = dot(p - eye, view_dir)
origin = p - view_dir * (along + focal)      # always -focal relative to the eye
```

**2. The unprojected point can land BEHIND the eye**, so `eye -> p` points backwards.
Measured `dir = (0, -0.99, 0.13)` on a camera looking down `+Y`; every ray missed.
Worse, a reprojection round-trip cannot catch it, because under an orthographic
projection a point behind the camera lands on the very same pixel. Force the
direction into the screen: `dot(dir, target - eye) > 0`.

Choosing between the perspective and orthographic ray models by reprojection error
does work — the losing candidate is ~600 px out at the edge of the viewport — but
**never test at the exact centre**, where the two coincide and a zero error proves
nothing.

---

## Custom graphics

**The property is `rootComponent.customGraphicsGroups`**, not `customGraphics`. The
collection's method is `add()`; `addGroup()` exists only for nesting inside a group.

**Writing to ANY command input wipes every custom graphics group in the document.**
This is the single most confusing behaviour here, because it presents as "`addMesh`
silently failed": the group is there, with zero entities, and nothing is logged. The
consequences:

- the overlay must always be drawn **last** — refresh the dialog text first, then draw;
- `on_input_changed` must end with a redraw, or changing the brush size blanks the
  overlay.

**Cost, measured** — this is what makes an interactive tool feel broken or fine:

| operation | cost |
|---|---|
| `viewport.refresh()` | **3.5 ms** |
| creating any custom graphics entity | **~5 ms**, regardless of its size |
| setting `CustomGraphicsEntity.transform` | **0.16 ms** |

So: build a cursor **once** and move it by transform; **batch** geometry appends; and
do **not** force a refresh per mouse event — Fusion repaints on its own while the
pointer moves over the viewport. Applying all three took a paint stroke from
**13.1 ms to 1.84 ms per mouse event**.

`CustomGraphicsSolidColorEffect` ignores the opacity component of its colour.

---

## Commands

**A command's `destroy` fires AFTER the next command's `created`.** Tearing down
shared state unconditionally in `destroy` therefore deletes the state the *new*
command just built, and every later interaction silently does nothing. Compare
`args.command` against the session owner before tearing anything down. Note the
shell's destroy handler receives no command, so the worker has to register its own.

**Never open a modal `messageBox` in `execute`.** It blocks Fusion's main thread
outright — the whole application, background threads included, until someone clicks
it. `adsk.core.Application.log(message)` writes to the TEXT COMMAND window without
blocking.

---

## Geometry and the document

**Occurrence transforms:** `occ.transform = m` after creation silently no-ops in a
parametric design (it needs a snapshot). Pass the matrix to
`root.occurrences.addNewComponent(matrix)` instead.

**A body reference goes stale after `BaseFeature.finishEdit()`.** Setting `.name`,
`.appearance` or attributes on it afterwards silently does nothing — the same failure
mode each time, and easy to misread as "the API ignored my value". Do all of it
*inside* the edit, then re-fetch the body from the collection if you still need it.

**Construction geometry from raw coordinates is documented as direct-modelling only**
— `setByPlane`, `setByLine`, `setByPoint` — and they do fail at the top level of a
parametric design. **But they are accepted inside a `BaseFeature` edit**, which is a
direct-modelling context. That is how to create an arbitrary construction plane or
axis in a parametric design with **no helper body at all**. The (empty) base feature
must stay: deleting it deletes the construction geometry with it.

For the record, the alternative also works but is worse: build a helper body, derive
the plane from its face with `setByOffset(planarFace, 0)` or `setByCircularFace`,
then delete the body — the plane survives, but it dangles on a deleted face.

**Appearances:** library appearances are read-only. Copy first with
`design.appearances.addByCopy(libraryAppearance, name)` and set properties on the
copy. `Glass (Bronze)` has **two** `Color` properties; the glass tint is the one in
the transparency group — find it by walking back from the `Transparent Distance`
property rather than by index. `BRepBody.opacity` (0–1) is a separate override and is
the reliable way to make a body read as see-through in the shaded viewport.

**Mesh access is uniform across body types.** `MeshBody.displayMesh` and
`BRepBody.meshManager.displayMeshes.bestMesh` both return a `TriangleMesh` with
`nodeCoordinatesAsDouble` and `nodeIndices`, so one code path covers meshes and
solids. Reach bodies through their occurrence to get assembly coordinates — the
native pickers work in assembly space, and mixing the two paints the wrong place.

Be aware that a BRep display mesh can be very coarse: the side facets of a plain
cylinder span its entire height. Any tool that works "per triangle" will behave
badly on it.

---

## Testing an add-in at all

Fusion has **no headless mode and no CLI**, so nothing outside it can run a script.
The only way to test automatically is from inside a running Fusion: a resident add-in
watching a folder for jobs. The API is **not thread-safe**, so the watcher thread must
touch only the filesystem and hand work to the main thread through
`app.fireCustomEvent` / a `CustomEventHandler`.

`Viewport.saveAsImageFile` **does** capture custom graphics, which makes it possible
to check an overlay actually renders rather than trusting that the calls returned.
