Reviewed against current code 2026-07-26 (SYS-W14-39): M1's <include> data
model/cycle-detection and M2's registry schema/C++ interface still match
reality. M2 now has deterministic local catalog previews, structured metadata
filters and dependency-aware local asset packs; it still has no sync/cloud
workflow. Found and fixed drift in M3's AiAssistant
class/lifecycle description (prompt caching, maxTokens, apiBaseUrl test
seam, truncation handling, and mc3.xsd validation were all added after this
doc was first written) — see the inline notes below.
Design notes for three related features that together form an asset-pipeline extension for MeshCraft. Each feature is independently useful; together they form a full loop: share assets via file includes → persist them in a registry → generate new ones with AI.
Allow one .mc3.xml file to import shared assets (definitions, materials,
textures) from another file. Useful for:
- Asset libraries shared across multiple scenes.
- Model registry integration (M2): include library files generated from the registry without inlining all content into every scene.
<!-- scene.mc3.xml -->
<mc3 version="0.3" model="MyScene">
<include file="furniture_library.mc3.xml"/>
<include file="materials_pbr.mc3.xml"/>
<objects>
<instance name="Chair1" definition="chair" position="0 0 0"/>
</objects>
</mc3>What <include> merges from the referenced file:
<definitions>→ definitions available for<instance>in the main scene<materials>→ materials available for assignment<textures>→ texture declarations
What is not merged (scene-specific):
<objects>— each scene owns its own object tree<lights>,<cameras>,<environment>,<actions>— scene-level config
Merge order: included content is loaded first; local content in the main file takes priority over identically-named entries from includes.
Paths are always relative to the file that contains the <include> element.
Each file tracks the set of files currently being processed on the call
stack (DFS-style). If a file is already in that set, parsing throws
std::runtime_error("Cyclic <include>: …").
Diamond includes (A→B, A→C, B→D, C→D) are handled by a "processed" set; D is merged exactly once and subsequent includes of D are silently skipped.
Mc3Document stores:
std::vector<std::string> includes; // relative paths, in order
std::set<std::string> includedDefs; // definition IDs from includes
std::set<std::string> includedMaterials; // material IDs from includes
std::set<std::string> includedTextures; // texture IDs from includesWhen saving:
<include>elements are emitted at the top of<mc3>, before other sections.- Definitions/materials/textures in the "included" sets are not written to the file — they live in the referenced library files.
This preserves the include structure across save/load.
| File | Change |
|---|---|
mc3/include/MeshCraft/Mc3/Mc3Document.hpp |
Add includes, includedDefs, includedMaterials, includedTextures fields |
mc3/src/Mc3XmlParser.cpp |
processIncludes(recordIncludes) + mergeInclude(); cycle detection; local-override fix (erase included IDs after local parse) |
mc3/src/Mc3XmlWriter.cpp |
Emit <include> elements; skip included defs/materials/textures; fix material id written from map key not mat.name |
mc3/mc3.xsd |
Add <include> element to root <mc3> sequence |
test/mc3_library.mc3.xml |
Sample asset library (definitions + material) |
test/scene_with_include.mc3.xml |
Sample scene using the library |
test/include_override_*.mc3.xml |
Test fixtures for local override |
test/include_lib_[ab].mc3.xml |
Test fixtures for nested includes |
test/include_nested_scene.mc3.xml |
Test fixture for nested include roundtrip |
test/include_cycle_[ab].mc3.xml |
Test fixtures for cycle detection |
mc3/test/roundtrip_test.cpp |
testInclude(), testIncludeOverride(), testIncludeNested() (with roundtrip), testIncludeCycle() |
A local SQLite database storing reusable 3D model assets. Each entry is an mc3.xml snippet (definitions + materials). MeshCraft can browse and insert models from the registry without calling an API, and AI-generated models (M3) can be saved here without applying them to the current scene.
Note: The original design used
group_name,mc3_xml, andcreated_at. The implementation uses the shorter historic column names below. Preview data is a deterministic catalog tile, not a renderer-produced scene image.
CREATE TABLE models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
grp TEXT NOT NULL DEFAULT '', -- "furniture", "architecture"
name TEXT NOT NULL, -- "chair", "arch_window"
variant TEXT NOT NULL DEFAULT '', -- "gothic", "modern", "damaged"
xml TEXT NOT NULL, -- mc3.xml content (defs + materials)
tags TEXT NOT NULL DEFAULT '', -- space-separated keywords
description TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', -- "handmade" | "ai_generated"
category TEXT NOT NULL DEFAULT '',
license TEXT NOT NULL DEFAULT '',
provenance TEXT NOT NULL DEFAULT '',
thumbnail_fingerprint TEXT NOT NULL DEFAULT '',
thumbnail_rgba BLOB, -- 64×64 RGBA catalog tile
created INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX idx_models_name ON models(name);Preview cache: the fingerprint is FNV-1a of the entry XML. Its 64×64 RGBA tile is generated locally from that fingerprint and cached in SQLite; it is regenerated on save or lazy search if the XML/cache is stale. It is an honest visual identity for browsing, not a claim to be a rendered thumbnail.
Migration: createSchema() adds the historic description/source columns
and the metadata/preview columns on open. SQLite has no ADD COLUMN IF NOT EXISTS, so only its exact duplicate-column result is ignored; another
migration failure is surfaced instead of silently leaving a partial schema.
The registry saves definitions (with their referenced materials and
textures), not arbitrary object selections. It also retains direct library
imports actually referenced by alias:definition instances in that saved
definition. The UI "Save Definition to
Registry" dialog lets the user pick a definition from the current scene and
optionally fill in group/name/variant/tags/description/source. Structured
asset metadata contributes category, license, provenance and its tag sets.
class ModelRegistry {
public:
struct Entry {
int64_t id{0};
std::string group, name, variant;
std::string xml; // serialised <mc3> fragment: defs + materials
std::string tags;
std::string description;
std::string source; // "handmade" | "ai_generated"
std::string category, license, provenance;
std::string thumbnailFingerprint;
std::vector<std::uint8_t> thumbnailRgba;
};
struct SearchFilter { std::string text, tag, category, license, provenance; };
void open(const std::filesystem::path& dbPath);
void close();
bool isOpen() const;
std::vector<Entry> search(const std::string& query);
std::vector<Entry> search(const SearchFilter& filter);
int64_t save(const Entry& e); // insert (id==0) or update (id>0)
void remove(int64_t id);
Entry entryFromDefinition(const Mc3Document& doc,
const std::string& defId,
const std::string& group,
const std::string& name,
const std::string& variant,
const std::string& tags,
const std::string& description = {},
const std::string& source = {}) const;
std::string insertIntoScene(Mc3Document& doc, const Entry& e) const;
static MaterialReport inspectMaterials(const Mc3Document& doc);
static AssetPackResult exportAssetPack(path, entries, resolvedDependencies);
static std::filesystem::path defaultPath();
};insertIntoScene returns the final definition id used in the scene (may have
a numeric suffix if the original id was already taken). It merges a saved
import declaration only when the scene's alias has the same source and hash;
a conflicting alias is rejected before scene content changes.
ImGui panel "Model Registry" (toggle in View menu):
- Live text search plus independent tag/category/license/provenance filters
- Results table: generated preview / Group / Name / Variant / category + license / Insert / X; provenance and source remain visible in the metadata hover detail
- "Material health" reports serially identical material groups and materials not referenced by a scene object or definition
- "Insert" calls
insertIntoScene(), places an<instance>in the scene, pushes undo, then refreshes local imports - "Save Definition to Registry" dialog → fields: definition (combo), group, name, variant, tags, description, source
- "Export filtered asset pack" writes a new or empty local directory with
manifest.json,entries/*.mc3.xml,thumbnails/*.rgba, and only the resolved library files required by those entries. The manifest records namespace/source/content hash and relative pack files, never host paths. An unresolved required library causes a named error; nothing is uploaded.
Default database path: ~/.meshcraft/modelregistry.sqlite3.
System libsqlite3-dev is optional (find_package(SQLite3)) and used on
desktop builds. Database browsing/saving is guarded by MESHCRAFT_HAS_SQLITE3
and unavailable on Emscripten/Android, but the pure material report and local
asset-pack exporter remain available and have a real no-SQLite regression test.
Synchronization, accounts, remote storage and collaboration are explicitly
out of scope; they need a separate product and credential design.
| File | Change |
|---|---|
include/MeshCraft/ModelRegistry.hpp |
ModelRegistry class + Entry struct |
src/MeshCraft/ModelRegistry.cpp |
SQLite wrapper; createSchema + migration |
mc3/test/mc3_registry_test.cpp |
SQLite migration/cache/filter/material/asset-pack and registry regressions |
test/model_registry_no_sqlite_test.cpp |
No-SQLite fallback + local-pack regression |
CMakeLists.txt |
Optional SQLite target plus both registry CTests |
src/MeshCraft/Application/UI/Registry.cpp |
ImGui Registry panel |
include/MeshCraft/Application/MeshCraftApplication.hpp |
Registry panel state and filter/export buffers |
Let the user provide a Claude API key and a text prompt. MeshCraft serialises the current scene (or a selected sub-tree), sends it to the AI with the prompt, and merges the AI response (a new/updated mc3.xml) back into the document.
Use cases:
- "Fill the room with furniture."
- "Add a row of columns along the east wall."
- "Generate a medieval tavern interior."
- "Make the chair look more ornate."
- "Generate a definition for a stone arch."
User types prompt → AiAssistant::sendAsync(prompt, currentXml)
│
│ (background thread, std::thread)
│
Claude API (HTTPS POST)
model: claude-sonnet-5 (default; user-editable)
system: "You are a 3D scene editor. Return only valid mc3.xml."
user: "<current scene xml>\n\nTask: <prompt>"
│
▼
response: updated mc3.xml
│
▼ (main thread, poll() checked each frame)
wasTruncated()? → stop_reason == "max_tokens": show a
"cut off, increase Max Tokens" error, skip validation
auto-validate: extract (markdown-fence/prose-tolerant)
→ repair → require <mc3 root → parse → reject if empty
→ validate against mc3.xsd (libxml2, added post-M3)
→ aiPendingDoc_ = parsed document
│
┌────────────────────────────────────┐
│ Apply to Scene (user clicks) │
│ pushUndo(); document_ = *aiPendingDoc_ │
└────────────────────────────────────┘
│
┌────────────────────────────────────┐
│ Save to Registry (user clicks) │
│ → opens save dialog, source set to │
│ "ai_generated"; aiPendingDoc_ │
│ used as source doc │
└────────────────────────────────────┘
Note (verified against
include/MeshCraft/AiAssistant.hpp, current as of this note): the class grew several members since this doc was first written — prompt caching splitsendAsyncinto 3 params, a configurablemaxTokens(default 32000, UI-adjustable up to 64000) pluswasTruncated()/stopReason()for detecting a truncated response, and a test-onlyapiBaseUrlseam (defaults to the real Claude API; overridden only byai_test.cpp's mock-HTTP-server tests, never in production). The snippet below reflects the current interface, not the original design.
class AiAssistant {
public:
std::string apiKey;
std::string model{"claude-sonnet-5"};
int maxTokens{32000}; // adjustable up to 64000 in the UI
std::string apiBaseUrl{"https://api.anthropic.com"}; // test seam only
// systemPrompt/sceneXml are sent as cached context; taskPrompt is not
// cached (it's expected to differ per request).
void sendAsync(const std::string& systemPrompt,
const std::string& sceneXml,
const std::string& taskPrompt);
bool isInFlight() const;
bool isDone() const;
bool hasError() const;
bool wasTruncated() const; // stop_reason == "max_tokens"
std::string result() const; // valid mc3.xml or empty on error
std::string errorMsg() const;
std::string stopReason() const;
void poll(); // call once per frame from the UI thread
void reset();
};cpp-httplib (single-header, MIT, no runtime deps) + system OpenSSL for HTTPS.
FetchContent_Declare(httplib
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
GIT_TAG v0.18.3
)"AI Assistant" panel (toggle in View menu):
- API key field (password-masked; pre-filled from
ANTHROPIC_API_KEYenv var) - Model field (default
claude-sonnet-5) - Scope combo: "Full scene" / "Selection only" (Send disabled when selection scope + empty selection)
- Prompt text area (multi-line)
- Send button — starts background request; clears any previous pending result
- Progress indicator while in-flight
- On response: checks for truncation (
stop_reason == "max_tokens") first, then auto-validates XML (extracts from markdown fences/prose, checks<mc3>root, rejects empty docs, validates againstmc3.xsd) - Apply to Scene — applies
aiPendingDoc_to the current document (pushes undo); does not clear the pending result so "Save to Registry" remains available - Save to Registry… — opens the Registry save dialog pre-filled with
source = "ai_generated"; available independently of whether Apply was clicked - Reset — clears both the assistant state and the pending result
Send clicked
→ aiAssistant_.reset(), aiPendingDoc_.reset(), aiValidationError_.clear()
→ request starts
Response arrives (each frame, poll() called)
→ if isDone() && !hasError() && !aiPendingDoc_ && !aiValidationError_:
if wasTruncated(): aiValidationError_ = "cut off by token limit" message
else: validateAndParseAiResponseAlg() — extract (tolerates markdown
fences / surrounding prose) → repair → check <mc3> root → parse →
reject if empty → validate against mc3.xsd (libxml2, post-M3 addition)
on success: aiPendingDoc_ = parsed doc
on failure: aiValidationError_ = error message
Apply to Scene (button active when aiPendingDoc_ is set)
→ pushUndo(), document_ = *aiPendingDoc_
→ aiPendingDoc_ NOT cleared (Save to Registry still available)
Save to Registry (button active when aiPendingDoc_ has definitions — registry NOT required to be open)
→ if registry not open: auto-opens ModelRegistry::defaultPath(); shows error on failure
→ opens registry save dialog with regSaveFromAi_ = true
→ Definition combo in save dialog lists aiPendingDoc_->definitions, not document_.definitions
→ save uses *aiPendingDoc_ as source document, not document_
→ user can save any definition from the AI response without applying it to the scene
Reset button (active when hasDone || aiPendingDoc_ || aiValidationError_)
→ aiAssistant_.reset(), aiPendingDoc_.reset(), aiValidationError_.clear()
→ if regSaveFromAi_: regSaveDlgOpen_ = false, regSaveFromAi_ = false
(prevents the registry save dialog from falling back to document_.definitions)
After parsing the AI response, if parsed.objects.empty() && parsed.definitions.empty(),
the response is rejected with an error message and aiPendingDoc_ is not set.
This prevents the AI from silently replacing the scene with an empty document.
- "Selection only" scope: serialises only selected objects + all materials/definitions
- Scope combo is disabled (Send grayed) when scope is "Selection only" and selection is empty
"Save to Registry…" is available as soon as aiPendingDoc_ is set and contains
at least one definition. The user does not need to:
- open the Registry panel first (the default DB is auto-opened on first use), or
- apply the AI result to the current scene.
The save dialog is pre-filled with source = "ai_generated" and the first
definition id as the name. The Definition combo lists the AI response's
definitions (aiPendingDoc_->definitions), not the current scene's definitions,
so the user can select any AI-generated definition to save.
| File | Change |
|---|---|
include/MeshCraft/AiAssistant.hpp |
AiAssistant class declaration |
src/MeshCraft/AiAssistant.cpp |
HTTP + threading implementation (cpp-httplib, detached std::thread -- see NEXT.md's own threading-invariant note: never std::async/std::future, which reintroduces a destructor-blocking hang-on-close already fixed once) |
src/MeshCraft/MeshCraftApplication_UiAi.cpp |
ImGui AI panel; auto-validate lifecycle; empty-doc guard |
include/MeshCraft/MeshCraftApplication.hpp |
aiAssistant_, aiPendingDoc_, aiValidationError_, scope/key/model buffers |
CMakeLists.txt |
FetchContent_Declare(httplib v0.18.3); MESHCRAFT_HAS_AI define; OpenSSL link |
| Feature | New dependency | How obtained | Notes |
|---|---|---|---|
| M1 | none | — | Pure C++ + tinyxml2 (already present) |
| M2 | system libsqlite3 | find_package(SQLite3 REQUIRED) |
Desktop only; stubs on Emscripten/Android |
| M3 | cpp-httplib v0.18.3 + system OpenSSL 3.x | FetchContent + system package | MESHCRAFT_HAS_AI guard |
M1 (include) → M2 (registry) → M3 (AI)
M1 is a prerequisite for M2 (registry can export library mc3.xml files that scenes include). M2 is helpful but not required for M3 (AI results can be merged directly without saving to the registry first).