Skip to content

Latest commit

 

History

History
449 lines (362 loc) · 20.5 KB

File metadata and controls

449 lines (362 loc) · 20.5 KB

M1 · M2 · M3 — AI & Asset Registry Feature Track

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.


M1 — <include file="…"/> in mc3.xml

Purpose

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.

Semantics

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

Cycle detection

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.

Roundtrip guarantee

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 includes

When saving:

  1. <include> elements are emitted at the top of <mc3>, before other sections.
  2. 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.

Files changed (M1)

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()

M2 — Model Registry (modelregistry.sqlite3)

Purpose

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.

Actual schema (as implemented)

Note: The original design used group_name, mc3_xml, and created_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.

What the registry saves

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.

C++ interface (as implemented)

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.

Editor integration

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.

Dependency

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.

Files changed (M2)

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

M3 — AI API Integration (Claude / other LLM)

Purpose

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

Architecture

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               │
                  └────────────────────────────────────┘

AiAssistant class

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 split sendAsync into 3 params, a configurable maxTokens (default 32000, UI-adjustable up to 64000) plus wasTruncated()/stopReason() for detecting a truncated response, and a test-only apiBaseUrl seam (defaults to the real Claude API; overridden only by ai_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();
};

HTTP dependency

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
)

Editor UI (as implemented)

"AI Assistant" panel (toggle in View menu):

  • API key field (password-masked; pre-filled from ANTHROPIC_API_KEY env 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 against mc3.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

AI result lifecycle

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)

Empty-document guard (Task 2)

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.

Context management

  • "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

AI → ModelRegistry integration

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

Files changed (M3)

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

Dependency summary (actual)

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

Implementation order

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