Skip to content

fix(): key models by an internal uid to avoid race conditions + add load abort signal - #305

Draft
ShaMan123 wants to merge 32 commits into
ThatOpen:mainfrom
ShaMan123:feat/load-abort-signal
Draft

ShaMan123 wants to merge 32 commits into
ThatOpen:mainfrom
ShaMan123:feat/load-abort-signal

Conversation

@ShaMan123

@ShaMan123 ShaMan123 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds a signal option to load() (#173) and fixes the concurrency bugs found along the way.

The root cause: all model state, on both threads, was keyed by the consumer's modelId, and a modelId can be reused and therefore not unique. Late async work of a model that had been disposed or replaced landed on the next model loaded under the same modelId: worker replies, queued tiles, material transfers, progress events, an in-flight dispose.

How

  • Internal uid. Each model gets a uid from FragmentsModels that is never reused. All internal routing uses it:

    • messages between threads;
    • worker assignment;
    • progress callbacks;
    • the tile queue;
    • material definitions;
    • the worker's mesh cache;
    • editor state.

    modelId stays the consumer-facing name: models.list, disposeModel(), abort() and the editor API are still keyed by it. The pattern and its rules are written up in CONTRIBUTING.md under "Design patterns".

  • dispose() takes effect before it returns. The model leaves models.list and the scene, its worker slot is freed, an in-flight load is aborted, and later requests to it reject. The returned promise resolves once the worker has deleted it. The modelId can be loaded again right away. Anything the old model's worker still sends is dropped silently.

  • load({ signal }). Aborting rejects load() with a LoadAbortedError right away, without waiting for the worker to reach its next yield point, and disposes the partial model. Disposing the model mid-load does the same.

  • Typed messaging.

    • Requests from the workers are typed as WorkerRequest, and the workers' senders are checked against it.
    • FragmentsModel._invoke() only takes method names of the worker-side model, with matching arguments. Results are still cast at the call sites, because class instances in them arrive as plain objects.

Review feedback

  • abort(modelId) is kept. It now shares the signal's code path: it aborts whichever load currently runs under that ID, with no bookkeeping of its own.
  • load() no longer throws while a disposed model is still being deleted on the worker. With uid keying, that window is harmless, so the modelId is free as soon as dispose() is called.
  • fetchMeshCompute:
    • Its sends are caught now, as are the workers' other fire-and-forget sends (material transfers and load progress).
    • The main thread awaits its handler and answers a request it fails to handle with an error, which it also logs. The workers ignore that answer.
    • Messages for a disposed model are dropped before anything can throw.
    • Answers go back through the port the request came in on and are never routed by model.

Bugs fixed (all reproducible on main)

  • Loading a modelId that is already loaded or loading silently clobbered the first model. It now throws.
  • Dispose-then-reload of a modelId:
    • the old model's late tiles, materials and progress events were applied to the new model;
    • the old dispose's threads.delete(modelId) could remove the new model's worker assignment.
  • The worker's mesh cache is shared by all models on a worker and was keyed by a hash of the modelId. Two models with the same modelId on one worker read, and evicted, each other's meshes.
  • Any request for a model without a worker (abort() of an ID that isn't loading, a call on a disposed model) spawned a new worker. Only CREATE_MODEL assigns one now; other requests reject.
  • Answers to worker requests were routed by modelId instead of going back to the worker that asked.
  • Disposing a model mid-load:
    • the load carried on and, with autoCoordinate: false, fired onModelLoaded for the dead model;
    • on the worker, the delete disposed the half-built model while it was still being generated. A delete that lands mid-load now aborts the load and waits for it to unwind.
  • Requests pending on a worker that gets terminated never settled. They now reject.
  • A FINISH from a disposed model was dropped before it could settle forceUpdateFinish().
  • Editor state was keyed by modelId, so a reloaded or saved model inherited the old one's delta models and queued element requests:
    • delta models were never disposed with their parent;
    • concurrent edits orphaned a delta model.
  • getItemsGeometry() and getGeometries() returned transform as the plain object the worker's copy arrives as, typed as a THREE.Matrix4, so calling a Matrix4 method on it threw.

Behavior changes

  • load() of a modelId that is already loaded or loading throws.
  • Aborted loads reject with a LoadAbortedError instance (it was the worker's error string). That includes an abort that lands after the worker finished, which main ignored.
  • abort(modelId) for an ID that isn't loading does nothing (it spawned a worker).
  • dispose(), disposeModel() and FragmentsModels.dispose() apply their effects synchronously and still return a promise.
  • A call on a disposed model, including a stale Element or Item, or the old model after editor.save(), rejects with "model N is not loaded". Before, it went to whatever model owned the modelId.
  • editor.save(): the new model gets its own materials instead of reusing the old one's. They leaked, since reused materials were never tracked for disposal. The old model's delta models stay in the scene until the new model replaces them.
  • Queuing editor element requests (createMaterial() etc.) for a model that isn't loaded throws.
  • Delta model IDs end in a counter (<modelId>-DELTA-MODEL-<n>) instead of performance.now(). EditUtils.getRootModelId() still parses them.
  • model.modelId no longer reads model.object.name, so renaming the object doesn't change it.
  • Worker protocol: messages address models by uid. The worker bundle must match the library version, which FragmentsModels.getWorker() already guarantees.

Internal API changes

These are documented as "don't use directly", but they are public on their classes:

  • new FragmentsModel({ modelId, uid, meshManager, threads, editor, threadGroup }) takes a single object.
  • new Editor(core) no longer takes the connection.
  • model.threads.invoke(uid, …) takes the uid. Models use model._invoke(…), which is typed against VirtualFragmentsModel.
  • MaterialManager.releaseModelSlot() is removed.
  • RequestsManager.clean(uid) and FragmentsConnection.delete(uid) take the uid, and the latter returns a promise.
  • Connection<TInput> and ThreadHandler<T> are generic, and the new WorkerRequest type is exported.
  • VirtualFragmentsModel signatures now declare what the methods already handle:
    • the highlight and visibility methods accept no ids, meaning every item;
    • getItemsData() accepts GUIDs.

Code quality

Typed the message layer (MessageBase, WorkerRequest, _invoke()) in place of any, and collapsed some indirection in Connection. Routing is synchronous, so a request that can't be routed rejects instead of staying pending forever.

Additional context

The branch history is kept for reviewers of the first version. The rework after review:

  • b48ef60 key models by an internal uid, dispose synchronously
  • 2d759f1 keep abort(modelId) (reverting its removal in a2ca482)
  • e0fc555 key editor state by the model's uid
  • 99be14b type the requests workers send to the main thread, catch the workers' fire-and-forget sends
  • 11e8e78 type _invoke() against the worker model's methods
  • 6610477 return geometry transforms as THREE.Matrix4
  • d1d3dc0 document the modelId vs uid pattern in CONTRIBUTING.md

6610477 stands on its own if you'd rather take it separately, and so does the editor commit.

Worth a close look:

  • The synchronous dispose() contract in FragmentsModel.dispose / DataManager.dispose / FragmentsConnection.delete.
  • The worker-side delete-during-load in ThreadModelDeleter.

Tests: 168 passed, plus 1 expected fail (the existing #300 repro). New coverage:

  • load/abort/dispose races: load-abort.test.ts;
  • connection deletion and worker termination: fragments-connection.test.ts;
  • delete-during-load on the real worker controllers over school_arq.frag: thread-model-deleter.test.ts;
  • editor state: edit-helper.test.ts;
  • geometry transforms: edit-manager.test.ts;
  • a disposed model's FINISH: mesh-manager-fence.test.ts.

I checked that the key tests fail without their fix. None of this has been exercised in a browser with real workers yet.


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 force-pushed the feat/load-abort-signal branch from d26e144 to a8538bb Compare September 15, 2026 07:14

@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
ShaMan123 force-pushed the feat/load-abort-signal branch from a8538bb to d7f2b80 Compare September 15, 2026 07:29

@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.

cleanup types and indirection

Comment on lines +3 to +4
/** Set on answers, in either direction, despite the name. */
toMainThread?: boolean;

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.

this needs a decision - probably a rename

@ShaMan123 ShaMan123 changed the title fix(): model loading + abort signal fix(): model loading race conditions + add abort signal Sep 15, 2026
@agviegas

Copy link
Copy Markdown
Contributor

On removing abort(modelId): once an API is published we cannot simply delete it, that would break every consumer already using it. Let's extend instead: keep abort(modelId) exactly as it is, fully supported, and add the signal option alongside it as an alternative way to do the same thing. Worth keeping in mind that the same rule then applies to the signal option itself: once it ships we are committed to it, so if you have any doubt about its shape, now is the moment to raise it rather than after release.

Two more things before this lands. load() now throws when a model is still being disposed worker-side, which is an async window a caller cannot observe, so a dispose-then-reload cycle that works today would start throwing. And Connection.fetchMeshCompute calls fetch without catching it, so a mesh compute racing a disposal becomes an unhandled rejection under the new throw. Both need a guard.

The rest of the analysis holds up well: we verified all three race claims against main, and the suite passes 129 of 129 with your changes applied.

@ShaMan123

Copy link
Copy Markdown
Contributor Author

I understand that deleting the abort function is breaking. I addressed the why briefly in the description and stressed that the feature was intended to be an abort signal, that is what I requested to begin with.
The whole point of an abort signal is scoping aborting to a single closure without state handling.
abort as a public method requires holding state.
This is a design decision about async handling, which is important to the repo because of the threading arch - AbortSignal is the industry's standard, the repo should align to it.
That is why I deleted abort - IMO breaking changes are a must in order to keep the repo's code quality.
I can look into it a different approach in order to keep it - off the top of mind is keeping an AbortController map and filling it in load and perhaps marking it a deprecated.
Thoughts?

Regarding the throws. What do you suggest?

load() now throws when a model is still being disposed worker-side, which is an async window a caller cannot observe, so a dispose-then-reload cycle that works today would start throwing.

I can suggest that aborting returns a promise that resolves once completed. This will require the consumer to wait upon it - not good DX.
Perhaps this requires further design.
I don't have the full grasp of the code so I can only suggest abstract ideas.
Can aborting/deleting a model first detach all objects before starting the async work? Can the code guarantee the async message is processed in a way that respects the sync dropping (e.g. messages are queued and nothing can override the order, a return message for a dropped model is dropped as well)?
This will safeguard the model list from concurrency (a new model starts loading before an old one with the same id has completed the abort/deletion round trip).
Anything else doesn't fix the concurrency.

Connection.fetchMeshCompute calls fetch without catching it, so a mesh compute racing a disposal becomes an unhandled rejection under the new throw. Both need a guard.

I assume this is exactly the async round trip being able to drop the message of a dropped model.

@agviegas

Copy link
Copy Markdown
Contributor

Agreed on the direction, and thanks for laying out the reasoning. AbortSignal is the right primitive and we are happy for it to become the recommended way to cancel a load. What we cannot do is remove abort(modelId) outright: a lot of people build on this library, and a published method disappearing breaks all of them at once. So let's go with the middle path you suggested: keep abort(modelId) working, backed by an AbortController map filled in load, and mark it deprecated with a pointer to the signal option. That gives users a migration path instead of a break.

On the dispose-then-reload window: rather than making load() throw while a model is still being disposed worker-side, could you drop that part from this PR? The underlying race, a new load starting before the old dispose round trip finishes, predates your change, and your idea of detaching synchronously and dropping replies for a disposed model is the right shape for it, so let's handle it as its own issue. The catch in fetchMeshCompute still needs to go in, since the new not-loaded guard in fetchConnection makes that rejection reachable.

@ShaMan123

Copy link
Copy Markdown
Contributor Author

I will rework the PR.

On the dispose-then-reload window: rather than making load() throw while a model is still being disposed worker-side, could you drop that part from this PR? The underlying race, a new load starting before the old dispose round trip finishes, predates your change, and your idea of detaching synchronously and dropping replies for a disposed model is the right shape for it, so let's handle it as its own issue. The catch in fetchMeshCompute still needs to go in, since the new not-loaded guard in fetchConnection makes that rejection reachable.

I think the simplest solution would be to manage an internal id mapping. A model will get an internal id that is unique (growing counter), modelId will be treated as metadata, a name. Methods will map back the internal id to modelId making the entire issue go away.

@ShaMan123
ShaMan123 force-pushed the feat/load-abort-signal branch from eb03100 to d3f0b16 Compare September 29, 2026 04:21
ShaMan123 and others added 3 commits September 29, 2026 08:49
Every model gets a uid from FragmentsModels that is never reused, and all
internal routing uses it instead of the modelId: messages to and from the
workers, thread assignment, progress callbacks, the tile queue, material
definitions and the worker's mesh cache. Late work of a disposed model can
no longer land on a newer model loaded under the same modelId. The public
`models.list` stays keyed by modelId.

dispose() now takes effect before it returns: the model leaves the models
list and the scene, its thread slot is freed, its in-flight load is
aborted and later requests to it reject. The returned promise resolves
once the worker has deleted the model. The modelId can be loaded again
right away.

- load() rejects as soon as it is aborted or its model is disposed,
  without waiting for the worker to unwind.
- A DELETE_MODEL that lands mid-load aborts the load on the worker
  instead of disposing the model from under it.
- Terminating a worker rejects the requests still waiting on it.
- A FINISH from a disposed model still settles update fences.
- editor.save() no longer reuses the old model's materials; they are
  disposed with it in finalizeDispose().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings back the abort() method and its tests that a2ca482 removed. Models
are keyed by uid now, so it needs no bookkeeping of its own: it looks up
the model currently loading under the ID and aborts that load the same
way its signal would. An abort can't outlive its load or reach a later
load of the same ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delta models and queued element requests belonged to a modelId, so a model
loaded under a disposed model's modelId inherited them, including after
editor.save(). They now belong to the model's uid and go away with it.

- Disposing a model disposes its delta models.
- reset() clears deltaModelId along with the delta models.
- An edit or reset whose model is disposed meanwhile disposes the delta
  it built instead of leaving it loaded; concurrent edits no longer
  orphan a delta model.
- save() keeps the old delta models in the scene until the new model
  replaces them, and does nothing if the model is disposed meanwhile.
- relate(), unrelate() and deleteData() resolve their model once, and
  drop their work if it is disposed meanwhile. createElements() returns
  the elements of the model it edited.
- Delta model IDs end in a counter instead of performance.now(), so two
  can't collide.
- Queuing element requests for a model that isn't loaded throws.

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

Copy link
Copy Markdown
Contributor Author

Looking into the fix - I prefer first fixing modelId not being unique issue and add aborting on top of that.
How do you want it? 2 separate PRs?
I will push everything here and cherry-pick if you want 2 PRs.

ShaMan123 and others added 4 commits September 29, 2026 10:53
manageRequest is now the connection's handler itself, so the connection
awaits it and answers a request it fails to handle with an error. The
requests it receives are typed as WorkerRequest, which the workers'
senders are checked against with `satisfies`, and Connection is generic
over the requests it receives.

The workers send these requests fire-and-forget, so they catch that error
answer instead of leaving an unhandled rejection; the main thread already
logs the failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
CONTRIBUTING.md gets a Design patterns section: a model's consumer-facing
modelId against the internal uid everything is keyed and routed by, why
async work needs the latter, and the rules for new code. Also updates the
mental model's stale threads.invoke(modelId, ...) line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ShaMan123 ShaMan123 changed the title fix(): model loading race conditions + add abort signal fix(): key models by an internal uid to avoid race conditions + add load abort signal Sep 29, 2026
@ShaMan123
ShaMan123 marked this pull request as draft September 29, 2026 10:54
ShaMan123 and others added 4 commits September 29, 2026 14:44
_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>
ShaMan123 and others added 15 commits September 29, 2026 14:57
…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>
The answer was posted outside the try/catch, so a result postMessage()
can't copy, such as one holding a function, threw a DataCloneError as an
unhandled rejection and the request never settled. The request is now
answered with that error instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The main thread answers a worker's request with the request itself.
RequestsManager empties a RECOMPUTE_MESHES request's tile list once it has
taken the tiles, but a disposed model's request was dropped with its list
in place, so every tile batch its worker still flushed was copied back,
buffers and all. It is emptied there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A call on a disposed model, e.g. through an Item or Element kept from
before, was rejected by the connection as "model 7 is not loaded": 7 is
the model's internal uid, which the caller has never seen. _invoke now
rejects it first as 'model "arq" is disposed'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
save() disposes the model, keeping its visuals until the reloaded model is
in the scene, and tore them down only after the reload. When the reload
rejected (an abort, the model disposed again, FragmentsModels disposed, a
worker error), the old THREE object, tiles, materials and delta models
stayed in the scene for good: dispose() had already run, so nothing could
clean them up. The teardown now runs either way, and the reloaded model
joins the scene as soon as it is loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
save() replaces the model with a reloaded one under a new uid, and
disposing the old one dropped the element requests queued for it (with
createMaterial(), setItem(), createIndex(), ...) and not applied yet, so a
later applyChanges() lost them without a word. Before requests were keyed
by uid they carried over under the modelId. They now move to the reloaded
model, temp ids included, the way save() already passes undone requests on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each edit loads a delta model built from every request the worker had
when the edit reached it, and the edit shows it once it has loaded,
replacing the one shown. Loads run concurrently, so with two edits in
flight the first edit's delta model could finish last and replace the
second's, showing the model without the second edit until the next edit
that affects rendering. A reset raced the same way: a delta model loaded
after it was shown anyway.

Edits and resets are now numbered in the order they reach the worker, and
a delta model of an earlier one than the model shows is disposed instead.

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>
getHighlight() resolved to MaterialDefinition[], but it has an entry for
every item it looks up, undefined for an item without a highlight, and a
highlight that preserves the item's original material (setColor(),
setOpacity(), resetColor(), ...) only holds the properties it changes, so
it can have no color. Both were cast away.

HighlightDefinition types such a highlight, and getHighlight() now
resolves to (HighlightDefinition | undefined)[], as does getSequenced()
for "highlight". highlight() accepts a HighlightDefinition, so a highlight
read with getHighlight() can be applied again, as the MaterialsManagement
example does. The worker's material list and the definitions it sends the
main thread are typed the same way, which drops their casts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A RECOMPUTE_MESHES request carried its tile requests as any[], built in
four places of VirtualTilesController and read in six classes on the main
thread, none of which could tell what they held. TileRequest types them,
one member per TileRequestClass, and the worker side now builds, batches
and prepares them for transfer against it. Typing the main side's reads
comes next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Everything a connection receives from the other side, requests and
answers alike, is a structured-clone copy, so ThreadHandler and fetch()
now type it as Cloned. On the main thread that reaches the classes that
take the tile and material requests, which read them as `any` until now:
RequestsManager, MeshManager, LODManager and MaterialManager are typed
against the requests, and the tile matrix and bounding box they got as
copies are rebuilt instead of passed on as THREE objects.

MaterialManager keeps the definitions the worker transfers as
HighlightDefinition[], with one documented narrowing: a tile's own
material is one of the model's, which are complete. The THROW_ERROR
branch and the templateId reads go, since no worker sends either, and a
tile's positions are typed as the Float32Array the worker allocates.

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

Copy link
Copy Markdown
Contributor Author

More work spawned out of this branch so I want to plan how to move forward.

Recommended split

Stacked, in merge order. Each boundary was verified green on upstream/main, except where marked not replayed.

PR Scope Commits Size
1 Route model calls through _invoke (P0, new) new commit 14 files, +165/−309
2 Typed RPC, and rebuilding what the worker copies 11e8e78 6610477 e434dda 68fa665′ b9e85a1 7595c93′ 8813c97 4d7e496 a7606b7 d2603f0 4f19562, plus e090aa1 (not replayed) can split into 2a (up to b9e85a1) and 2b
3 load({ signal }) and message-layer hardening 4b39c26 a2ca482 bf1237e 8a84f60 d3f0b16 3e9cb9d 5 source files
4 uid and synchronous dispose, with the fence fix b48ef60′ 2d759f1 f2a6ab5 c4ce4c4 34 source files; can't be split further
5 Editor keyed by uid, save() and delta races, docs e0fc555 2f7a1f2 65526d9 118e068 d1d3dc0 4 source files
6 Typed worker → main messages 99be14b a628456 ac18a75 4c6cdaa (not replayed) after PR4; can go in parallel with PR5

′ marks a commit adapted by hand during the rebase.

@ShaMan123

Copy link
Copy Markdown
Contributor Author

if you don't mind that everything is on this branch - then I can review it, tidy up and hand it off

@ShaMan123

Copy link
Copy Markdown
Contributor Author

Regardless the design is clear - modelId is not a uid, using a uid behind scenes is the fix.
The rest are bugs that were discovered - bad any types were masking a few.

SPLIT_PLAN.md lays out landing ThatOpen#305 as six stacked PRs on upstream/main:
which commits go where and why, what changes in the public types, what
reviewers should focus on, and the test results at each boundary from
replaying the stack, including a fence hang that rebasing onto upstream
introduces without a conflict.

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

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.

2 participants