Skip to content

fix(): type calls to a model's worker, rebuild what the worker copies - #326

Open
ShaMan123 wants to merge 15 commits into
ThatOpen:mainfrom
ShaMan123:refactor/typed-rpc
Open

ShaMan123 wants to merge 15 commits into
ThatOpen:mainfrom
ShaMan123:refactor/typed-rpc

Conversation

@ShaMan123

@ShaMan123 ShaMan123 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Part of a stack of six PRs that replaces #305. The branches live in a fork, so GitHub can't chain them: every PR is based on main, and the diff of each one includes the PRs below it. Each PR names the commits that are its own and links a comparison that shows only those. They are meant to merge in the order below, and PRs 2 to 6 stay drafts until the one before them is in.

The work. All model state, on both threads, is keyed by the consumer's modelId, and a modelId can be reused. Late async work of a model that was disposed or replaced (a worker reply, queued tiles, a material transfer, a progress event, an in-flight dispose) therefore lands on the next model loaded under the same ID. The stack gives each model an internal uid that is never reused and routes everything by it, adds load({ signal }) (#173), and types the messages between the threads, where most of the bugs it fixes were hidden behind any.

The stack, bottom to top, in merge order:

  1. refactor(): route model calls through _invoke #325 refactor(): route model calls through _invoke: mechanical groundwork, no behavior change (merged)
  2. fix(): type calls to a model's worker, rebuild what the worker copies #326 fix(): type calls to a model's worker, rebuild what the worker copies
  3. feat(): load abort signal + harden the message layer #327 feat(): load abort signal + harden the message layer
  4. fix(): key models by an internal uid, dispose synchronously #328 fix(): key models by an internal uid, dispose synchronously: the core fix
  5. fix(edit): key editor state by the model's uid #329 fix(edit): key editor state by the model's uid
  6. refactor(): type the requests workers send to the main thread #330 refactor(): type the requests workers send to the main thread

Every commit in the stack type-checks and passes the test suite on its own, and each PR builds. The top of the stack matches #305's branch merged with today's main, apart from one fix that merge needs (see PR 4) and two reworded lines.


This PR's own diff: all of it. #325, the PR below, is merged and this branch is rebased on main, so nothing in the diff belongs to another PR.

Description

PR 2 of 6. _invoke() took any string and any arguments and resolved to any, so every call site cast the result to what it hoped the worker returned. This PR types the call end to end, fixes the bugs the types exposed, and tests the result through a worker.

Typing

  • _invoke() only takes names of the worker model's methods, with arguments that match their parameters.
  • It is limited to an explicit list, RemoteMethods: the methods the main thread calls. dispose(), which deleted the worker-side model behind the main thread's back, and getItemsByConfig(), which takes a function that can't be copied to a worker, are no longer callable through it.
  • The result is typed as Cloned<T>, the structured-clone copy that actually arrives. Methods are dropped from it, so a THREE.Matrix4 is { elements, isMatrix4 } and no longer passes for the real thing.
  • With that in place the casts at the call sites go. Where a result is handed out under a named type (ItemData, SpatialTreeItem, the raw data maps), a return annotation keeps the name and is checked against the worker's result. The casts that stay are the index methods' and getMetadata()'s, whose type parameters are the caller's own claim.

Bugs the types exposed (all reproducible on main)

  • getItemsGeometry() and getGeometries() returned a transform typed as a THREE.Matrix4 that was a plain object, so calling a Matrix4 method on it threw.
  • getHighlight() and getItemsMaterialDefinition() did the same with color and THREE.Color. resetColors() was meant to rebuild it but tested isColor, which the copy keeps, so it never did.
  • getSequenced() handed out mergedBoxes (THREE.Box3), geometry transforms and highlight colors as copies too. Each kind of result is now rebuilt through a map keyed by kind, so a new kind can't be left out.
  • getBuffer() was cast to ArrayBuffer but resolves to a Uint8Array when compressed, which is the default: new DataView(buffer) type-checked and threw.
  • Attribute types were typed as numbers. They are the names the importer writes, such as "IFCLABEL", so a numeric comparison never matched.
  • getMetadata() answered null for a model saved without metadata, against its declared type.

Bug the worker tests exposed (also reproducible on main)

  • getSequenced("mergedBoxes", …) always resolved to an empty box. The worker passed the selected items' local ids to getBBoxes(), which takes item ids. The types could not catch it, both being numbers.

Public API changes

  • AttributeData.type and ItemAttributes.setType() take a string.
  • getBuffer() resolves to ArrayBuffer | Uint8Array.
  • getSequenced() can resolve to null, for a result kind the worker doesn't know, as its docs say.
  • getMetadata() resolves to {} instead of null for a model without metadata. getCRS() still resolves to null without a CRS.
  • VirtualFragmentsModel signatures declare what the methods already handle: the highlight and visibility methods accept no ids, meaning every item, and getItemsData() accepts GUIDs.
  • MaterialManager.resetColors() is replaced by restoreColor().
  • getGlobalTranformsIdsOfItems() is renamed getGlobalTransformsIdsOfItems(). The misspelled name stays on FragmentsModel, SingleThreadedFragmentsModel, IFragmentsModel and EditUtils, marked @deprecated, and calls the renamed one.

Tests

The fixes are tested end to end in fragments-model.worker.test.ts: small_test.frag is loaded and queried through a worker, using the Worker of @vitest/web-worker that vitest.setup.ts already installs. Every message crosses a real MessagePort, so results arrive as structured-clone copies, and the first test asserts exactly that. These replace this PR's earlier tests, which stubbed _invoke() with structuredClone() and so never asked the worker anything.

  • 33 queries are asked of the model behind the worker and of a SingleThreadedFragmentsModel on the same file, and must be strictly equal, classes included.
  • What the copy flattens comes back rebuilt: Matrix4 from getItemsGeometry(), getGeometries() and an item's geometry, Color from getHighlight() and getItemsMaterialDefinition(), Vector3 and Box3 from positions and boxes.
  • Seven result kinds of getSequenced() are compared with the method that returns the same thing.
  • After a real edit, with the delta model on a second worker, the moved item's transforms are Matrix4 and moved.

Two limits. The stub runs the worker in the test's own thread, so this tests what copying does, not isolation between threads. The index methods (the fixture has no indexes) and getItemDrawChunks() (it depends on which tiles are loaded) are not covered.

Additional context

Worth a close look:

  • Cloned<T> in multithreading/cloned.ts.
  • Whether the RemoteMethods list is complete.
  • restoreColor() keeps the color components as they are. The worker already converted them from sRGB, and the old fallback would have converted them a second time.
  • vitest.worker.ts, the worker the tests give to FragmentsModels. Under the stub a worker runs where window exists, so fragments-thread.ts takes itself for the main thread and doesn't listen; this file listens for it, in one line, and fragments-thread.ts is untouched. The alternative is to have fragments-thread.ts recognize the stub.

14 commits. The first 12 are one change each, and if that is too much for one review they split in two after refactor(): limit _invoke to the worker methods the main thread calls. The last two were added later: fixes moves the tests onto a worker and fixes mergedBoxes, and the last one is the rename.

Tests: 268 pass, 58 of them new (fragments-model.worker.test.ts, cloned.test.ts, get-metadata.test.ts).


What is the purpose of this pull request?

  • Bug fix
  • New Feature
  • Documentation update
  • Other

Before submitting the PR, please make sure you do the following:

  • Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
  • Follow the Conventional Commits v1.0.0 standard for PR naming (e.g. feat(examples): add hello-world example).
  • Provide a description in this PR that addresses what the PR is solving, or reference the issue that it solves (e.g. fixes #123).
  • Ideally, include relevant tests that fail without this PR but pass with it.

🤖 Generated with Claude Code

@ShaMan123 ShaMan123 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed

ShaMan123 and others added 14 commits October 11, 2026 23:31
FragmentsModel._invoke() only takes names of VirtualFragmentsModel's
methods, with arguments matching their parameters, and resolves to their
return type. A renamed or retyped worker method no longer fails at
runtime only.

It showed worker methods declaring narrower parameters than they handle:
the highlight and visibility methods take no ids to mean every item, and
getItemsData() takes GUIDs too. Their signatures now say so. Results are
still cast at the call sites: they are copied between threads, so class
instances in them arrive as plain objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FragmentsModel.getItemsGeometry() and getGeometries() returned MeshData
whose `transform`, typed as a THREE.Matrix4, was the plain object the
worker's copy arrives as, so calling a Matrix4 method on it threw. They
now rebuild it, as ItemGeometry.get() already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_invoke is typed now, and every one of these arguments matches its worker
method, so the comments only switched off the argument checking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_invoke claimed to resolve to what the worker method returns, but the
worker's answer is a structured-clone copy: class instances in it arrive
without their prototype. And nothing checked the claim, because
FragmentsConnection.fetch/invoke resolved to `any`.

Cloned<T> describes that copy: methods are dropped, so a THREE.Matrix4 is
typed as `{ elements, isMatrix4 }` and no longer passes for one. invoke<R>()
resolves to Cloned<R>, with R (default any) the method's return type, and
FragmentsConnection.fetch keeps the request's type instead of dropping it,
so the execute and box requests now declare the fields the worker answers
with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_invoke accepted any public method of VirtualFragmentsModel, including
ones that break over the wire: dispose() deleted the worker-side model
behind the main thread's back, and getItemsByConfig() takes a function,
which can't be copied to the worker. It now takes only the methods listed
in RemoteMethods, which also documents what crosses the thread boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getHighlight() and getItemsMaterialDefinition() returned definitions whose
`color`, typed as a THREE.Color, was the plain copy the worker's answer
arrives as, so calling a THREE.Color method on it threw. resetColors()
meant to rebuild it but checked `isColor`, which the copy keeps as an own
property, so it never did, and getItemsMaterialDefinition() didn't call it.

MaterialManager.restoreColor() rebuilds the color from a copied definition
and is typed that way, so a definition can't be handed out without it. It
keeps the components as they are: the worker already converted them from
sRGB, and the old fallback would have converted them again. Definitions
the worker transfers for rendering go through it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…copy

EditManager.restoreTransforms() takes the geometries as they arrive from
the worker and returns them with their transform rebuilt, so dropping the
rebuild is now a type error rather than a cast that hides it. ItemGeometry
uses it instead of its own copy of the loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getBuffer() is compressed by default, and compressed it resolves to a
Uint8Array, but it was cast to an ArrayBuffer, so `new DataView(buffer)`
type-checked and threw. The cast couldn't simply go: the worker method's
inferred type, ArrayBufferLike, absorbs Uint8Array too. The worker method
now declares both, which is also what IFragmentsModel says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Item.getAttributes() cast the worker's attributes to `type?: number`, and
AttributeData and ItemAttributes.setType() took a number too, but the
types are the names the importer writes, such as "IFCLABEL", and the
worker declares them as strings. A numeric comparison against them never
matched. They are strings now, and the cast is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getSpatialStructure(), getMetadata(), getCRS() and getSequenced() on the
worker returned `any`, so the casts on the main thread were the only type
their results had. They now declare what they return.

That showed getSequenced() handing out results as the worker's copies:
"mergedBoxes" typed as a THREE.Box3, "geometry" transforms as THREE.Matrix4
and "highlight" colors as THREE.Color were all plain objects. Each kind of
result is now rebuilt from the copy, by a map keyed by the result kind, so
a kind that needs rebuilding can't be left out. It resolves to null when
the worker doesn't know the result kind, as its documentation says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Most of them claimed exactly what _invoke already resolves to, each as a
separate unchecked claim, so a worker method changing its result would go
unnoticed. Where a result is handed out under a named type (ItemData,
SpatialTreeItem, the raw data maps, ...), a return annotation keeps that
name in the declarations and is checked against the worker's result.

The casts that stay are the index methods' and getMetadata()'s: their type
parameters are the caller's word for an index's or the metadata's shape,
which the worker can't check either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getMetadata() promises an object (IFragmentsModel types it as T), but the
worker answered null for a model whose file has no metadata, which the
main thread's cast passed through. It answers {} now, so the promise
holds without every caller having to check for null. getCRS() still
answers null for a model without a CRS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rmsIdsOfItems()

The name was missing an "s". The misspelled one stays on what the package
exports (FragmentsModel, SingleThreadedFragmentsModel, IFragmentsModel and
EditUtils) as a deprecated method that calls the renamed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ShaMan123 ShaMan123 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ready

@ShaMan123
ShaMan123 marked this pull request as ready for review October 11, 2026 20:39

This branch has not been deployed

No deployments
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