Skip to content

Frame the camera on every body, not just the first (#19) - #11

Merged
gsdali merged 1 commit into
mainfrom
fix/18-frame-all-bodies
Aug 20, 2026
Merged

Frame the camera on every body, not just the first (#19)#11
gsdali merged 1 commit into
mainfrom
fix/18-frame-all-bodies

Conversation

@gsdali

@gsdali gsdali commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes the camera half of the OCCTSwift#302 multibody ripple (ecosystem#18), filed here as OCCTSwiftCADKit#19 and in the sibling service as OCCTSwiftUX#12.

Before OCCTSwift v1.11.3 a multibody STL/BREP came back as one lumped shape, so "the first shape" genuinely was the whole model and framing on it read correctly. Since v1.11.3 the importers return one body per solid, and the framing code kept asking for the first one.

The reported path, and the one that was not reported

Path How it framed Affected
loadFile(from:progress:) (deprecated) focusOnLoadedShape() off legacyLoadedShape = result.shapes.first yes, this is what the issues describe
focus(on:) shape(id:), which returns only an entity's first body yes, and undocumented

The second one is the worse of the two. The multi-entity loadFile(from:id:progress:) registers a multibody file as one entity owning N bodies, and its own doc comment tells callers:

Camera is not auto-focused; call focus(on:) once you've loaded what should be visible.

So the documented way to frame a multibody import was the broken one, and the issues do not mention it.

Why it survived a previous fix

This exact assumption was found and fixed once before, in applySideBySide. Its regression test says so explicitly:

shape(id:), which deliberately returns only the FIRST body's shape of an entity, fine for the "roughly frame the camera" use in focus(on:), but wrong for an offset that has to clear every body of a multi-body entity.

It was not fine for framing either. That earlier pass stopped at the boundary of the symptom in front of it, and left a comment asserting the rest was safe. Worth remembering the next time a shared accessor turns out to be wrong for one caller.

The change

Both paths now go through combinedBounds(ofEntities:), which unions over Entity.bodyIDs instead of shape(id:). That also collapses two drifted copies of the same centre and max-dimension arithmetic into one frameCamera(on:).

focusOnLoadedShape() keeps a currentSingleShape fallback for any path that sets legacyLoadedShape without installing identity. Nothing does that today; it is there so that stays a non-event rather than a silently unframed camera.

Testing

Asserts the union directly rather than the camera, because focus(on:) animates toward the framing over 0.3s and cameraState read straight afterwards is mid-interpolation, so it cannot answer what was framed.

Verified discriminating, not merely passing: reverting the union to bodyIDs.prefix(1) fails it at box.max.x → 1.0000001 against the expected 21.0000001.

The single-body companion test compares with a tolerance rather than for exact equality, which is a real finding rather than a loosened assertion: load tessellates the shape, and the bounding box read afterwards is computed over the triangulation, landing within a rounding step of the pre-tessellation box (-2.0000001000000003 against -2.0000001) rather than on it.

362 tests pass. swift-format lint --strict and swiftlint --strict are clean.

Not in this PR

OCCTSwiftUX#12 is a separate implementation in OCCTSwiftUXViewportService, not a consumer of this one, so it needs the same fix applied there. That duplication is itself in scope for the ecosystem#25 audit, and is the argument for that service eventually delegating here rather than carrying a parallel copy.

Closes the camera half of the OCCTSwift#302 multibody ripple, tracked as
ecosystem#18. Before OCCTSwift v1.11.3 a multibody STL/BREP came back as one
lumped shape, so "the first shape" genuinely was the whole model. Since v1.11.3
the importers return one body per solid, and the framing code kept asking for
the first one.

Two paths were affected, and only one of them was reported:

- `loadFile(from:progress:)` (deprecated) sets `legacyLoadedShape` to
  `result.shapes.first`, so `focusOnLoadedShape()` zoomed to body 0. This is
  what OCCTSwiftCADKit#19 and OCCTSwiftUX#12 describe.
- `focus(on:)` framed via `shape(id:)`, which returns only an entity's FIRST
  body. The multi-entity `loadFile(from:id:progress:)` registers a multibody
  file as ONE entity owning N bodies, and its own documentation tells callers
  to "call focus(on:) once you've loaded what should be visible". So the
  documented way to frame a multibody import was the broken one. The issues
  did not mention this path.

The same first-body-only assumption was fixed once before in `applySideBySide`,
whose regression test records it as "fine for the roughly-frame-the-camera use
in focus(on:)". It was not fine, and that comment is why this stayed open: the
earlier fix stopped at the boundary of the reported symptom.

Both paths now go through `combinedBounds(ofEntities:)`, which unions over
`Entity.bodyIDs` rather than `shape(id:)`. That also collapses the two copies of
the centre/max-dimension arithmetic that had drifted into `focus(on:)` and
`focusOnLoadedShape()` into one `frameCamera(on:)`.

`focusOnLoadedShape()` keeps a `currentSingleShape` fallback for any path that
sets `legacyLoadedShape` without installing identity. Nothing does that today;
the fallback is there so it stays a non-event rather than an unframed camera.

Tested by asserting the union directly rather than the camera: `focus(on:)`
animates over 0.3s, so `cameraState` read straight afterwards is mid
interpolation and cannot say what was framed. Verified discriminating by
reverting the union to `bodyIDs.prefix(1)`, which fails it at 1.0 against the
expected 21.0.

The single-body companion test compares with a tolerance rather than for exact
equality: `load` tessellates the shape, and the bounding box read afterwards is
computed over the triangulation, so it lands within a rounding step of the
pre-tessellation one rather than on it.

362 tests pass; swift-format --strict and swiftlint --strict are clean.
@kilo-code-bot

kilo-code-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • Sources/OCCTSwiftCADKit/CADViewportService.swift
  • Tests/OCCTSwiftCADKitTests/SmokeTests.swift

Reviewed by nemotron-3-ultra-550b-a55b:free · Input: 175.1K · Output: 3.1K · Cached: 125.3K

@gsdali
gsdali merged commit f47f830 into main Aug 20, 2026
5 checks passed
@gsdali
gsdali deleted the fix/18-frame-all-bodies branch August 20, 2026 08:05
gsdali added a commit that referenced this pull request Aug 20, 2026
… the dedup (#14)

* Remove the deprecated API ahead of 2.0.0

Ten deprecated public declarations go, along with the machinery that existed only
to back them. A major version is when removal is free; carrying them into 1.x
would have owed them support for the whole line.

Removed from OCCTSwiftCADKit:

  loadedShape                              -> loadedShapes / shape(id:)
  selected                                 -> selection
  selectedFace                             -> selection
  selectionSummary                         -> selectionMeasurements
  SelectionSummary (typealias)             -> SelectionMeasurements
  loadFile(from:progress:)                 -> loadFile(from:id:progress:)
  loadShape(_:id:)                         -> load(_:id:transform:)
  loadFromData(_:filename:progress:)       -> loadFromData(_:filename:id:progress:)
  CADViewportView.init x2                  -> the selection: initialiser

The removals cascade further than the declarations themselves, which is the
point. The three deprecated loaders were the only callers of
focusOnLoadedShape(), and the only writers of legacyLoadedShape and
legacyLoadedShapeEntityID. With them gone that state was written only as nil and
read only by a branch that could never be taken, so currentSingleShape now has a
single source of truth: a shape it reports is always an entity that `entities`
actually lists. remove(id:) no longer has to invalidate a parallel copy by hand.

Nothing auto-focuses now. The deprecated loaders were the only callers of
focusOnLoadedShape, and the surviving multi-entity loaders have always documented
that the camera is not auto-focused and that you call focus(on:). The multibody
framing fix from #11 lives on in focus(on:) via combinedBounds(ofEntities:),
which is where it mattered: that was the path the docs told callers to use.

Test changes are not all mechanical, so they are worth reading:

- Ten call sites moved from loadShape to load(_:id:), same behaviour at one body.
- Three tests existed only to cover deprecated semantics and are deleted:
  the loadedShape single-entity case, the selected single-selection case, and
  the SelectionSummary alias resolving.
- One asserted that a single-shape load wipes a prior multi-entity load's
  selection. That behaviour no longer exists, and the invariant that does survive
  (selection drops only entries referencing a removed entity) is already covered
  by its neighbour, so it is deleted rather than rewritten into a duplicate.
- Two are rewritten rather than deleted, because their invariant outlived their
  vehicle and nothing else asserted it: removeAll() must leave no entity, body or
  selection behind, and remove(id:) must clear an entity from every collection
  rather than only from modelBodies. Both now drive the surviving API, and both
  assert a precondition first so the removal proves something.

358 tests pass. swift-format --strict and swiftlint --strict clean.

CADViewportService 2,497 to 2,306 lines; the package 8,439 to 8,214.

Not removed: OCCTSwiftAIS/CompatibilityAliases.swift. Those three typealiases are
source-compatibility shims from the three-repo merge, deliberately never marked
deprecated because a same-module typealias shadows the type it aliases and would
warn at ~75 internal uses. Whether they survive 2.0.0 is a separate call.

* Split CADViewportService into per-domain files

2,306 lines in one file becomes a 365-line core plus seven extension files, none
over 572. This is the code-structure policy applied to the repo's largest
outstanding violation (OCCTSwiftInteraction#13), and it is much cheaper now than
after 2.0.0 freezes the public surface.

  CADViewportService.swift              365   stored state, init, shape info
  CADViewportService+Selection.swift    572
  CADViewportService+Clipping.swift     445   clipping, capping, clip-aware picking
  CADViewportService+Loading.swift      418   file import and multi-entity loading
  CADViewportService+Comparison.swift   270
  CADViewportService+ScalarFields.swift 171
  CADViewportService+Escalation.swift   155
  CADViewportService+Overlays.swift      48

It is a move: the method bodies are byte-identical, split at the `// MARK: -`
seams the file already had. Two things did have to change, and both are worth
knowing about.

**Access levels.** A Swift extension in another file cannot see `private`, so 31
of the type's 64 private members widened to `internal`; 33 stayed private. That
widening is target-scoped, not module-wide: nothing outside OCCTSwiftCADKit can
reach them. Stored state stays declared once, in the
core file, because an extension cannot hold stored properties, which is why
`pendingEscalation` and its continuation stayed behind while its behaviour moved.

**Three properties needed `internal(set)`, not plain widening.** `selection`,
`comparison` and `pendingEscalation` were `public private(set)`. Blanket-widening
made them publicly settable, which would have handed consumers write access to
state the service owns, in the release that freezes the API. `public
internal(set)` keeps them read-only to consumers exactly as before while letting
the split-out files write them.

Verified rather than assumed: the public API of `CADViewportService` and
`CADViewportView` is byte-identical to the pre-split commit, compared
declaration by declaration. 358 tests pass, swift-format --strict and
swiftlint --strict clean.

* docs: write the durable identity cookbook the code already pointed at

`FaceIdentityTable` closed with "See the durable identity cookbook
(`topology-graph-uids.md`)". That page did not exist, and it was the only dangling
doc reference in Sources.

Writing it is also the answer to the comment-ratio item on #13, though not the
answer I predicted. I had recorded the three identity tables as carrying "three
near-identical explanations". Reading them, that is wrong and worth correcting:
each carries its own genuine difference. Face has the IsSame decision and the
raw-versus-deduplicated enumeration history; Edge and Vertex exist largely to say
their ordinals were never ambiguous, because both were always built from one
TopTools_IndexedMapOfShape, so there was no split to reconcile; Vertex explains
why it holds [Shape] where its siblings hold [Shape?].

What was genuinely duplicated is the shared reasoning underneath: why the tables
exist at all, what identity means, and the pre-v2.0.0 divergence a consumer may
still carry assumptions about. That now lives in one page all three point at,
which takes FaceIdentityTable from 4.38x comment:code to 2.69x.

The page also records two things that were only in commit messages and issues:
the index-space rule behind [Shape?] (a nil keeps its ordinal's slot, because
compacting silently renames every later sub-shape, which was #9), and why
GraphUID resolution was unaffected by the face-enumeration divergence (it goes
through graph.findNode(for:), an identity lookup, never index correspondence).

Also clears the six em-dashes from the cookbook index, per the writing-style
policy's "clear them from any file you are already editing".

The ratio check still lists these files, and should. They are doc-heavy because
they encode a decision that is expensive to reconstruct, which is the difference
between a comment that earns its place and one that restates the code.

* Verify the pick-resolver deduplication by execution, not by reading

#12's duplication audit was a static read of the call graph. That confirms
OCCTSwiftAIS *calls* SubShapePickResolver, which is not the same as confirming
the two agree: a wrapper can call a resolver and still hand back something
different, by passing it different inputs. Item 4 of #13 asked for the
difference, and this is it.

Five tests. Three drive a real pick through InteractiveContext.handlePick and
independently resolve the same pick by rebuilding the resolver's inputs the way
display(_:style:) does, then compare the ordinal. Two cover what AIS legitimately
adds on top: selection-mode gating must suppress a pick the resolver would still
resolve, and with .body on and .face off a face pick must fall back to the whole
body. That fallback stays in AIS deliberately, because "the pick names the object
rather than one of its faces" is a selection decision rather than an identity
one, and OCCT draws the same line at
SelectMgr_EntityOwner::ComesFromDecomposition().

The first version of these tests did not earn its pass, and the second version
exists because of it. Comparing a single pick on triangle 0, they all passed even
with `triangleIndex + 1` injected into the AIS face wrapper: a box puts two
triangles on every face, so an off-by-one in the triangle index lands on the same
face and the comparison cannot see it. They now sweep every primitive, and the
same injection fails the face test with six issues.

Each sweep also asserts it compared more than one pick, so a future change that
makes the resolver return nil everywhere degrades to a failure rather than to a
vacuous pass over an empty loop.

363 tests. swift-format --strict and swiftlint --strict clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant