diff --git a/common/lc_assetloader.cpp b/common/lc_assetloader.cpp index 73deea46..70a65e5a 100644 --- a/common/lc_assetloader.cpp +++ b/common/lc_assetloader.cpp @@ -34,7 +34,7 @@ bool lcAssetLoader::LoadPieceInfo(PieceInfo* Info, lcPieceLoadFlags Flags) if (Info->IsProject() || (Info->IsModel() && !Info->GetModel()->HasDirectGeometry())) Info->mState = lcPieceInfoState::Loaded; else if (Info->mState == lcPieceInfoState::Unloaded) - QueuePieceLocked(Info, Flags.testFlag(lcPieceLoadFlag::Visible) ? Priority::Visible : Priority::Background); + EnqueuePiece(Info, Flags.testFlag(lcPieceLoadFlag::Visible) ? Priority::Visible : Priority::Background); else if (Flags.testFlag(lcPieceLoadFlag::Visible)) { mQueueMutex.lock(); @@ -51,11 +51,12 @@ void lcAssetLoader::QueuePiece(PieceInfo* Info, bool PriorityHint) { Q_ASSERT(QThread::currentThread() == thread()); - QueuePieceLocked(Info, PriorityHint ? Priority::Visible : Priority::Background); + EnqueuePiece(Info, PriorityHint ? Priority::Visible : Priority::Background); } void lcAssetLoader::CancelSynthMesh(lcPiece* Piece) { + // Remove queued work immediately; running synth phases notice cancellation cooperatively. Q_ASSERT(QThread::currentThread() == thread()); mSynthPieces.erase(Piece); @@ -239,6 +240,7 @@ void lcAssetLoader::PromoteRequestPriorityLocked(const std::shared_ptr& void lcAssetLoader::OnConsumerReleased(PieceInfo* Info) { + // Ordinary running builds finish privately; discard them if their last consumer is gone. Q_ASSERT(QThread::currentThread() == thread()); if (mLibrary->HasPieceConsumers(Info)) @@ -286,7 +288,7 @@ void lcAssetLoader::OnConsumerReleased(PieceInfo* Info) mLibrary->ReleasePieceLoadHold(Info); } -void lcAssetLoader::QueuePieceLocked(PieceInfo* Info, Priority LoadPriority) +void lcAssetLoader::EnqueuePiece(PieceInfo* Info, Priority LoadPriority) { QMutexLocker QueueLock(&mQueueMutex); @@ -857,6 +859,7 @@ void lcAssetLoader::WaitForResultsLocked() bool lcAssetLoader::ProcessCompletions() { + // Bounded UI-thread publication also runs during scoped waits, without public callbacks. Q_ASSERT(QThread::currentThread() == thread()); bool Progress = false; @@ -1243,6 +1246,7 @@ bool lcAssetLoader::ProcessCompletions() void lcAssetLoader::DispatchNotifications() { + // Event-handler delivery avoids reentering consumers inside a synchronous readiness wait. Q_ASSERT(QThread::currentThread() == thread()); constexpr int MaxNotificationsPerPump = 32; @@ -1379,7 +1383,7 @@ bool lcAssetLoader::EnsurePiecesReady(const std::vector& Parts) if (Info->IsProject() || (Info->IsModel() && !Info->GetModel()->HasDirectGeometry())) Info->mState = lcPieceInfoState::Loaded; else - QueuePieceLocked(Info, Priority::Blocking); + EnqueuePiece(Info, Priority::Blocking); } { @@ -1456,7 +1460,7 @@ void lcAssetLoader::QueueModelPiece(PieceInfo* Info) Info->mState = lcPieceInfoState::Unloaded; - QueuePieceLocked(Info, Priority::Visible); + EnqueuePiece(Info, Priority::Visible); } void lcAssetLoader::InvalidatePiece(PieceInfo* Info) @@ -1547,6 +1551,7 @@ bool lcAssetLoader::HasPendingWork() void lcAssetLoader::CancelAndDrain() { + // Shutdown/reload joins obsolete workers and releases holds without requiring GL success. Q_ASSERT(QThread::currentThread() == thread()); std::deque> Cancelled; diff --git a/common/lc_assetloader.h b/common/lc_assetloader.h index e268950f..e14e4e93 100644 --- a/common/lc_assetloader.h +++ b/common/lc_assetloader.h @@ -13,6 +13,7 @@ class Image; class lcTexture; enum class lcTextureLoadError; +// Copied on the UI thread so workers do not consult live models or project lookup. struct lcPartSourceSnapshot { QString FileName; @@ -28,6 +29,7 @@ struct lcPartSourceSnapshot bool StudCylinderColorEnabled = false; }; +// Private worker output; meshes and dependencies are published only on the UI thread. struct lcPartBuildResult { lcPartBuildResult() = default; @@ -67,6 +69,9 @@ struct lcTextureBuildResult lcTextureLoadError Error{}; }; +// Owned by lcPiecesLibrary. UI-thread entry points schedule private worker builds; +// UI-thread completion processing resolves colors, uploads textures, and publishes meshes. +// See docs/asset-loading.md for readiness, ownership, and caller policies. class lcAssetLoader : public QObject { public: @@ -101,6 +106,8 @@ private: Blocking // Assets needed by a synchronous wait. }; + // Owns one PieceInfo load hold through completion and deferred notification. + // Parsing and conversion are separate worker phases; textures settle before publication. struct Request { PieceInfo* Info; @@ -109,10 +116,10 @@ private: lcPartSourceSnapshot Source; Priority LoadPriority; qint64 EnqueuedAt; - bool Running = false; - bool Obsolete = false; - bool Terminal = false; - bool Succeeded = false; + bool Running = false; // A worker phase is outstanding, including its completion. + bool Obsolete = false; // Its result must not publish, even if the build succeeds. + bool Terminal = false; // The wait has settled; this does not imply success. + bool Succeeded = false; // Published successfully, including valid empty geometry. bool ConvertingMesh = false; std::unique_ptr MeshData; std::vector ColorTranslucency; @@ -123,6 +130,7 @@ private: QString Error; }; + // Holds a texture reference through CPU decoding and UI-thread upload/retries. struct TextureRequest { lcTexture* Texture; @@ -135,6 +143,8 @@ private: qint64 RetryAt = 0; }; + // The instance cancels before destruction; workers use the copied definition/controls. + // Cancelled is also checked during expensive generation and conversion. struct SynthRequest { lcPiece* Piece; @@ -158,6 +168,7 @@ private: using TextureRequestMap = std::map>; using SynthRequestMap = std::map>; + // Workers hand off one private result here; no live asset is published by a worker. struct Completion { std::shared_ptr RequestedPart; @@ -169,6 +180,7 @@ private: std::unique_ptr SynthMesh; }; + // UI-thread delivery retains the load hold after publication, until callbacks finish. struct Notification { std::shared_ptr RequestedPart; @@ -176,17 +188,21 @@ private: QString Error; }; + // Retains the base identity; cancellation makes delivery safe after instance deletion. struct SynthNotification { std::shared_ptr RequestedSynth; PieceInfo* Info; }; - void QueuePieceLocked(PieceInfo* Info, Priority LoadPriority); + // Acquires mQueueMutex internally; callers must not already hold it. + void EnqueuePiece(PieceInfo* Info, Priority LoadPriority); + // Caller holds mQueueMutex. void PromoteRequestPriorityLocked(const std::shared_ptr& RequestedPart, Priority LoadPriority); void QueueTexture(lcTexture* Texture, Priority LoadPriority); static QString TextureFailureMessage(const lcTexture* Texture); void CancelUnusedTextureRequests(); + // Caller holds mQueueMutex. void StartWorkersLocked(); void LoadQueuedPieces(); bool ProcessCompletions(); @@ -197,17 +213,22 @@ private: void FinishSynth(const std::shared_ptr& RequestedSynth); void UploadTextureRequest(const std::shared_ptr& RequestedTexture); void FinishTextureRequest(const std::shared_ptr& RequestedTexture); + // Caller holds mQueueMutex; the condition wait releases it and reacquires it on return. void WaitForResultsLocked(); bool WaitForRequest(PieceInfo* Info); bool event(QEvent* Event) override; lcPiecesLibrary* const mLibrary; + // Protects scheduling maps/queues, worker counts, and result handoff. + // Private build data passes between worker and UI phases through completions. QMutex mQueueMutex; QWaitCondition mResultReady; + // Serializes full queue drains; scoped readiness waits do not acquire this mutex. QMutex mDrainMutex; PartRequestMap mRequests; TextureRequestMap mTextureRequests; SynthRequestMap mSynthRequests; + // UI-thread diagnostics and instance tracking; these do not own the pieces. std::unordered_map mFailedSynthErrors; std::unordered_set mSynthPieces; std::deque> mQueue; @@ -219,6 +240,7 @@ private: std::deque mCompletions; std::deque> mStarted; std::deque> mStartedTextures; + // UI-thread only; public signals are deferred until outside synchronous waits. std::deque mNotifications; std::deque mSynthNotifications; std::vector> mFutures; diff --git a/common/lc_library.h b/common/lc_library.h index 938d274f..125620fc 100644 --- a/common/lc_library.h +++ b/common/lc_library.h @@ -150,6 +150,9 @@ struct lcLibrarySource std::map Primitives; }; +// Owns catalog/local identities, sources, references, and shared render buffers. +// Its asset loader owns pending work; workers build privately and the UI thread publishes. +// See docs/asset-loading.md for the loading and lifetime contracts. class lcPiecesLibrary : public QObject { Q_OBJECT @@ -179,37 +182,58 @@ public: void SetModelPieceName(PieceInfo* Info, const char* Name); bool RenamePiece(PieceInfo* Info, const char* NewName); static std::string NormalizePieceName(const char* PieceName); + // Returns one acquired model reference, paired with ReleasePieceInfo(). PieceInfo* CreateModelPiece(const char* PieceName, Project* Project, bool& Reused); + // Returns a borrowed identity; lookup does not acquire a reference or ensure readiness. PieceInfo* FindPiece(const char* PieceName, Project* Project, bool CreateMissing, bool SearchProjectFolder); bool RemapProjectPiece(PieceInfo* Info, const QString& ProjectDirectory, bool IsPreview); + + // Asset scheduling, waits, invalidation, and consumer releases below run on the UI thread. + // Acquires one reference even on failure; always pair with ReleasePieceInfo(). + // Without Wait, true does not establish readiness. See docs/asset-loading.md. bool LoadPieceInfo(PieceInfo* Info, lcPieceLoadFlags Flags); void NotifyConsumersChanged(); + // Wait for these identities only, with temporary holds; false means failure/cancellation. + // Does not traverse container children or wait for per-instance synth geometry. bool EnsurePieceReady(PieceInfo* Info); bool EnsurePiecesReady(const std::vector& Parts); + // Replaces any pending generation for this instance; snapshots control points for workers. void QueueSynthMesh(lcPiece* Piece); void CancelSynthMesh(lcPiece* Piece); + // Caller keeps the instances alive through the wait; false means missing generated geometry. bool EnsureSynthMeshesReady(const std::vector& Pieces); QString GetSynthMeshError(const lcPiece* Piece) const; void QueueModelPiece(PieceInfo* Info); + // Obsoletes pending work and resets readiness without acquiring a reference. void InvalidatePiece(PieceInfo* Info); + // Waits through GPU upload; caller retains the texture, and false means not ready. bool EnsureTextureReady(lcTexture* Texture); + // Releases one reference and lets the loader cancel unused queued work. void ReleasePieceInfo(PieceInfo* Info); + // Lifetime hold only: does not start loading or establish readiness. void AddPieceReference(PieceInfo* Info); + // Loader helper: assumes a pending request/notification owns one in-flight hold. bool HasPieceConsumers(const PieceInfo* Info); + // Releases that in-flight hold without triggering consumer-release cancellation. void ReleasePieceLoadHold(PieceInfo* Info); void SetPieceLoadError(const PieceInfo* Info, QString Error); QString GetPieceLoadError(const PieceInfo* Info) const; void ClearPieceLoadError(const PieceInfo* Info); bool LoadBuiltinPieces(); lcPartSourceSnapshot SnapshotPieceSource(const PieceInfo* Info) const; + // Worker build service: returns private CPU data/errors without publishing asset state. lcPartBuildResult BuildPieceData(const lcPartSourceSnapshot& Source); void SaveBuiltPieceCache(const lcPartSourceSnapshot& Source, lcMesh& Mesh); + // UI-thread drain of all work, including unrelated assets; not a success check. void WaitForLoadQueue(); - // Returns a texture reference that the caller must release. + // UI-thread lookup and upload wait; returns a ready reference or nullptr on failure. + // Release a returned reference with ReleaseTexture(). lcTexture* FindTexture(const char* TextureName, Project* CurrentProject, bool SearchProjectFolder); + // UI-thread lookup/acquisition only, without decoding or upload; caller releases the reference. lcTexture* FindTextureDeferred(const char* TextureName, const std::vector& SearchDirectories); lcTextureSourceSnapshot SnapshotTextureSource(const lcTexture* Texture) const; + // Worker decode service: returns private pixels/errors without GL upload or publication. lcTextureBuildResult BuildTextureData(const lcTextureSourceSnapshot& Source); bool LoadTexture(lcTexture* Texture); void ReleaseTexture(lcTexture* Texture); @@ -302,9 +326,13 @@ protected: // project for each local PieceInfo, or nullptr after that project is destroyed. std::unordered_map mProjectPieces; + // Protects identity/reference bookkeeping and shared primitive loading state. + // Recursive because library release and lookup paths can reenter this lock. lcLibraryLoadMutex mLoadMutex; + // Serializes texture source access, including archive extraction by workers. QMutex mTextureMutex; + // Sparse errors avoid storing a QString in every catalog identity. mutable QMutex mPieceErrorMutex; std::unordered_map mFailedPartErrors; diff --git a/docs/asset-loading.md b/docs/asset-loading.md new file mode 100644 index 00000000..e2dd5b4a --- /dev/null +++ b/docs/asset-loading.md @@ -0,0 +1,455 @@ +# Asset loading + +This guide describes the current implementation. Update it with changes to +loading, ownership, readiness, scheduling, or output policy. Names below refer +to existing APIs, rather than proposed interfaces. + +This is the implementation record replacing the streaming-assets plan. It +documents the current contracts and accepted decisions, rather than retaining +completed stages or superseded proposals as future requirements. + +## What users see + +Opening a model still parses its file on the UI thread, but does not wait for +every referenced mesh or texture. Unavailable geometry initially draws as cubes; +pieces appear incrementally once their meshes **and textures** are ready. +Already-loaded shared assets can appear immediately. A disk cache hit still +requires worker loading and any texture dependencies; it is not a UI-thread fast +path and receives no special queue priority. + +The status-bar spinner is visible while the active project has pending pieces +or generated meshes. Its tooltip reports loading counts, or regeneration when +only synth work remains. Counts describe unique required `PieceInfo` identities +plus generated piece instances, not the number of placed bricks or global jobs. +Requirements include all project models and their nested dependencies, not just +the visible step. The spinner hides immediately when work settles; there is no +"all pieces loaded" message. A separate failure indicator retains error details +until the project's failures disappear or the project changes. + +Views redraw and update bounds as results arrive, but keep the user's camera. +During synth edits, the previous generated mesh stays visible until a replacement +settles. Images and geometry exports wait for their requirements and omit failed +geometry instead of saving loading cubes. See [Caller policies](#caller-policies) +for the deliberately stricter preview rules and thumbnail fallbacks. + +## Accepted decisions + +These decisions are the baseline, not open TODO items. Do not propose reversing +them merely because another design is possible or an older requirement differs. +Revisit one only at the user's request or when a concrete regression, changed +requirement, or relevant measurement invalidates its rationale. Explain that +new evidence and update this record with any agreed change. + +| Decision | Rationale and alternatives set aside | +| --- | --- | +| Keep identities, sources, references, and shared buffers in the library; pending work belongs to its loader. | One registry and one completion path avoid competing lifetime/readiness authorities. Do not create a second asset registry in the loader. | +| Use the existing Qt worker pool and shared offscreen GL context, with UI-thread publication. | Reuses existing infrastructure and keeps model mutation, color registration, upload, and rendering sequential. Do not publish assets from workers or introduce a separate renderer/context for loading. | +| Use promotion-only priorities and existing reference counts. | Models open at their last step; precise consumer demotion added bookkeeping for little demonstrated benefit. Per-consumer pointer maps, step-based priorities, outgoing-project demotion, and scoped priority removal were deliberately set aside. Accept briefly elevated work after a requesting view closes. | +| Treat missing geometry as `Failed`; keep structural type and library membership separate. | A separate Placeholder/Missing type or readiness value duplicates failure state. Cubes are shared display fallbacks, not stored asset meshes. Library membership is established explicitly by the `PieceInfo` constructor. | +| Require all includes and textures for successful asset loading; never cache an erroneous mesh as successful. | Partial private builds must not masquerade as ready assets. Permissive output is a caller policy that omits failed assets, not relaxed loader success criteria. | +| Allow model outputs to save available geometry after waiting. | Geometry exports, HTML, images, instructions, and printing do not reject the whole operation because an asset failed. Actual rendering/file failures remain errors. Strict previews and thumbnail fallback rules remain separate. | +| Keep missing HTML parts-list entries with empty thumbnails and quantities. | Missing geometry must not erase the inventory entry or substitute a loading cube. Instruction parts lists currently omit unavailable thumbnails. | +| Fit outputs to available geometry; interactive fitting includes fallbacks. | Failed cubes must not distort exported framing. Include root direct geometry and available children, preserve explicit cameras, and never automatically refit interactive views as assets arrive. | +| Build synth meshes per instance on workers; retain the previous generated mesh during edits and cancel obsolete work cooperatively. | Control points belong to placements, not shared library assets. Keep interactive edits stable while ensuring output never mistakes an older mesh for the completed replacement. | +| Keep the uncached mesh draw path and debounced shared-buffer repacking. | Measured repacking cost was small and repeated scenes benefited from shared buffers. Per-mesh buffers, visibility-triggered uploads, and mesh-buffer upload as a readiness requirement were set aside without evidence of a benefit. Texture upload remains part of readiness. | + +## Responsibilities and entry points + +| Owner | Responsibility | Source | +| --- | --- | --- | +| `lcPiecesLibrary` | Asset identities, references, source discovery, archives, primitive and mesh caches, settings, shared mesh buffers, and public forwarding APIs. | [lc_library.h](../common/lc_library.h), [lc_library.cpp](../common/lc_library.cpp) | +| `lcAssetLoader` | Pending requests, worker scheduling, private results, texture upload, publication, cancellation, readiness waits, and deferred settlement notifications. | [lc_assetloader.h](../common/lc_assetloader.h), [lc_assetloader.cpp](../common/lc_assetloader.cpp) | +| `PieceInfo` / `lcTexture` | Published geometry or texture data, readiness state, and asset metadata. | [pieceinf.h](../common/pieceinf.h), [lc_texture.h](../common/lc_texture.h) | +| Projects, models, and views | Project-local names, required-asset traversal, generated-piece ownership, bounds, fallback display, cameras, and redraws. | [project.cpp](../common/project.cpp), [lc_model.cpp](../common/lc_model.cpp), [piece.cpp](../common/piece.cpp) | + +The library owns the loader. Call library entry points from the UI thread for +asset acquisition, release, scheduling, waiting, and invalidation. Workers use +source snapshots and the library's build services; they do not consult the +active project or publish into live models. + +`FindPiece()` returns a borrowed identity, without acquiring a reference. Lookup +checks the supplied project's name index, then the shared library catalog, then +optionally an external file beside the project. Creating a missing identity +records `Failed`. `CreateModelPiece()` differs: it returns one acquired reference +for the model to release. Library membership is independent of readiness and +Part/Model/Project type. Project-local names cannot replace another project's +names. Worker include resolution uses library sources, while model geometry and +texture search directories are snapshotted before dispatch. + +### Project-local identities and source paths + +`Project::mPieceIndex` maps normalized local filenames to borrowed `PieceInfo` +pointers. "Project-local names" means lookup visibility, not object ownership: +the library tracks local identities separately in `mProjectPieces`, and reference +counts govern their lifetime. Models know their owning project from construction; +temporary models can have no project. Do not use the active project implicitly +when resolving a candidate project, external file, or preview. + +This scope lets different open/candidate/preview projects use the same submodel +name without replacing one another's identities. Containers remain structurally +Model or Project even when their direct mesh fails. The shared catalog remains +separate; worker primitive/part includes must not accidentally resolve through +a project's name index. + +Source directories are also distinct from the project's current save filename. +Models preserve asset search directories when moving between projects or saving +to a new folder, so embedded geometry can still find its source textures. Save As +uses `QSaveFile`; source state and the old filename are restored if writing or +commit fails. After a successful folder change, local external references are +remapped in place against the destination folder and direct model meshes are +invalidated/requeued. Missing replacements become failed identities and retain +their references. Saving in the same folder skips this remapping. + +Merge validates name/identity conflicts before mutating either project, renames +conflicting submodels, transfers local registrations, and preserves source +directories. Renames update live instance IDs and undo/redo snapshots. Deletion +and project teardown unregister borrowed index entries; they must not leave +lookup pointers to deleted objects. These paths are in +[project.cpp](../common/project.cpp) and [lc_model.cpp](../common/lc_model.cpp). + +## Readiness + +```mermaid +flowchart LR + subgraph Part["PieceInfo request"] + U[Unloaded] --> Q[Queued] --> L[Loading] + L --> A[AwaitingTextures] --> R[Loaded] + L --> R + L --> F[Failed] + A --> F + Q --> C[Cancelled] + end + subgraph Texture["Texture request"] + TU[Unrequested] --> TQ[Queued] --> TD[Decoding] + TD --> TC[Decoded] --> TR[Ready] + TD --> TF[Failed] + TC --> TP[RetryPending] --> TC + TC --> TF + end +``` + +The diagram shows the normal paths. Mesh parsing and conversion are separate +worker phases, so a part can return to `Queued` between phases. Invalidation or +unloading resets readiness; cancellation and stale-result disposal also have +request-level terminal flags, independent of the published asset state. + +A part becomes `Loaded` after its private mesh is committed and all declared +textures are ready. Valid empty geometry may also settle successfully. A missing +include or declared texture is a failure, even if some geometry was built. +Texture names remain in mesh sections and cache data when lookup fails. + +A texture is ready only when `IsReady()` reports `Ready` with a nonzero GL +texture. CPU pixels, `HasImageData()`, and `!NeedsUpload()` do not establish +readiness. The completion pump uploads through the global shared offscreen +context without requiring the part to be drawn. Upload failures retry at most +three times, with 50 ms and 200 ms delays; decoding failures settle immediately. +Loader-decoded textures receive `LC_TEXTURE_MIPMAPS`. + +**Container readiness is not recursive readiness.** A Project `PieceInfo`, or a +Model without direct geometry, can be `Loaded` while its children are pending +or failed. Models with directly embedded geometry have their own mesh request. +Use `lcModel::GetRequiredPieces()` and `GetRequiredSynthPieces()` for recursive +requirements, including nested and external models, with cycle protection. + +### Diagnostics and retries + +Part error text lives in the library's sparse `mFailedPartErrors` map, queried +through `GetPieceLoadError()`, rather than an empty string in every catalog part. +Synth error text is likewise sparse in the loader and available through +`GetSynthMeshError()`. Entries must be cleared when their identity is unloaded, +deleted, invalidated, or successfully rebuilt; synth cancellation also clears +its entry. Pointer-keyed errors must not survive destruction and later attach +to a different object allocated at the same address. + +Textures store a compact `lcTextureLoadError` enum. The loader's static +`TextureFailureMessage()` translates it into user-facing text when reporting a +dependency failure. Missing includes and texture names remain in diagnostics; +an empty error string alone does not prove readiness. Preserve translatable +messages and check state/result rather than parsing error text. + +Only GPU upload attempts receive the bounded automatic retry described +above. Missing files, invalid source geometry, and decode failures do not produce +an endless render-time retry loop. A strict caller reports failure; a permissive +caller omits unavailable geometry, while the loading indicator exposes errors. + +## A request from acquisition to notification + +```mermaid +sequenceDiagram + participant Caller as Caller / UI thread + participant Library as Library + participant Loader as Loader / UI thread + participant Worker as Qt worker + Caller->>Library: LoadPieceInfo(Info, Flags) + Library->>Loader: Acquire reference and queue/promote + Loader->>Worker: Immutable source snapshot + Worker-->>Loader: Private mesh data or cached mesh + Loader->>Loader: Resolve global colors + Loader->>Worker: Convert source mesh data + Worker-->>Loader: Private mesh + Loader->>Loader: Resolve textures and queue decode if needed + Loader->>Worker: Texture source snapshot + Worker-->>Loader: Decoded CPU image + Loader->>Loader: Upload texture and commit ready mesh + Loader-->>Caller: Deferred settlement notification +``` + +Texture-free meshes skip decoding/upload; cached meshes skip source parsing and +conversion. Both paths use the same dependency and publication rules. Cache +reads and mesh deserialization run on workers; source mesh color indices are +resolved on the UI thread before conversion uses a color snapshot. A corrupt +mesh cache is rebuilt privately. Failed builds are not saved as successful mesh +caches. Temporary disk textures are scoped by their resolved file identity, so +same-name PNGs in different project directories remain distinct. + +Mesh cache validity includes the cache format, source checksums, stud style, and +stud-cylinder color setting. Style flags propagate through nested primitive +includes. `LoadPrimitive()` returns `lcResult` with include errors and +clears partial data on failure, so failed primitive builds cannot seed a later +successful mesh with incomplete cached geometry. + +Workers post results and wake the loader's completion event. `ProcessCompletions()` +processes at most 16 completions per pump, including texture retries. Publication +and rendering remain sequential on the UI thread. `DispatchNotifications()` +later delivers `PartLoaded`, `PartLoadFailed`, and `GeneratedMeshSettled`, with +bounded batches and stale-target checks. Synchronous waits drive completion +processing but defer these public settlement notifications to the event handler; +they do not run an unrestricted nested UI event loop. `AssetRequestsChanged` +also signals request-list or consumer changes and is not a success notification. + +The main window coalesces updates, propagates affected bounds, and redraws views. +Its loading indicator counts the active project's requirements, including +generated meshes; unrelated work does not keep that indicator visible. Mesh +publication schedules a debounced shared-buffer repack after loader work becomes +quiescent. Until then, meshes draw through the existing uncached path. Shared +buffer upload is not part of asset readiness. + +## References, cancellation, and generated geometry + +`LoadPieceInfo()` acquires one reference even if loading fails. Always pair it +with `ReleasePieceInfo()`. Its asynchronous return value means the call did not +wait, not that the asset loaded. `AddPieceReference()` retains an identity without +starting work. `EnsurePiecesReady()` deduplicates its input, temporarily holds +each part, and releases those holds before returning. + +A part request owns an additional in-flight reference. Removing one instance +cannot destroy an asset still used by another instance or pending work. On the +last consumer release, queued work is removed; running ordinary part work is +allowed to finish and its result is discarded if no consumer survives. Successful +or failed publication keeps the request's hold through deferred notification, +then releases it. Library piece identities can remain in the catalog after their +geometry unloads; unreferenced local leaf identities can be deleted. Model and +external-project containers have their own teardown rules. + +Texture requests likewise hold a reference, and staged/published mesh sections +hold their texture dependencies. Cancellation removes unused queued texture work +without releasing another mesh's texture. Failed assets remain terminal while +retained; drawing does not retry them. Explicit invalidation or unloading can +permit a later request. + +Synth meshes are **per `lcPiece` instance**, separate from the shared library +mesh. `QueueSynthMesh()` copies control points and retains the shared synth +definition. Replacing or deleting the piece cancels its old request; workers +check an atomic cancellation flag and stale results cannot replace newer meshes. +Initial generation displays a cube. Control-point edits retain the previous +generated mesh until replacement settles; that older mesh does not satisfy a +wait for the new generation. Settlement holds the base `PieceInfo` through its +deferred notification. See [lc_synth.cpp](../common/lc_synth.cpp) and +[piece.cpp](../common/piece.cpp). + +Interactive fallback rendering, bounds, ray/box picking, and selection outlines +use the same displayed geometry and keep the original instance identity. A +model's unavailable direct geometry can display a local cube while its children +remain independently visible/selectable. Failed containers retain their +structure; deleting a model or external project detaches its pointers explicitly. +Undo restores external references using project-folder lookup, not only the +library catalog. Placeholder appearance must never change piece type or asset +ownership. + +## Scheduling and waits + +Priorities only promote: `Background < Visible < Blocking`. Ordinary thumbnail +requests start Background; asynchronous model pieces and generated meshes start +Visible, regardless of step. Synchronous waits promote their required requests +and pending texture dependencies to Blocking. Shared textures retain the highest +promotion received from any parent or direct wait. There is no consumer identity +map, priority demotion, or step-based scheduling. Priority lasts until that request +completes or is cancelled, not merely until the requesting view closes. + +At most four QtConcurrent drain workers run (fewer if the machine reports fewer +threads). Running work is nonpreemptive. Queued work gains one scheduling priority +level per ten seconds of waiting, capped at Blocking, with oldest work winning +ties. Completion processing uses promoted priorities. Acquiring a pending asset +shares its request rather than submitting another build. + +`EnsurePieceReady()` / `EnsurePiecesReady()` wait for the supplied identities, +not the entire library, and return false for failed or cancelled requirements. +They do not expand model containers themselves. `EnsureSynthMeshesReady()` waits +for the specified instances; `EnsureTextureReady()` waits through upload. +`lcModel::WaitForAssets()` combines recursive part and synth waits and updates +bounds, but deliberately ignores their failure results. `EnsureAssetsReady()` +adds strict failure reporting. `WaitForLoadQueue()` drains all pending work and +workers, including unrelated assets; it is not a scoped success check. + +### Choosing an API + +| Need | API and lifetime contract | +| --- | --- | +| Resolve a name | `FindPiece()` with the intended project and explicit `CreateMissing` / `SearchProjectFolder` arguments. The returned pointer is borrowed; retain it before keeping it beyond its owner's lifetime. | +| Retain and start ordinary asynchronous work | `LoadPieceInfo()` without `lcPieceLoadFlag::Wait`; add `Visible` for model/view work. Pair every acquisition with `ReleasePieceInfo()`, including failures. | +| Retain without starting work | `AddPieceReference()`, paired with `ReleasePieceInfo()`. This does not make geometry ready. | +| Wait for an explicit part set | `EnsurePiecesReady()` (or `EnsurePieceReady()`), check the boolean for strict consumers. Temporary holds end before return, so the caller still needs ownership if it will use the assets afterward. | +| Wait for a model, including nested synths | `WaitForAssets()` for permissive output; `EnsureAssetsReady()` for strict preview-style failure reporting. Keep the model and its pieces alive throughout. | +| Replace/wait for generated geometry | `QueueSynthMesh()` / `EnsureSynthMeshesReady()`. Supply live `lcPiece` instances; a shared part mesh or a retained previous generated mesh is not readiness for the replacement. | +| Acquire a texture directly | `FindTexture()` waits for readiness and returns an acquired texture or null. `FindTextureDeferred()` acquires without waiting; `EnsureTextureReady()` waits through GPU upload. Retain the texture during a direct wait and pair acquisition with `ReleaseTexture()`. | +| Synchronize an entire library operation | `WaitForLoadQueue()`; reserve this for operations that truly need all work drained, such as settings changes. Ordinary previews must use scoped waits. | + +Scoped waits may process unrelated completions while pumping publication, but +their completion condition depends only on the requested assets. They cannot +preempt a worker already parsing, converting, or decoding another asset. Waiting +on a single container `PieceInfo` is insufficient for a submodel preview: collect +its recursive pieces and per-instance synths, or use the model-level API. + +## Caller policies + +| Caller | Waiting and failure behavior | +| --- | --- | +| Ordinary LDraw/LDD opening and interactive views | Parse and queue, then return. Draw shared fallback cubes for unavailable geometry; recurse through available children. Update bounds and redraw as assets settle without automatically refitting the camera. | +| Inventory import / insertion layout | Wait for required geometry before computing persistent placement; failure prevents using cube bounds as final layout. | +| Preview | Wait for its recursive requirements and generated meshes; failure rejects the preview. See [lc_previewwidget.cpp](../common/lc_previewwidget.cpp). | +| Thumbnail manager | Acquire required assets asynchronously. Missing library geometry/textures produce an error thumbnail. Pending generated meshes delay rendering; synth failure can use a loaded library mesh. See [lc_thumbnailmanager.cpp](../common/lc_thumbnailmanager.cpp). | +| OBJ, COLLADA, 3DS, POV-Ray export | Wait for model requirements, then omit failed meshes and failed generated geometry. Empty output is allowed. Render/write failures still report errors. See [project.cpp](../common/project.cpp). | +| Saved images, screenshots, HTML, instructions, printing | Wait for settlement, then render available geometry without loading cubes. HTML parts lists retain missing entries and quantities with blank thumbnails; instruction parts lists omit unavailable thumbnails. Actual rendering/writing failures still fail the operation. | +| Model save, CSV, BrickLink inventory | Save references or inventory data without requiring successful geometry loading. | + +Automatic output fitting uses `lcGeometryBoundsMode::AvailableGeometryOnly`; +interactive fitting uses `IncludeFallbackGeometry`. A whole-model wait can +conservatively include later steps even when output requests an earlier step. +Scene flags preventing fallback cubes do not themselves initiate a wait or +guarantee success. See [lc_view.cpp](../common/lc_view.cpp) and +[lc_scene.h](../common/lc_scene.h). + +`SetRequireCompleteAssets(true)` suppresses unavailable fallback geometry and +marks missing assets in the scene; `SetRequireGeneratedMeshes(true)` also excludes +the fixed library mesh when the current synth generation is unavailable. A +strict renderer checks `HasMissingAssets()` after drawing. Permissive output +uses these exclusion rules without making missing assets an operation-wide error. +Do not infer readiness from a successfully allocated framebuffer or a nonempty +image: it may still contain only the available portion of a model. + +Thumbnail identity is based on `PieceInfo`, not just its filename. Pending +thumbnails retain recursive requirements and receive settlement notifications. +Category resets release thumbnail IDs before repopulating the list. The submodel +category excludes models that include the active model, preventing recursive +insertion. `lcThumbnailManager::RefreshPieces()` refreshes affected identities +and requirements after Save As remapping; preserve that refresh when changing +sources without changing pointers. The preview dock's `RefreshModel()` checks +its recursive requirements and respects its lock; it refreshes a displayed +submodel affected by editing elsewhere, not an editable temporary preview model. + +Wait and propagate final bounds **before** automatic output fitting, not only +inside the later image-rendering call. CLI automatic fitting uses the requested +step for one image and the last step for an image range, reusing that camera +across the range. Named cameras and explicit positions are preserved. Headless +HTML output likewise fits the last step and reuses its camera. Temporary steps, +cameras, framebuffer state, and the caller's GL context must be restored on +success and failure; texture upload preserves the previous context/surface. + +### Geometry collection versus inventory + +`Project::GetModelParts()` is the settled, flattened geometry list used by 3D +exporters. Entries carry an explicit mesh for generated or directly embedded +geometry, including root geometry; exporters must prefer that mesh over the +shared `PieceInfo` mesh. Failed entries and unfinished/failed synth replacements +are filtered out. A retained editing mesh is not exported as the new generation. +This list is separate from `GetPartsList()` inventory traversal: changing export +collection must not remove missing references from inventories or turn direct +model geometry into an ordinary catalog part. + +Project-local filenames are not necessarily safe export identifiers. COLLADA +and POV-Ray identifiers use `Project::MakeExportNameFragment()` and +`MakeUniqueExportIdentifier()`: preserve ASCII letters/digits, collapse invalid +runs into separators, handle empty/long names, and disambiguate collisions. +Other formats have their own naming rules. Keep these transformations at the +output boundary; do not sanitize lookup names, LDraw references, or stored IDs +to satisfy an exporter. HTML output names likewise must not become unchecked +paths derived from arbitrary submodel filenames. + +## Project replacement, settings, and shutdown + +Opening a candidate retains the current project until parsing succeeds. A failed +open discards the candidate. A successful switch releases old owners, cancels +old-only queued work, and preserves shared asset identities and requests. Neither +path demotes request priorities. + +Stud-style changes pause dispatch, finish running phases, discard staging built +with old settings, refresh sources, and rebuild affected published meshes and +generated geometry. The existing style-change path resumes and drains work. +Published ordinary meshes are rebuilt selectively using `HasStyleStud`, including +flags propagated from nested includes; a style change does not indiscriminately +invalidate every library mesh. Pending snapshots must still be refreshed so +obsolete settings cannot publish later. +Library reload and destruction call `CancelAndDrain()` before destroying sources, +registries, or GL resources. It advances the generation, cancels pending work, +joins workers, discards obsolete results, and releases notification holds without +requiring successful upload or delivering public settlement signals. + +## Performance rationale and verification + +The shared-buffer decision has measured support, not just architectural +preference. September 2026 macOS Debug measurements found a first cold cube +frame at 219 ms and a first real-mesh frame at 846 ms for a 1,689-reference +fighter model; its cold repacks took 6–7 ms. On an Apple M4 renderer, a larger +6,696-instance scene at 1280×720 had synchronized median draw times of 75.59 ms +uncached versus 35.55 ms with shared buffers, with a 4.43 ms repack. +These are historical local measurements, not portable performance guarantees. +GPU timer queries, logical GL texture storage, and process RSS do not establish +exact physical driver allocation or residency. + +Reconsider buffer ownership only with evidence of repeated repacks exceeding a +frame budget, sustained uncached-draw stalls, or substantially greater memory +pressure. Cross-platform/GPU profiling and longer soak runs are optional +follow-ups, not unfinished streaming implementation stages. + +For changes that affect these contracts, use controlled delays and failure +injection with real library/loader/model APIs. Useful acceptance scenarios are: + +| Area | What to verify | +| --- | --- | +| Cold opening and interaction | Quick cube frame, incremental textured replacement, stable selection/picking, ancestor bounds, and no automatic interactive camera changes. | +| Scoped waits and priorities | Occupy the workers, then request an unrelated preview; its dependencies progress ahead of queued ordinary work. Shared requests build/decode once; aging permits background progress. | +| Cache and dependency failures | Cold/warm/corrupt-cache equivalence; missing nested includes and missing/corrupt PNGs fail explicitly. Same-name project textures stay isolated. | +| Upload and context state | Hidden preview or CLI image completes before a normal draw/event loop. Exercise upload retries and alternate contexts/surfaces/framebuffers. | +| Ownership and replacement | Same-name active/candidate/preview models, failed opens, shared assets, last-reference release, external-file undo, and no stale success callbacks after deletion. | +| Synth lifecycle | Initial loading cube, repeated pending edits, retained old mesh, cancellation/deletion, nested thumbnails, and final geometry matching the latest control points. | +| Output policy | Strict preview failure, permissive geometry/image output, empty scenes, missing parts-list thumbnails, explicit cameras, and restored steps/state after render/write errors. | +| Reload and shutdown | Stud changes during loading, library reload, exit with jobs pending, and no-renderer drains. Test both shared-buffer and VBO-disabled rendering where relevant. | + +Historical local Debug and application AddressSanitizer runs exercised these +areas, including 120 lifecycle cycles and 2,400 control-point edits. System/Qt +frameworks were not instrumented and leak detection was disabled. Those runs +are evidence for the tested implementation, not current proof or a replacement +for checking new paths; temporary harnesses are not a maintained repository test +suite. + +## Invariants and maintenance checks + +- Keep workers on copied source/settings/control-point data and private results. + Published meshes, texture pointers, global color registration, GL upload, + model mutations, and consumer notifications belong on the UI thread. +- Protect queue decisions with the loader mutex; release it before publication, + GL upload, or settlement callbacks. Condition waits release the queue mutex + while sleeping. Do not hold library locks needed by workers while draining. +- Preserve in-flight holds through stale-result disposal and deferred notification. + Validate request identity/generation before publishing; deleting an owner must + not let a worker or callback access that deleted model/piece. +- Preserve unresolved dependency names and diagnostics through caches. A failed + include/texture must never be treated as successful partial asset loading. +- Distinguish container state, library mesh readiness, and per-instance synth + readiness. Choose a caller's failure policy explicitly; settling and succeeding + are separate decisions. +- When changing this code, exercise shared mesh/texture promotion, last-owner + cancellation, failed and successful project replacement, pending synth edits, + missing includes/PNGs, cache rebuilds, context restoration, and reload/shutdown. + Cover both strict previews and permissive outputs; previous validation is not + proof that a new lifecycle path is safe.