Format: Plan for
plan-runnerskill execution. Each## Phase <code-name>section is one plan-runner invocation. Phases with### Verifysections ending incargo build/testoruv run pytestare automatically verifiable. Phases requiring running SketchUp or Tauri dev are manual verification only.
Integration of VCAD BRep kernel into SketchUp via the existing supex extension. The agent writes parametric CAD code in Loon (a Lisp with algebraic data types, type inference and square-bracket syntax), the VCAD Rust sidecar evaluates the code to produce a VCAD IR Document, evaluates it into BRep geometry, exports the resulting mesh as COLLADA DAE, and SketchUp natively imports it as a component. A standalone Tauri viewer provides high-fidelity BRep preview — the agent can read viewer state (camera, selection, annotations) and use it as context for agentic coding over the user's Loon project.
AI Agent (Claude Code, MCP client)
|
[MCP Protocol (stdio)]
|
supex Python MCP Driver
/ \
[TCP JSON-RPC :9876] [TCP JSON-RPC :9877]
| |
SketchUp Ruby Runtime VCAD Rust Sidecar
(bridge_server.rb) (loon-lang + vcad-loon + vcad-eval + vcad-kernel)
| |
SketchUp Application .cmp.oo files (source of truth)
Loon code produces pure ADT data trees — no BRep objects are created during evaluation. The BRep kernel evaluates the resulting IR in a separate step:
.cmp.oo source
| (loon-lang: parse + interpret)
Value::Adt tree (pure data — Cube, Union, Fillet, etc.)
| (vcad-loon: value_to_document)
vcad_ir::Document (DAG of CsgOp nodes)
| (vcad-eval: evaluate_document)
vcad_kernel::Solid (BRep geometry)
| (Solid::to_mesh)
TriangleMesh {vertices, indices, normals}
| (COLLADA DAE export)
.dae file → SketchUp definitions.import
- supex:
/Users/darwin/x/p/supex-ws/supex(all changes go here — branchdev) - vcad:
/Users/darwin/x/p/supex-ws/vcad(reference only — Rust crates as path deps; patches onsupex-patchesbranch) - loon:
/Users/darwin/x/p/supex-ws/loon(reference only — loon-lang crate; patches onsupex-patchesbranch)
/Users/darwin/x/p/supex-ws/
├── supex/ # Main repo — all implementation goes here (git branch: dev)
│ └── vcad/sidecar/ # Rust sidecar (Phase sidecar) — path deps to sibling vcad/loon
├── vcad/ # vcad BRep kernel (44 crates) — reference + patches
├── loon/ # Loon language interpreter — reference + patches
├── example-simple-table/ # Example project
└── research/ # Research notes
- Python MCP driver:
driver/src/supex_driver/mcp/mcp_server.py— MCP server and tool registration - TCP connections:
driver/src/supex_driver/connection/sketchup_connection.pyanddriver/src/supex_driver/connection/vcad_connection.py— JSON-RPC 2.0 clients - Ruby bridge:
runtime/src/supex_runtime/bridge_server.rb— TCP JSON-RPC server on :9876 - Ruby tools:
runtime/src/supex_runtime/tools.rb— tool dispatch - Stdlib:
stdlib/src/supex_stdlib/— SketchUp API helpers
| Decision | Choice | Rationale |
|---|---|---|
| Sidecar runtime | Rust native binary | Direct access to loon-lang, vcad-eval, vcad-kernel; native ADT cache for cross-node composition; full Loon module resolution with filesystem access; no Node.js dependency |
| Sidecar location | supex repo (vcad/sidecar/) |
Supex controls the entire stack |
| Modeling language | Loon (ADT-based Lisp) | Native vcad language; Hindley-Milner type inference; algebraic data types map directly to vcad IR; pipe-friendly subject-last convention; module system with [use ...] |
| Mesh exchange | COLLADA DAE files (obj_path field kept for wire compatibility) |
Native SketchUp definitions.import, no custom format |
| Communication | TCP JSON-RPC 2.0 on :9877 | Same pattern as supex bridge |
| Metadata in SketchUp | Attribute dictionary "vcad" | node_id + source_file, not IR |
| Viewer | Standalone Tauri app, slim R3F frontend | No HtmlDialog limits, no @vcad/app editor baggage |
| Eval concurrency | Single eval worker + queue | Serialized evaluation avoids shared mutable state in Evaluator/ADT cache; health checks bypass queue via fast-path; backpressure via bounded queue |
| Cross-node composition | ADT-level injection (Loon Value::Adt) |
Cached ADT tree injected into Loon env before eval; kernel optimizes full combined CSG tree in one pass; no mesh roundtrip or intermediate BRep serialization |
| Import resolution | Three-way AST-based protocol | Sidecar parses and rewrites AST, driver resolves references via Ruby bridge, sidecar evaluates with injected bindings; keeps sidecar SketchUp-agnostic and driver Loon-agnostic |
| Mesh authority | Driver owns all mesh state | Driver mediates sidecar -> SketchUp and sidecar -> viewer; single source of truth for revision/freshness; viewer and SketchUp never talk to sidecar directly |
| Reactive updates | Poll + coalesce + revision guard | SketchUp observer polled (no push transport needed); multi-source events coalesced into batched cascades; monotonic revision with drop-on-stale prevents rollback flicker |
| SketchUp target | 2026-only, no legacy paths | Simplified API surface (definitions.import returns ComponentDefinition directly); no fallback code for older versions |
Loon uses algebraic data types (ADT) to represent geometry. The vcad CAD library (cad-lib/src/lib.loon) is automatically prepended before user code. Convenience constructors use subject-last convention for pipe compatibility.
Primitives:
[cube x y z]→Cube f64 f64 f64[cylinder r h]→Cylinder f64 f64[sphere r]→Sphere f64[cone rb rt h]→Cone f64 f64 f64
Booleans (subject-last):
[union other s]→Union Solid Solid[difference tool s]→Difference Solid Solid[intersection other s]→Intersection Solid Solid
Transforms (subject-last):
[translate x y z s]→Translate Solid f64 f64 f64[rotate x y z s]→Rotate Solid f64 f64 f64[scale x y z s]→Scale Solid f64 f64 f64
Features (subject-last):
[fillet r s]→Fillet Solid f64[chamfer d s]→Chamfer Solid f64[shell t s]→Shell Solid f64
Patterns (subject-last):
[linear-pattern dx dy dz count spacing s]→LinearPattern Solid f64 f64 f64 Int f64[circular-pattern ox oy oz ax ay az count angle s]→CircularPattern ...
Sketch + extrude:
[sketch ox oy oz xx xy xz yx yy yz segments]→Sketch ...[extrude dx dy dz sk]→Extrude Sketch f64 f64 f64[revolve aox aoy aoz adx ady adz angle sk]→Revolve ...
Sweep/Loft:
[sweep-line sx sy sz ex ey ez sk]→SweepLine ...[sweep-helix radius pitch height turns sk]→SweepHelix ...[loft sketches]→Loft (Vec Sketch)
Scene/Material:
[root solid "material-name"]→SceneEntry Solid Str[material "name" r g b metallic roughness]→Material ...
Pipe operator (thread-last):
[pipe [cube 50.0 30.0 5.0]
[difference [cylinder 3.0 10.0]]
[fillet 1.0]
[translate 0.0 0.0 10.0]]
Key crates used by the sidecar:
// loon-lang: parse + evaluate
loon_lang::parser::parse(source: &str) -> Result<Vec<Expr>, ParseError>
loon_lang::interp::eval_program(exprs: &[Expr]) -> IResult // IResult = Result<Value, InterpError>
loon_lang::interp::eval_program_with_base_dir(exprs: &[Expr], base_dir: Option<&Path>) -> IResult
// vcad-loon: Loon source -> vcad IR Document
vcad_loon::eval_vcad(source: &str, base_dir: Option<&Path>) -> Result<Document, String>
vcad_loon::eval_vcad_file(path: &Path) -> Result<Document, String>
vcad_loon::eval_vcad_to_value(source: &str, base_dir: Option<&Path>) -> Result<Value, String>
vcad_loon::value_to_document(value: &Value) -> Result<Document, String>
// Internally: prepends lib.loon, parses, evaluates, converts Value::Adt -> Document
// vcad-eval: Document -> EvaluatedScene (BRep + meshes)
vcad_eval::evaluate_document(doc: &Document, options: &EvalOptions) -> Result<EvaluatedScene, EvalError>
// EvalOptions { skip_clash_detection: bool }
// EvaluatedScene { parts: Vec<EvaluatedPart>, part_defs, instances, clashes }
// EvaluatedPart { mesh: EvaluatedMesh, material: String, solid: Option<Solid> }
// EvaluatedMesh { positions: Vec<f32>, indices: Vec<u32>, normals: Option<Vec<f32>> }
// NOTE: normals is Option — handle None case in mesh export
// vcad-kernel: BRep operations + mesh
vcad_kernel::Solid::volume(&self) -> f64
vcad_kernel::Solid::surface_area(&self) -> f64
vcad_kernel::Solid::bounding_box(&self) -> ([f64;3], [f64;3])
vcad_kernel::Solid::is_empty(&self) -> bool
vcad_kernel::Solid::to_mesh(&self, segments: u32) -> TriangleMesh
// TriangleMesh { vertices: Vec<f32>, indices: Vec<u32>, normals: Vec<f32> }
// NOTE: field is `vertices` not `positions` (different from EvaluatedMesh)The last expression in a .cmp.oo file is evaluated by the Loon interpreter to produce an ADT value. vcad-loon converts this to a Document. For Phase sidecar-mcp-tools, each file must produce exactly one renderable part, which maps to one SketchUp ComponentDefinition.
This is enforced by sidecar validation (not only convention):
scene.parts.len() == 0->NO_GEOMETRYscene.parts.len() == 1-> validscene.parts.len() > 1->MULTI_PART_UNSUPPORTED
For multi-part assemblies, the user structures the project as:
project/
├── src/
│ └── build.loon # Shared: parametric geometry functions
├── base-plate.cmp.oo # Exports one Solid (base plate)
├── bracket-left.cmp.oo # Exports one Solid (left bracket)
├── bracket-right.cmp.oo # Exports one Solid (right bracket)
└── bolt-pattern.cmp.oo # Exports one Solid (bolt array)
; src/build.loon — shared parametric logic
[pub let plate-w 200.0]
[pub let plate-d 150.0]
[pub fn make-plate [] [cube plate-w plate-d 10.0]]
[pub fn make-bracket [side]
[let offset [if [= side :left] -80.0 80.0]]
[pipe [cube 10.0 40.0 60.0]
[translate offset 0.0 5.0]]]
[pub fn make-bolts []
[pipe [cylinder 4.0 15.0]
[linear-pattern 50.0 0.0 0.0 4 50.0]]]
; base-plate.cmp.oo — one Solid out
[use build]
[build.make-plate]
; bracket-left.cmp.oo — one Solid out
[use build]
[build.make-bracket :left]
Each .cmp.oo produces one ComponentDefinition in SketchUp. The user places instances and arranges hierarchy in SketchUp manually (or via agent MCP tools).
Loon module system ([use ...]) resolves imports from the project root via the filesystem. The sidecar passes base_dir to eval_vcad_file(), enabling seamless cross-file imports. Shared functions in build.loon return Solid ADT values — since these are pure data (not BRep objects), they serialize/copy trivially.
ADT composition (Phase adt-imports): After evaluating a .cmp.oo, the sidecar retains the Loon Value::Adt tree (the result before conversion to Document). When another node imports :solid from a vcad-backed entity, the cached ADT is injected as a Loon binding — the importing code operates on it with normal cad-lib functions ([difference imported], [fillet 2.0 imported]). The combined ADT tree flows through the full pipeline (value_to_document -> evaluate_document) so the kernel optimizes the entire CSG tree in one pass — no mesh roundtrip, no intermediate BRep serialization.
- Branch:
dev(never directly onmain) - Version: DO NOT BUMP without explicit request
- Commits: commit after each phase (plan-runner default)
- SketchUp support target: 2026 only (no legacy compatibility paths)
- Emoji: DO NOT USE in documentation
Create a Rust sidecar binary in vcad/sidecar/ that listens on TCP :9877, evaluates Loon code via the vcad Rust crate pipeline (loon-lang + vcad-loon + vcad-eval + vcad-kernel), and returns OBJ meshes with geometry metadata. The phase outcome is a working vcad.eval_code and vcad.eval_file via JSON-RPC.
Create vcad/sidecar/Cargo.toml:
[package]
name = "supex-vcad-sidecar"
version = "0.1.0"
edition = "2021"
[dependencies]
# vcad evaluation pipeline (path deps to supex-ws siblings)
loon-lang = { path = "../../../loon/crates/loon-lang" }
vcad-loon = { path = "../../../vcad/crates/vcad-loon" }
vcad-eval = { path = "../../../vcad/crates/vcad-eval" }
vcad-kernel = { path = "../../../vcad/crates/vcad-kernel" }
vcad-ir = { path = "../../../vcad/crates/vcad-ir" }
# JSON-RPC + serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["clock"] }
blake3 = "1"
# Temp files
tempfile = "3"
# Signal handling
ctrlc = "3"
# Logging
tracing = "0.1"
tracing-subscriber = "0.3"Path deps assume workspace layout:
/Users/darwin/x/p/supex-ws/
supex/vcad/sidecar/ # this Cargo project
vcad/crates/ # vcad Rust crates
loon/crates/ # loon-lang crate
vcad/sidecar/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point: start TCP server
│ ├── server.rs # TCP listener, JSON-RPC dispatch
│ ├── evaluator.rs # Loon evaluation pipeline
│ ├── obj_export.rs # EvaluatedMesh -> Wavefront OBJ
│ ├── adt_cache.rs # Loon ADT cache for cross-node composition (Phase adt-imports)
│ └── config.rs # Port, host, temp_dir from env
Newline-delimited JSON-RPC 2.0 server on 127.0.0.1:9877 (configurable via VCAD_PORT).
Concurrency model:
- Network I/O accepts multiple TCP clients concurrently.
- Geometry evaluation is serialized through a single eval worker (queue) that owns
Evaluator. - Lightweight methods (
hello,ping,resources/list) are handled immediately and never wait behind long eval jobs.
This preserves safe single-evaluator semantics while keeping health checks responsive under load.
Pattern: follow runtime/src/supex_runtime/bridge_server.rb — same JSON-RPC format, same error codes.
Backpressure/timeouts:
VCAD_MAX_QUEUE(default64) limits queued eval jobs.VCAD_EVAL_TIMEOUT_MS(default120000) limits wait time for one eval request.- Queue overflow returns structured JSON-RPC error
VCAD_QUEUE_FULL. - Eval timeout returns structured JSON-RPC error
VCAD_EVAL_TIMEOUT.
Graceful shutdown:
- On SIGTERM/SIGINT, sidecar stops accepting new TCP connections and drains in-flight work.
- Listener socket is closed immediately (no new connections accepted).
- Running eval job is allowed to finish (bounded by
VCAD_EVAL_TIMEOUT_MS). - Pending queued jobs are dropped (their
reply_txchannels are dropped, clients get connection-closed). - Open TCP connections finish their current response, then close.
- Artifact pair write in progress completes or rolls back via pending marker (crash-recovery handles the rest on next startup).
- Exit code 0 on clean shutdown, non-zero on timeout or error.
Security model:
- Bind to loopback by default (
127.0.0.1). - Non-loopback bind is allowed only with
VCAD_ALLOW_REMOTE=1and configuredVCAD_AUTH_TOKEN. - Clients authenticate in
helloparams viatoken; missing/invalid token returnsAUTH_REQUIRED/AUTH_INVALID. - Path-bearing operations (
vcad.eval_file, export paths, future file writes) must validate against connection workspace root; violations returnPATH_NOT_ALLOWED. - Reject path traversal outside workspace (including
../escapes and disallowed absolute paths). - Manifest paths are internal-only: derive from accepted
obj_pathand reject any read/write outside workspace-scoped allowed roots.
JSON-RPC contract (aligned with supex bridge):
hello— handshake (returns{version, engine: "rust", protocol_version, capabilities, limits})ping— health checkresources/list— optional sidecar resources/capabilities listingtools/call— domain method dispatch viaparams.name+params.arguments
hello expected params:
name,version,agent,pid(same shape as supex bridge)workspace(used for path policy)token(required when sidecar auth is enabled)protocol_version(driver-supported protocol major, required)requested_capabilities(optional list for explicit negotiation)
Handshake compatibility rules:
- sidecar responds with
protocol_version(major) and optionalprotocol_minor - if requested/advertised major versions differ, sidecar returns
PROTOCOL_MISMATCH - no migration/fallback mode for protocol mismatch in this plan; incompatibility is fail-fast
- sidecar advertises
capabilities+limits; driver must gate feature usage accordingly
hello response additions:
capabilitiesexample:imports.data_extractsimports.solid_adtviewer.relayauth.required
limitsexample:max_queueeval_timeout_mspayload_inline_max
Supported tools/call names:
vcad.eval_code(code: string)— evaluates Loon string, returns mesh + metadatavcad.eval_file(path: string)— evaluates.cmp.oofile (with module resolution)vcad.inspect(code_or_path: string)— returns volume, bbox, surface_area (no OBJ export)vcad.eval_repl(code: string)— evaluates Loon and returns display string (no mesh)
Unified application error taxonomy (use these error_code values across sidecar/driver/ruby/viewer-relay):
| error_code | Layer | Meaning |
|---|---|---|
NO_GEOMETRY |
sidecar | scene produced zero parts |
MULTI_PART_UNSUPPORTED |
sidecar | scene produced more than one part in single-component phase |
VCAD_QUEUE_FULL |
sidecar | eval queue capacity exceeded |
VCAD_EVAL_TIMEOUT |
sidecar | eval request timed out while waiting for worker |
AUTH_REQUIRED |
sidecar | auth token required but missing |
AUTH_INVALID |
sidecar | auth token provided but invalid |
PROTOCOL_MISMATCH |
sidecar/driver/viewer relay | incompatible protocol major versions |
CAPABILITY_UNAVAILABLE |
driver | requested feature not present in negotiated capabilities |
STATE_RECONCILE_REQUIRED |
driver | persisted runtime state diverged from SketchUp/runtime reality |
SOURCE_FILE_MISSING |
driver | node exists in SketchUp but source file is missing/unreadable during recovery |
SCHEMA_VALIDATION_FAILED |
driver/sidecar/viewer relay | payload violates negotiated JSON schema contract |
ARTIFACT_READ_FAILED |
driver diagnostics | artifact manifest cannot be read (missing/corrupt/io) |
PATH_NOT_ALLOWED |
sidecar/ruby/driver | workspace path policy violation |
IMPORT_DEFINITION_NOT_FOUND |
ruby runtime | import did not return expected definition |
PAYLOAD_TOO_LARGE |
viewer relay | inline payload exceeds allowed size |
SOLID_IMPORT_UNAVAILABLE |
import pipeline | required solid-import capability not available |
Error response contract:
- Always return machine-readable
error_codeseparate from humanmessage. - Optional
detailsobject may include operation, path, counts, etc. - For selected error codes,
detailshas required fields defined in Phase error-details. - Tests must assert
error_code, not message substrings.
// server.rs — request/response types
#[derive(Deserialize)]
struct JsonRpcRequest {
jsonrpc: String, // "2.0"
id: serde_json::Value,
method: String,
params: Option<serde_json::Value>,
}
#[derive(Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
id: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Serialize)]
struct JsonRpcError {
code: i32,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<serde_json::Value>, // {"error_code": "...", "details": {...}}
}Server loop:
fn run(config: &Config) {
let listener = TcpListener::bind((config.host.as_str(), config.port))?;
eprintln!("vcad sidecar listening on :{}", config.port);
// Shutdown signal: set by SIGTERM/SIGINT handler.
let shutdown = Arc::new(AtomicBool::new(false));
{
let shutdown = shutdown.clone();
ctrlc::set_handler(move || {
eprintln!("vcad sidecar shutting down...");
shutdown.store(true, Ordering::SeqCst);
}).expect("Failed to set signal handler");
}
let (job_tx, job_rx) = std::sync::mpsc::sync_channel::<EvalJob>(config.max_queue);
// Single eval worker owns Evaluator; all heavy eval requests are serialized here.
// Exits when job_tx is dropped (all senders gone after shutdown).
std::thread::spawn({
let temp_dir = config.temp_dir.clone();
let temp_ttl_sec = config.temp_ttl_sec;
let temp_max_files = config.temp_max_files;
let adt_cache_max = config.adt_cache_max;
move || {
let mut evaluator = Evaluator::new(temp_dir, temp_ttl_sec, temp_max_files, adt_cache_max);
while let Ok(job) = job_rx.recv() {
let result = dispatch_eval(job.request, &mut evaluator);
let _ = job.reply_tx.send(result);
}
}
});
// Non-blocking accept loop: check shutdown flag between accepts.
listener.set_nonblocking(true)?;
while !shutdown.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
stream.set_nonblocking(false)?;
let job_tx = job_tx.clone();
let eval_timeout_ms = config.eval_timeout_ms;
std::thread::spawn(move || {
handle_connection(stream, job_tx, eval_timeout_ms);
});
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => return Err(e.into()),
}
}
// Shutdown: drop job_tx so eval worker drains current job and exits.
drop(job_tx);
eprintln!("vcad sidecar stopped");
}
fn handle_connection(
stream: TcpStream,
job_tx: std::sync::mpsc::SyncSender<EvalJob>,
eval_timeout_ms: u64,
) {
let reader = BufReader::new(&stream);
let mut writer = BufWriter::new(&stream);
for line in reader.lines() {
let request: JsonRpcRequest = serde_json::from_str(&line?)?;
// Fast-path methods bypass eval queue so health checks stay responsive.
let response = match request.method.as_str() {
"hello" | "ping" | "resources/list" => dispatch_fast(&request),
_ => {
// dispatch_eval validates auth + workspace path policy before execution.
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
let job = EvalJob { request, reply_tx };
if job_tx.send(job).is_err() {
error_response("VCAD_QUEUE_FULL", "Evaluation queue is full")
} else {
match reply_rx.recv_timeout(std::time::Duration::from_millis(eval_timeout_ms)) {
Ok(resp) => resp,
Err(_) => error_response("VCAD_EVAL_TIMEOUT", "Evaluation timed out"),
}
}
}
};
serde_json::to_writer(&mut writer, &response)?;
writer.write_all(b"\n")?;
writer.flush()?;
}
}Connection context requirements:
- Persist
workspaceand auth state fromhelloper TCP connection. - Persist negotiated
protocol_version,capabilities, andlimitsfromhello. - Reject non-
hellomethods until identification succeeds. - For
vcad.eval_file(path)and any file-writing tool, validate path against connection workspace before enqueueing eval job.
Encapsulates the full Loon -> Document -> Solid -> mesh -> OBJ pipeline.
use vcad_loon::{eval_vcad, eval_vcad_file, eval_vcad_to_value};
use vcad_eval::{evaluate_document, EvalOptions};
use vcad_ir::Document;
struct TempRetention {
ttl_sec: u64,
max_files: usize,
seq: u64,
}
pub struct Evaluator {
temp_dir: PathBuf,
adt_cache: AdtCache,
retention: TempRetention,
}
#[derive(Serialize)]
pub struct EvalResult {
pub obj_path: String,
pub manifest_path: String,
pub volume: f64,
pub surface_area: f64,
pub bbox: BBox,
pub is_empty: bool,
}
#[derive(Serialize)]
pub struct ArtifactManifest {
pub status: String, // applied | stale_dropped | superseded
pub node_id: Option<String>,
pub revision: Option<u64>,
pub request_id: Option<String>,
pub source_file: Option<String>,
pub source_hash: String,
pub obj_path: String,
pub volume: f64,
pub surface_area: f64,
pub bbox: BBox,
pub queued_at: Option<String>,
pub started_at: Option<String>,
pub finished_at: String,
}
#[derive(Serialize)]
pub struct BBox {
pub min: [f64; 3],
pub max: [f64; 3],
}
impl Evaluator {
pub fn new(temp_dir: PathBuf, ttl_sec: u64, max_files: usize, adt_cache_max: usize) -> Self {
std::fs::create_dir_all(&temp_dir).ok();
let max_files = max_files.max(1);
let seq = Self::load_retention_seq(&temp_dir);
let mut this = Self {
temp_dir,
adt_cache: AdtCache::new(adt_cache_max),
retention: TempRetention {
ttl_sec,
max_files,
seq,
},
};
this.recover_incomplete_artifact_pairs().ok();
this
}
fn cleanup_temp_dir(&self) {
// Remove expired files by ttl_sec and trim to max_files newest files.
}
fn load_retention_seq(temp_dir: &Path) -> u64 {
let mut max_seq = 0_u64;
let Ok(entries) = std::fs::read_dir(temp_dir) else {
return 0;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("obj") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some((_, seq_str)) = stem.rsplit_once('-') else {
continue;
};
if seq_str.len() != 20 || !seq_str.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
if let Ok(seq) = seq_str.parse::<u64>() {
max_seq = max_seq.max(seq);
}
}
max_seq
}
fn next_obj_path(&mut self, name: &str) -> Result<PathBuf, String> {
let safe_name = Self::sanitize_obj_name(name);
loop {
let next_seq = self.retention.seq.checked_add(1)
.ok_or_else(|| "TEMP_SEQ_EXHAUSTED: cannot allocate unique OBJ filename".to_string())?;
self.retention.seq = next_seq;
let candidate = self.temp_dir.join(format!(
"{}-{:020}.obj",
safe_name,
self.retention.seq,
));
if !candidate.exists() {
return Ok(candidate);
}
}
}
fn sanitize_obj_name(name: &str) -> String {
// Keep [A-Za-z0-9-_], replace others with '_'.
}
/// Evaluate inline Loon code (no module resolution).
pub fn eval_code(&mut self, code: &str) -> Result<EvalResult, String> {
let doc = eval_vcad(code, None)?;
self.evaluate_and_export(&doc, "eval")
}
/// Evaluate .cmp.oo file (with module resolution via base_dir).
pub fn eval_file(&mut self, path: &str) -> Result<EvalResult, String> {
let file_path = Path::new(path);
let doc = eval_vcad_file(file_path)?;
let stem = file_path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
self.evaluate_and_export(&doc, stem)
}
/// Evaluate Loon code and return display string (REPL mode, no mesh).
pub fn eval_repl(&self, code: &str) -> Result<String, String> {
let value = eval_vcad_to_value(code, None)?;
Ok(format!("{}", value))
}
/// Enforce single-part contract for this phase.
fn select_single_part<'a>(
&self,
scene: &'a vcad_eval::EvaluatedScene,
) -> Result<&'a vcad_eval::EvaluatedPart, String> {
match scene.parts.len() {
0 => Err("NO_GEOMETRY: scene produced zero parts".to_string()),
1 => Ok(&scene.parts[0]),
n => Err(format!(
"MULTI_PART_UNSUPPORTED: scene produced {} parts",
n
)),
}
}
/// Inspect: evaluate and return only geometry metadata (no OBJ export).
pub fn inspect(&self, code_or_path: &str) -> Result<EvalResult, String> {
let doc = if code_or_path.ends_with(".cmp.oo") || code_or_path.ends_with(".loon") {
eval_vcad_file(Path::new(code_or_path))?
} else {
eval_vcad(code_or_path, None)?
};
let options = EvalOptions { skip_clash_detection: true };
let scene = evaluate_document(&doc, &options)
.map_err(|e| format!("Evaluation error: {:?}", e))?;
let part = self.select_single_part(&scene)?;
let solid = part.solid.as_ref()
.ok_or_else(|| "No BRep solid produced".to_string())?;
let (bb_min, bb_max) = solid.bounding_box();
Ok(EvalResult {
obj_path: String::new(),
manifest_path: String::new(),
volume: solid.volume(),
surface_area: solid.surface_area(),
bbox: BBox { min: bb_min, max: bb_max },
is_empty: solid.is_empty(),
})
}
fn evaluate_and_export(&mut self, doc: &Document, name: &str) -> Result<EvalResult, String> {
// Pre-clean before export to reclaim expired/overflow files even if write fails.
self.cleanup_temp_dir();
let options = EvalOptions { skip_clash_detection: true };
let scene = evaluate_document(doc, &options)
.map_err(|e| format!("Evaluation error: {:?}", e))?;
let part = self.select_single_part(&scene)?;
// Extract geometry metadata from Solid
let (volume, surface_area, bbox, is_empty) = if let Some(ref solid) = part.solid {
let (bb_min, bb_max) = solid.bounding_box();
(solid.volume(), solid.surface_area(), BBox { min: bb_min, max: bb_max }, solid.is_empty())
} else {
// Fallback: compute bbox from mesh positions
let bb = compute_mesh_bbox(&part.mesh);
(0.0, 0.0, bb, false)
};
// Build deterministic artifact files.
let obj_content = mesh_to_obj(&part.mesh);
let obj_path = self.next_obj_path(name)?;
let manifest_path = Self::manifest_path_for_obj(&obj_path, &self.temp_dir)?;
let manifest = ArtifactManifest {
status: "applied".to_string(),
node_id: None,
revision: None,
request_id: None,
source_file: None,
source_hash: Self::hash_document(doc),
obj_path: obj_path.to_string_lossy().into_owned(),
volume,
surface_area,
bbox: BBox { min: bbox.min, max: bbox.max },
queued_at: None,
started_at: None,
finished_at: Self::now_rfc3339(),
};
// Publish OBJ + manifest as an atomic pair (with crash-recovery marker).
Self::write_artifact_pair_atomic(
&self.temp_dir,
&obj_path,
&obj_content,
&manifest_path,
&manifest,
)
.map_err(|e| format!("Artifact pair write error: {}", e))?;
self.cleanup_temp_dir();
Ok(EvalResult {
obj_path: obj_path.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
volume,
surface_area,
bbox,
is_empty,
})
}
fn write_artifact_pair_atomic(
allowed_root: &Path,
obj_path: &Path,
obj_content: &str,
manifest_path: &Path,
manifest: &ArtifactManifest,
) -> Result<(), String> {
let canonical_root = allowed_root
.canonicalize()
.map_err(|e| format!("PATH_NOT_ALLOWED: root canonicalize failed: {}", e))?;
for p in [obj_path, manifest_path] {
let parent = p.parent().ok_or_else(|| "PATH_NOT_ALLOWED: missing parent".to_string())?;
let canonical_parent = parent
.canonicalize()
.map_err(|e| format!("PATH_NOT_ALLOWED: parent canonicalize failed: {}", e))?;
if !canonical_parent.starts_with(&canonical_root) {
return Err("PATH_NOT_ALLOWED: artifact path outside allowed root".to_string());
}
}
let obj_tmp = obj_path.with_extension("obj.tmp");
let manifest_tmp = manifest_path.with_extension("json.tmp");
let pair_marker = obj_path.with_extension("pair.pending");
let marker = serde_json::json!({
"obj_path": obj_path,
"manifest_path": manifest_path,
"created_at": Self::now_rfc3339(),
});
let marker_body = serde_json::to_vec_pretty(&marker)
.map_err(|e| format!("Pair marker serialize error: {}", e))?;
std::fs::write(&pair_marker, marker_body)
.map_err(|e| format!("Pair marker write error: {}", e))?;
std::fs::write(&obj_tmp, obj_content)
.map_err(|e| format!("OBJ temp write error: {}", e))?;
let body = serde_json::to_vec_pretty(manifest)
.map_err(|e| format!("Manifest serialize error: {}", e))?;
std::fs::write(&manifest_tmp, body)
.map_err(|e| format!("Manifest temp write error: {}", e))?;
std::fs::rename(&obj_tmp, obj_path)
.map_err(|e| format!("OBJ publish rename error: {}", e))?;
std::fs::rename(&manifest_tmp, manifest_path)
.map_err(|e| format!("Manifest publish rename error: {}", e))?;
std::fs::remove_file(&pair_marker).ok();
Ok(())
}
fn recover_incomplete_artifact_pairs(&mut self) -> Result<(), String> {
// On startup, scan *.pair.pending markers and rollback incomplete pairs.
// Recovery rule: if marker exists, remove tmp files and any half-published pair,
// then remove marker so retention sees only committed pairs.
Ok(())
}
fn manifest_path_for_obj(obj_path: &Path, allowed_root: &Path) -> Result<PathBuf, String> {
let canonical_root = allowed_root
.canonicalize()
.map_err(|e| format!("PATH_NOT_ALLOWED: root canonicalize failed: {}", e))?;
let parent = obj_path
.parent()
.ok_or_else(|| "PATH_NOT_ALLOWED: obj parent missing".to_string())?;
let canonical_parent = parent
.canonicalize()
.map_err(|e| format!("PATH_NOT_ALLOWED: parent canonicalize failed: {}", e))?;
if !canonical_parent.starts_with(&canonical_root) {
return Err("PATH_NOT_ALLOWED: manifest path outside allowed root".to_string());
}
let file_name = obj_path
.file_name()
.ok_or_else(|| "PATH_NOT_ALLOWED: obj filename missing".to_string())?;
Ok(canonical_parent.join(file_name).with_extension("manifest.json"))
}
fn hash_document(doc: &Document) -> String {
// Deterministic hash of evaluated source/document for reproducibility diagnostics.
format!("blake3:{}", blake3::hash(format!("{:?}", doc).as_bytes()).to_hex())
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
}use vcad_eval::EvaluatedMesh;
/// Convert EvaluatedMesh to Wavefront OBJ text format.
/// EvaluatedMesh has: positions: Vec<f32>, indices: Vec<u32>, normals: Option<Vec<f32>>
/// Units: mm (vcad default).
/// Note: OBJ carries geometry only (no materials). Material metadata (color, metallic,
/// roughness) is delivered separately via the viewer relay `mesh.update` message and
/// via SketchUp attribute dictionaries — not embedded in the OBJ file.
pub fn mesh_to_obj(mesh: &EvaluatedMesh) -> String {
use std::fmt::Write;
let mut obj = String::new();
// Vertices: v x y z
for i in (0..mesh.positions.len()).step_by(3) {
writeln!(obj, "v {} {} {}",
mesh.positions[i], mesh.positions[i+1], mesh.positions[i+2]).unwrap();
}
// Normals: vn nx ny nz (if available)
let has_normals = mesh.normals.as_ref().map_or(false, |n| !n.is_empty());
if let Some(ref normals) = mesh.normals {
for i in (0..normals.len()).step_by(3) {
writeln!(obj, "vn {} {} {}",
normals[i], normals[i+1], normals[i+2]).unwrap();
}
}
// Faces: f v1//n1 v2//n2 v3//n3 (with normals) or f v1 v2 v3 (without)
for i in (0..mesh.indices.len()).step_by(3) {
let v1 = mesh.indices[i] + 1;
let v2 = mesh.indices[i+1] + 1;
let v3 = mesh.indices[i+2] + 1;
if has_normals {
writeln!(obj, "f {}//{} {}//{} {}//{}",
v1, v1, v2, v2, v3, v3).unwrap();
} else {
writeln!(obj, "f {} {} {}", v1, v2, v3).unwrap();
}
}
obj
}
/// Compute bounding box from mesh positions (fallback when Solid not available).
/// Note: EvaluatedMesh.positions is Vec<f32>, convert to f64 for BBox.
pub fn compute_mesh_bbox(mesh: &EvaluatedMesh) -> BBox {
let mut min = [f64::MAX; 3];
let mut max = [f64::MIN; 3];
let positions = &mesh.positions;
for i in (0..positions.len()).step_by(3) {
for j in 0..3 {
let v = positions[i + j] as f64;
min[j] = min[j].min(v);
max[j] = max[j].max(v);
}
}
BBox { min, max }
}Retained for Phase adt-imports cross-node composition. Stores evaluated Loon ADT trees (Value::Adt) keyed by node_id. When another node imports :solid, the cached ADT is injected as a Loon binding — the importing code uses it with normal cad-lib functions.
Eviction policy:
- Maximum entries controlled by
VCAD_ADT_CACHE_MAX(default256). - LRU eviction: on
set, if cache is at capacity, the least-recently-used entry is evicted. getcounts as access (updates LRU order).invalidateandclearare immediate (no LRU interaction).- DAG-tracked nodes are never auto-evicted while their node is active in the driver DAG — the driver calls
invalidateexplicitly on node removal.
use loon_lang::interp::Value;
use std::collections::HashMap;
pub struct AdtCache {
cache: HashMap<String, CacheEntry>,
max_entries: usize,
access_seq: u64,
}
struct CacheEntry {
value: Value,
last_access: u64,
}
impl AdtCache {
pub fn new(max_entries: usize) -> Self {
Self {
cache: HashMap::new(),
max_entries: max_entries.max(1),
access_seq: 0,
}
}
pub fn set(&mut self, node_id: &str, value: Value) {
self.access_seq += 1;
self.cache.insert(node_id.to_string(), CacheEntry {
value,
last_access: self.access_seq,
});
self.evict_if_needed();
}
pub fn get(&mut self, node_id: &str) -> Option<&Value> {
self.access_seq += 1;
let seq = self.access_seq;
self.cache.get_mut(node_id).map(|entry| {
entry.last_access = seq;
&entry.value
})
}
pub fn has(&self, node_id: &str) -> bool {
self.cache.contains_key(node_id)
}
pub fn invalidate(&mut self, node_id: &str) {
self.cache.remove(node_id);
}
pub fn clear(&mut self) {
self.cache.clear();
}
pub fn len(&self) -> usize {
self.cache.len()
}
fn evict_if_needed(&mut self) {
while self.cache.len() > self.max_entries {
let lru_key = self.cache.iter()
.min_by_key(|(_, e)| e.last_access)
.map(|(k, _)| k.clone());
if let Some(key) = lru_key {
self.cache.remove(&key);
} else {
break;
}
}
}
}mod config;
mod evaluator;
mod obj_export;
mod server;
mod adt_cache;
fn main() {
tracing_subscriber::fmt::init();
let config = config::Config::from_env();
eprintln!("vcad sidecar v{} (Rust + loon-lang)", env!("CARGO_PKG_VERSION"));
server::run(&config);
}pub struct Config {
pub host: String,
pub port: u16,
pub temp_dir: PathBuf,
pub temp_ttl_sec: u64,
pub temp_max_files: usize,
pub max_queue: usize,
pub eval_timeout_ms: u64,
pub adt_cache_max: usize,
pub allow_remote: bool,
pub auth_token: Option<String>,
}
impl Config {
pub fn from_env() -> Self {
Self {
host: std::env::var("VCAD_HOST").unwrap_or_else(|_| "127.0.0.1".into()),
port: std::env::var("VCAD_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(9877),
temp_dir: std::env::var("VCAD_TEMP_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("vcad-sidecar")),
temp_ttl_sec: std::env::var("VCAD_TEMP_TTL_SEC")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3600),
temp_max_files: std::env::var("VCAD_TEMP_MAX_FILES")
.ok()
.and_then(|s| s.parse().ok())
.filter(|v: &usize| *v > 0)
.unwrap_or(500),
max_queue: std::env::var("VCAD_MAX_QUEUE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(64),
eval_timeout_ms: std::env::var("VCAD_EVAL_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(120_000),
adt_cache_max: std::env::var("VCAD_ADT_CACHE_MAX")
.ok()
.and_then(|s| s.parse().ok())
.filter(|v: &usize| *v > 0)
.unwrap_or(256),
allow_remote: std::env::var("VCAD_ALLOW_REMOTE")
.ok()
.map(|s| s == "1")
.unwrap_or(false),
auth_token: std::env::var("VCAD_AUTH_TOKEN").ok(),
}
}
}cd /Users/darwin/x/p/supex-ws/supex/vcad/sidecar && cargo build && cargo test
# runtime behavior check:
# - enqueue a long eval request
# - verify `ping` responds while eval is in progress
# - verify queue overflow returns VCAD_QUEUE_FULL and timeout returns VCAD_EVAL_TIMEOUT
# - evaluate source that produces >1 part and assert MULTI_PART_UNSUPPORTED
# - verify remote bind without VCAD_AUTH_TOKEN is rejected
# - verify eval_file path traversal (`../`) returns PATH_NOT_ALLOWED
# - run many evals and assert retained files in VCAD_TEMP_DIR stay <= VCAD_TEMP_MAX_FILES
# - verify OBJ/manifest retention remains paired (no orphan manifest without OBJ)
# - verify eval response includes manifest_path and manifest contains deterministic source_hash + timestamps
# - verify manifest path escapes (`../`) are rejected with PATH_NOT_ALLOWED
# - verify symlinked temp path outside workspace is rejected with PATH_NOT_ALLOWED
# - inject fault between OBJ/manifest publish steps and verify no committed half-pair is visible
# - restart sidecar and verify startup recovery scan removes incomplete pair markers/tmp files
# - age out temp files and assert cleanup respects VCAD_TEMP_TTL_SEC
# - restart sidecar and verify subsequent exports do not overwrite existing retained OBJ files
# - simulate OBJ write failure and verify retention cleanup still runs (pre-write + failure path)
# - seed `*-18446744073709551615.obj` and verify export returns TEMP_SEQ_EXHAUSTED instead of hanging
# - all negative-path checks assert `error_code` field (not message substring)
# graceful shutdown check:
# - send SIGTERM during idle -> sidecar exits with code 0
# - send SIGTERM during active eval -> running eval finishes, sidecar exits with code 0
# - send SIGTERM with queued pending jobs -> pending jobs are dropped, running job completes
# - verify no new TCP connections are accepted after SIGTERMAdd TCP client for the vcad sidecar and sidecar process lifecycle management to the supex Python driver. No MCP tools yet — just the connection infrastructure.
TCP client for the vcad sidecar. Copies the pattern from connection.py (SketchupConnection):
class VcadConnection:
"""TCP JSON-RPC 2.0 client for vcad Rust sidecar."""
def __init__(self, host, port, timeout, agent):
# Same structure as SketchupConnection
def send_command(self, method: str, params: dict) -> dict:
# JSON-RPC request/response
def eval_code(self, code: str) -> dict:
return self.send_command("vcad.eval_code", {"code": code})
def eval_file(self, path: str) -> dict:
return self.send_command("vcad.eval_file", {"path": path})
def inspect(self, code_or_path: str) -> dict:
return self.send_command("vcad.inspect", {"code_or_path": code_or_path})
def get_vcad_connection(agent: str = "unknown") -> VcadConnection:
"""Singleton factory — mirrors get_sketchup_connection."""Transport behavior must mirror SketchupConnection:
send_command("vcad.eval_code", ...)is encoded as JSON-RPCtools/callwith{"name": "vcad.eval_code", "arguments": {...}}hello,ping, andresources/listremain direct JSON-RPC methodshellomust includeworkspaceand (when configured)tokenfor sidecar auth/path policy
Protocol negotiation requirements:
- driver sends
protocol_versioninhelloand validates sidecar response before first tool call - on major mismatch, fail startup path with
PROTOCOL_MISMATCH(hard fail) - do not auto-downgrade/upgrade protocol version; no compatibility fallback path
- driver stores negotiated
capabilitiesandlimitsin connection state - if any operation requires a missing capability, fail-fast with
CAPABILITY_UNAVAILABLE CAPABILITY_UNAVAILABLEdetails are required:required_capability,negotiated_capabilities,operation- no graceful degrade/silent fallback for vcad operations when capability negotiation fails
Connection env vars: VCAD_HOST (default localhost), VCAD_PORT (default 9877), VCAD_TIMEOUT (default 30.0 — larger than SU due to evaluation time), VCAD_AUTH_TOKEN (optional; required if sidecar auth enabled), VCAD_STATE_PATH (optional persisted state path; default <workspace>/.supex/vcad-state.json).
Related sidecar/runtime env vars: VCAD_MAX_QUEUE (default 64), VCAD_EVAL_TIMEOUT_MS (default 120000), VCAD_ADT_CACHE_MAX (default 256), VCAD_ALLOW_REMOTE (default 0), VCAD_TEMP_TTL_SEC (default 3600), VCAD_TEMP_MAX_FILES (default 500), VCAD_TRIGGER_COALESCE_MS (default 150).
Retry, reconnect, idle timeout — same as SketchupConnection.
Driver error mapping policy:
- preserve sidecar/ruby
error_codeunchanged in MCP tool responses - only transport-level failures (connection/protocol/timeout before JSON-RPC response) may use driver-local error codes
Result freshness guard (stale-result protection):
- Driver owns per-node monotonic
revisioncounters (incremented on each trigger before eval starts). - Every eval/update task carries
{node_id, revision}metadata through sidecar request, SketchUp update call, and viewer relay publish. - Driver applies geometry only when
revision == current_revision(node_id). - If an older result arrives late, driver drops it (
stale_dropped += 1) and does not mutate SketchUp or viewer state. - Prevents rollback/flicker when rapid edits enqueue overlapping evaluations.
Supersede-aware queue pruning (same-node pending optimization):
- When a new eval job for
node_idis enqueued, older pending (not-yet-started) jobs for the samenode_idare markedsuperseded. - Eval worker checks supersede status immediately before starting compute; superseded jobs are skipped without evaluation.
- Running evaluation is never cancelled; stale-result guard still protects apply phase.
- No new client-facing error code is introduced; this is internal scheduling optimization only.
Trigger coalescing window (multi-source burst merging):
- Driver merges rapid change events from
fs-watch,mod-track,su-observer, and manual updates into one pending node set. - Coalescing window is controlled by
VCAD_TRIGGER_COALESCE_MS(default150). - Exactly one topological cascade is scheduled per coalesced batch.
- Events arriving during an active cascade are queued for the next batch; no parallel cascades.
Persistent runtime state (restart recovery baseline):
- Driver persists workspace-local vcad runtime state in
<workspace>/.supex/vcad-state.json(override viaVCAD_STATE_PATH). - Persist per node:
{node_id, source_file, revision, applied_revision, last_entity_id, status}. statusvalues:active(normal),degraded(recoverable inconsistency),orphan(present in SU but not tracked by source).- Writes are atomic (temp file + rename) to avoid partial state on crash.
Startup recovery + reconciliation flow:
- Load persisted state file (if present).
- Call SketchUp bridge
list_vcad_nodesto obtain authoritative in-model nodes. - Reconstruct in-memory DAG + revision counters from persisted state.
- Compare persisted/runtime snapshots and classify drift:
missing_node: persisted node absent in SketchUporphan_definition: SketchUp vcad definition absent in persisted staterevision_gap: applied_revision behind actual update intentsource_missing: source file missing/unreadable
- Reconcile policy:
- no drift: keep graph, continue normal operation
- drift on known source files: run targeted
vcad_update_cascadefor affected roots - source missing: mark node
degraded, returnSOURCE_FILE_MISSING, skip destructive overwrite - unrecoverable drift set: return
STATE_RECONCILE_REQUIREDwithdetails.drift
Sidecar restart behavior:
- Driver treats sidecar restart as cache-loss event (ADT/import caches invalid).
- On reconnect, rerun reconciliation for nodes with dependencies and rebuild sidecar caches by cascading in topological order.
- During reconciliation, apply stale-result guard so late pre-restart responses cannot overwrite recovered state.
class VcadSidecar:
"""Manages vcad sidecar Rust binary process lifecycle."""
def __init__(self, sidecar_path: str | None = None):
self.sidecar_path = sidecar_path or os.environ.get("VCAD_SIDECAR_PATH")
self.process: subprocess.Popen | None = None
def ensure_running(self) -> None:
"""Start sidecar if not running. Check health via ping."""
def stop(self) -> None:
"""Graceful shutdown (SIGTERM)."""Auto-start: get_vcad_connection() calls VcadSidecar.ensure_running() before the first connection. Sidecar binary path defaults to <supex_root>/vcad/sidecar/target/release/supex-vcad-sidecar.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_connection
# stale-result guard check:
# - enqueue two eval/update requests for same node (slow old + fast new)
# - verify only new revision is applied; old result is dropped
# - verify stale_dropped metric > 0
# supersede pruning check:
# - enqueue multiple rapid pending updates for same node before worker starts
# - verify only newest pending revision is evaluated; older pending jobs are skipped
# - verify counters: superseded_dropped_total and superseded_skipped_before_eval_total
# trigger coalescing check:
# - emit rapid fs-watch/mod-track/su-observer events within coalesce window
# - verify one merged cascade run for union of affected nodes
# - verify events during active cascade are deferred to next batch (no parallel cascade)
# protocol handshake check:
# - simulate sidecar protocol major mismatch -> expect PROTOCOL_MISMATCH
# - simulate missing required capability -> expect CAPABILITY_UNAVAILABLE with details.required_capability, details.negotiated_capabilities, details.operation
# - positive path (SketchUp 2026-only config): negotiated capabilities + limits populated in connection state
# - verify mismatch path is fail-fast (no downgrade/fallback attempt)
# restart recovery check:
# - restart driver while SketchUp model stays open; reload persisted state and reconcile via list_vcad_nodes
# - verify DAG/revision state reconstructed without duplicate imports
# - remove a source file and verify node is marked degraded with SOURCE_FILE_MISSING
# sidecar restart under load:
# - restart sidecar during queued evals
# - verify driver reconnects, rebuilds cache via topological cascade, and avoids stale rollbackStandalone native viewer application using Tauri with a slim React frontend. Renders triangle meshes with PBR materials. In this phase the viewer loads meshes from local files (OBJ or JSON) — no sidecar connection yet.
@vcad/app is a full CAD editor (~80% editor-specific: FeatureTree, SketchToolbar, PropertyPanel, DrawingView, PrintPanel, CamPanel, AIPanel, auth, onboarding). Only the rendering pipeline (~15-20%) is relevant. Building a slim viewer from the same npm primitives is cleaner than stripping the editor.
vcad/viewer/ — Tauri project with slim React + R3F frontend.
vcad/viewer/
├── src-tauri/ # Rust backend
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ └── src/
│ └── main.rs # Tauri entry, IPC commands (load mesh from file)
├── src/ # Frontend (~300-500 lines in this phase)
│ ├── main.tsx # React entry
│ ├── App.tsx # Top-level: Canvas + UI shell
│ ├── Viewport.tsx # R3F Canvas, lights, post-processing
│ ├── SceneMesh.tsx # Mesh rendering with PBR materials
│ ├── ViewerControls.tsx # OrbitControls, grid, gizmo
│ └── store.ts # Zustand: meshes, camera, selection
├── package.json
├── vite.config.ts
├── tailwind.config.ts
└── index.html
{
"dependencies": {
"@react-three/fiber": "^9",
"@react-three/drei": "^10",
"@react-three/postprocessing": "^3",
"three": "^0.172",
"react": "^19",
"react-dom": "^19",
"zustand": "^5"
},
"devDependencies": {
"vitest": "^3",
"@react-three/test-renderer": "^9",
"happy-dom": "^17"
}
}No @vcad/app, no @vcad/auth, no @vcad/core. The viewer receives pre-computed meshes (positions, indices, normals) — it doesn't need the BRep engine.
The viewer is a dumb mesh renderer:
- Loads triangle meshes from local files (OBJ or JSON with positions/indices/normals)
- Renders with PBR materials (color, metallic, roughness)
- OrbitControls for camera, grid plane, ambient occlusion
- Selection: click mesh -> highlight
- Tauri IPC:
load_mesh(path)command from Rust backend loads OBJ and pushes to frontend
The evaluation stays in the Rust sidecar. The viewer never evaluates Loon or creates Solids.
#[tauri::command]
fn load_mesh(path: String) -> Result<MeshData, String> {
// Read OBJ file, parse into positions/indices/normals
// Return to frontend via IPC
}This enables manual testing: build a mesh with the sidecar, then open the OBJ in the viewer.
- Via CLI:
./scripts/launch-vcad-viewer.sh - Dev mode:
cd vcad/viewer && npm run tauri dev
Two testing layers, both run in Node via vitest (no browser, no display server needed):
Store + logic tests (vitest + happy-dom):
store.test.ts— Zustand store: add/remove/update meshes, camera state, selection statemesh-parsing.test.ts— OBJ parse logic, positions/indices/normals extraction
Scene graph tests (vitest + @react-three/test-renderer):
SceneMesh.test.tsx— mesh component receives positions/indices/normals, creates correct BufferGeometry attributesViewport.test.tsx— scene contains expected number of meshes after store updates, material properties applied
The test renderer creates a reconciled R3F scene graph without WebGL — tests verify structure and props, not pixel output. Visual verification remains manual (npm run tauri dev).
cd /Users/darwin/x/p/supex-ws/supex/vcad/viewer && npx vitest runManual visual check (not run by plan-runner): cd vcad/viewer && npm run tauri dev
Connect the viewer to the Python driver via WebSocket relay for live mesh push. The driver bridges sidecar evaluation outputs to the viewer and exposes viewer state/screenshot via MCP tools.
- Phase viewer (Tauri viewer with R3F rendering)
- Phase vcad-conn (driver ↔ sidecar connection)
New WebSocket server in the driver (alongside MCP server), on configurable port (default :9878).
Security model: loopback-only (127.0.0.1). No authentication token required — the relay carries pre-computed mesh data (not source code or credentials) and is not exposed beyond the local machine. If VCAD_VIEWER_RELAY_ALLOW_REMOTE=1 is set in the future, authentication must be added before that is allowed.
Driver -> Viewer:
mesh.update {node_id, revision, positions, indices, normals, material, bbox}— push mesh after evaluationmesh.remove {node_id}— remove meshscene.snapshot {nodes: [...]}— full latest-state snapshot after viewer reconnectscene.reset— clear all meshes
Viewer -> Driver:
viewer.state {camera, selection}— periodic or on-changeviewer.ready {protocol_version, features}— connection established + capability advertisement
Driver -> Sidecar transport (unchanged):
- TCP JSON-RPC (
tools/call) forvcad.eval_*,vcad.inspect, etc.
No WebSocket endpoint is required in the sidecar in this phase.
Viewer relay compatibility rules:
- driver validates
viewer.ready.protocol_versionbefore sending mesh traffic - major mismatch => reject viewer data-plane session and return
PROTOCOL_MISMATCHfor viewer-dependent tools - driver enables only negotiated viewer
features; unsupported requests fail-fast withCAPABILITY_UNAVAILABLE - viewer capability failures must include
details.required_capability,details.negotiated_capabilities, anddetails.operation - after successful
viewer.ready, driver sends exactly onescene.snapshotcontaining only latestrevisionper node - driver does not replay historical mesh events on reconnect (snapshot replaces event backlog replay)
WebSocket client that connects to the driver relay, receives mesh push messages, and updates Zustand store. Replaces file-based loading from Phase viewer as the primary mesh source.
vcad/viewer/src/
...existing files...
DriverRelayClient.ts # WebSocket client to driver relay (NEW)
Driver maintains latest viewer state and can request screenshots over the relay channel. MCP tools read these values from driver-owned state (no sidecar viewer RPC needed).
- Viewer snapshot source is driver-applied mesh state (
applied_revisionmap), including data restored by startup reconciliation.
Freshness rule on relay publishes:
- Driver publishes
mesh.updateonly for the current per-noderevision. - Viewer keeps
latest_revision_by_nodeand ignores any message with lowerrevision. - This ensures no stale render rollback during overlapping evaluations.
Screenshot output model (file-first, no large inline payloads):
- Viewer sends PNG bytes to driver over relay.
- Driver stores screenshot under workspace temp (default:
<workspace>/.tmp/vcad-viewer/shot-<timestamp>.png). vcad_viewer_screenshotreturns JSON metadata withpath,width,height,bytes, andcreated_at.- Optional inline preview is allowed only for very small payloads (
inline_base64max 128 KB).
Path policy:
- screenshot files must be written inside workspace-scoped allowed paths.
- invalid output path requests return
PATH_NOT_ALLOWED. - oversize inline requests return
PAYLOAD_TOO_LARGE.
@mcp.tool()
def vcad_viewer_state(ctx: McpContext) -> str:
"""Get current vcad viewer state: camera position, selection, visible nodes."""
@mcp.tool()
def vcad_viewer_screenshot(ctx: McpContext) -> str:
"""Capture screenshot from vcad viewer.
Returns metadata including workspace-relative file path (no large base64 payload)."""
@mcp.tool()
def vcad_viewer_focus(ctx: McpContext, node_id: str) -> str:
"""Focus viewer camera on a specific vcad node."""After this phase, the full loop works:
- Agent calls
vcad_place→ driver requests sidecar evaluation → driver pushes mesh to viewer via relay WebSocket - Agent calls
vcad_viewer_screenshot→ sees the rendered result - Agent calls
vcad_viewer_state→ reads camera/selection for context
Extends Phase viewer test suite with relay client tests (vitest + happy-dom, no browser needed):
DriverRelayClient.test.ts— WebSocket client: connect, receivemesh.update/mesh.remove/scene.snapshot/scene.reset, sendviewer.ready/viewer.state; uses a lightweight in-process WS server stubrelay-freshness.test.ts— revision guard: out-of-ordermesh.updatewith lower revision is ignored, only latest revision reflected in storerelay-reconnect.test.ts— disconnect + reconnect: client sendsviewer.ready, receivesscene.snapshot, store rebuilt from snapshot
cd /Users/darwin/x/p/supex-ws/supex/vcad/viewer && npx vitest run
# responsiveness check:
# 1) start a long vcad eval (complex boolean model)
# 2) in parallel, call check_sketchup_status / vcad ping and confirm health response is immediate
# 3) confirm viewer relay still receives state/screenshot messages during/after eval
# screenshot payload check:
# - vcad_viewer_screenshot returns `path` and file metadata
# - returned file exists on disk under workspace `.tmp/vcad-viewer/`
# - MCP response remains small (no large inline base64 blob)
# relay protocol/capability check:
# - viewer.ready major mismatch -> vcad_viewer_* returns PROTOCOL_MISMATCH
# - missing viewer feature (e.g. screenshot) -> CAPABILITY_UNAVAILABLE with required_capability, negotiated_capabilities, and operation details
# - matching protocol/features in SketchUp 2026-only stack -> mesh push + screenshot/state tools operate normally
# - mismatch must not enter control/data fallback mode; fail-fast error path only
# reconnect snapshot check:
# - run several updates, disconnect viewer, reconnect viewer
# - first message after viewer.ready is scene.snapshot with latest revision per node
# - verify no historical replay duplicates and rendered state matches driver applied_revision mapManual visual + live relay check (not run by plan-runner): cd vcad/viewer && npm run tauri dev
Add tools to the supex Ruby runtime for importing OBJ meshes into SketchUp as vcad components with attributes.
- Existing runtime code:
bridge_server.rb,tools.rb - This phase should be completed before Phase mcp-tools and before final su-mock integration verification
SketchUp API policy for this phase:
- target SketchUp 2026 only
- use
definitions.import(obj_path)return value directly (Sketchup::ComponentDefinition) - do not implement legacy fallback via
definitions.to_a.last
module SupexRuntime
module VcadTools
extend self
VCAD_DICT = 'vcad'.freeze
# Import OBJ as ComponentDefinition, set vcad attributes, place instance
def place_vcad_node(params, workspace: nil)
obj_path = params['obj_path']
node_id = params['node_id']
source_file = params['source_file']
component_name = params['component_name'] || "vcad_#{node_id}"
position = params['position'] || [0, 0, 0]
# Path policy (workspace-scoped)
PathPolicy.validate!(obj_path, operation: 'vcad import', workspace: workspace)
PathPolicy.validate!(source_file, operation: 'vcad source', workspace: workspace) if source_file
model = Sketchup.active_model
model.start_operation('Place vcad node', true)
# SketchUp 2026: definitions.import returns ComponentDefinition.
defn = model.definitions.import(obj_path)
unless defn.is_a?(Sketchup::ComponentDefinition)
raise "IMPORT_DEFINITION_NOT_FOUND: #{obj_path}"
end
defn.name = component_name
# Set vcad metadata
defn.set_attribute(VCAD_DICT, 'node_id', node_id)
defn.set_attribute(VCAD_DICT, 'source_file', source_file)
defn.set_attribute(VCAD_DICT, 'version', 1)
# Place instance
pt = Geom::Point3d.new(
position[0].to_f.mm,
position[1].to_f.mm,
position[2].to_f.mm
)
tr = Geom::Transformation.new(pt)
instance = model.active_entities.add_instance(defn, tr)
model.commit_operation
{
success: true,
node_id: node_id,
entity_id: instance.entityID,
definition_name: defn.name
}
end
# Re-import OBJ using atomic definition swap + rollback on failure.
# Never mutate the existing definition in place.
def update_vcad_node(params, workspace: nil)
obj_path = params['obj_path']
node_id = params['node_id']
source_file = params['source_file']
# Path policy (workspace-scoped)
PathPolicy.validate!(obj_path, operation: 'vcad import', workspace: workspace)
PathPolicy.validate!(source_file, operation: 'vcad source', workspace: workspace) if source_file
model = Sketchup.active_model
old_defn = find_vcad_definition(model, node_id)
raise "vcad node not found: #{node_id}" unless old_defn
model.start_operation('Update vcad node', true)
begin
# Capture current placements so we can rebind all instances to a new definition.
placements = old_defn.instances.map do |inst|
{
parent_entities: inst.parent.entities,
transformation: inst.transformation,
layer: inst.respond_to?(:layer) ? inst.layer : nil,
material: inst.respond_to?(:material) ? inst.material : nil,
name: inst.respond_to?(:name) ? inst.name : nil
}
end
old_name = old_defn.name
version = old_defn.get_attribute(VCAD_DICT, 'version', 0).to_i + 1
# SketchUp 2026: import returns the new ComponentDefinition directly.
new_defn = model.definitions.import(obj_path)
unless new_defn.is_a?(Sketchup::ComponentDefinition)
raise "IMPORT_DEFINITION_NOT_FOUND: #{obj_path}"
end
# Keep the new definition hidden from UI naming collisions until swap is complete.
new_defn.name = "#{old_name}__updating"
new_defn.set_attribute(VCAD_DICT, 'node_id', node_id)
new_defn.set_attribute(VCAD_DICT, 'source_file', source_file || old_defn.get_attribute(VCAD_DICT, 'source_file'))
new_defn.set_attribute(VCAD_DICT, 'version', version)
# Rebind every instance to the new definition at the same transform.
old_instances = old_defn.instances.to_a
placements.each_with_index do |placement, idx|
replacement = placement[:parent_entities].add_instance(new_defn, placement[:transformation])
replacement.layer = placement[:layer] if placement[:layer]
replacement.material = placement[:material] if placement[:material]
replacement.name = placement[:name] if placement[:name] && replacement.respond_to?(:name=)
old_instances[idx]&.erase!
end
# Finalize naming and cleanup old definition when no instances remain.
old_defn.name = "#{old_name}__old"
new_defn.name = old_name
model.definitions.remove(old_defn) if old_defn.instances.empty?
model.commit_operation
{
success: true,
node_id: node_id,
version: version,
definition_name: new_defn.name,
replaced_instances: placements.length
}
rescue StandardError => e
model.abort_operation
raise "Update vcad node failed: #{e.message}"
end
end
# List all vcad nodes
def list_vcad_nodes(_params = {}, workspace: nil)
model = Sketchup.active_model
nodes = model.definitions.select { |d| d.get_attribute(VCAD_DICT, 'node_id') }
nodes.map do |d|
{
node_id: d.get_attribute(VCAD_DICT, 'node_id'),
source_file: d.get_attribute(VCAD_DICT, 'source_file'),
version: d.get_attribute(VCAD_DICT, 'version'),
name: d.name,
instances: d.instances.length
}
end
end
# Get single vcad node metadata
def get_vcad_node(params, workspace: nil)
node_id = params['node_id']
model = Sketchup.active_model
defn = find_vcad_definition(model, node_id)
raise "vcad node not found: #{node_id}" unless defn
{
node_id: node_id,
source_file: defn.get_attribute(VCAD_DICT, 'source_file'),
version: defn.get_attribute(VCAD_DICT, 'version'),
name: defn.name,
instances: defn.instances.length,
bounds: bounds_to_hash(defn.bounds)
}
end
private
def find_vcad_definition(model, node_id)
model.definitions.find { |d| d.get_attribute(VCAD_DICT, 'node_id') == node_id }
end
def bounds_to_hash(bb)
{
min: [bb.min.x.to_mm, bb.min.y.to_mm, bb.min.z.to_mm],
max: [bb.max.x.to_mm, bb.max.y.to_mm, bb.max.z.to_mm]
}
end
end
endError mapping requirement:
- catch
PathPolicy::PathAccessDeniedand return structured error codePATH_NOT_ALLOWED - include operation context (
vcad import,vcad source) in error details - map import resolution failures to
IMPORT_DEFINITION_NOT_FOUND - keep
error_codestable so driver can pass it through unchanged
In runtime/src/supex_runtime/bridge_server.rb add to tool dispatch:
def execute_vcad_tool(name, params, workspace: nil)
case name
when 'place_vcad_node'
VcadTools.place_vcad_node(params, workspace: workspace)
when 'update_vcad_node'
VcadTools.update_vcad_node(params, workspace: workspace)
when 'list_vcad_nodes'
VcadTools.list_vcad_nodes(params, workspace: workspace)
when 'get_vcad_node'
VcadTools.get_vcad_node(params, workspace: workspace)
end
endIn execute_tool add fallback to execute_vcad_tool for vcad_* tools.
Add require_relative 'vcad_tools'.
cd /Users/darwin/x/p/supex-ws/supex/runtime && bundle exec rake test
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_ruby_mock
# import semantics check (SketchUp 2026 target):
# - `definitions.import` return value is used as the new definition
# - no fallback to `definitions.to_a.last`
# path policy check:
# - obj/source path outside workspace returns PATH_NOT_ALLOWEDAdd MCP tools for orchestrating vcad evaluation and placement into SketchUp. Uses the vcad connection from Phase vcad-conn.
- Phase vcad-conn (VcadConnection + sidecar lifecycle)
- Phase ruby-tools (Ruby runtime vcad tools + bridge dispatch)
New module with MCP tool implementations. Follow existing server.py style:
- import shared
mcp+McpContextfromsupex_driver.mcp.server - declare tools as module-level synchronous
deffunctions with@mcp.tool() - first argument is always
ctx: McpContext
@mcp.tool()
def vcad_place(
ctx: McpContext,
node_id: str,
source_file: str,
position: list[float] | None = None,
component_name: str | None = None,
) -> str:
"""Evaluate a .cmp.oo file and place the resulting mesh in SketchUp.
1. Send source file to vcad sidecar -> eval -> OBJ file
2. Send OBJ path to SketchUp -> definitions.import -> ComponentDefinition
3. Store vcad metadata in attribute dictionary
"""
@mcp.tool()
def vcad_update(ctx: McpContext, node_id: str, source_file: str | None = None) -> str:
"""Re-evaluate vcad node and update SketchUp geometry."""
@mcp.tool()
def vcad_inspect(ctx: McpContext, source: str) -> str:
"""Inspect vcad geometry: volume, surface area, bounding box.
Source can be a .cmp.oo file path or inline Loon code."""
@mcp.tool()
def vcad_export(ctx: McpContext, source: str, format: str = "obj", output_path: str = "") -> str:
"""Export vcad geometry to OBJ or STEP."""
@mcp.tool()
def vcad_eval(ctx: McpContext, code: str) -> str:
"""Evaluate Loon code in vcad sidecar (REPL mode). Returns display string."""
@mcp.tool()
def vcad_list_nodes(ctx: McpContext) -> str:
"""List all vcad nodes in the current SketchUp model."""MCP response contract:
- for successful operations, return normal result payload
- for failures from sidecar/ruby, preserve
error_codeanddetailsfields unchanged - avoid replacing upstream
error_codewith genericerror_type - when negotiation fails, use deterministic codes:
PROTOCOL_MISMATCHorCAPABILITY_UNAVAILABLE - for
CAPABILITY_UNAVAILABLE, always includedetails.required_capability,details.negotiated_capabilities,details.operation - do not auto-retry alternative code paths after capability mismatch; return error immediately
- driver must not rewrite upstream
error_code; it may only enrich missingdetails.operation
Import vcad tools module for side-effect registration (module-level @mcp.tool()):
from . import vcad_tools # registers vcad tools on shared mcp instancecd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_tools
# negotiation error propagation check:
# - sidecar major mismatch surfaces as PROTOCOL_MISMATCH in MCP tool response
# - missing capability surfaces as CAPABILITY_UNAVAILABLE with required_capability/negotiated_capabilities/operation details
# - verify tool returns error directly (no fallback branch)Standalone Ruby process with comprehensive SketchUp API mocks that loads the real supex runtime code (bridge_server.rb, tools.rb, vcad_tools.rb) and runs the TCP bridge server. Python integration tests connect to it over TCP exactly like they connect to real SketchUp. Enables CI-friendly testing of vcad Ruby tools without launching SketchUp.
- Phase ruby-tools (su-mock loads real runtime code including
vcad_tools.rb) - Existing runtime code: bridge_server.rb, tools.rb
vcad_tools.rb exercises SketchUp API surface far beyond what the existing thin test mocks (runtime/test/helpers/sketchup_mocks.rb, 635 lines) support — definitions.import(), attribute dictionaries, Geom::Transformation, Numeric#mm, entities.clear!, manifold?, Face#mesh(). Testing these tools requires either real SketchUp (expensive, slow, not CI-friendly) or comprehensive mocks. The su-mock provides the latter as a standalone TCP server.
mock/
su-mock.rb # Launcher: load mocks, patch event loop, start bridge
sketchup_api/
core.rb # Sketchup module singleton + loads all sub-modules
model.rb # MockModel (definitions, active_entities, find_entity_by_id)
entities.rb # MockEntities (add_instance, clear!, grep, manifold?)
definition_list.rb # MockDefinitionList (import(obj_path), Enumerable)
component_definition.rb # Enhanced (entities, attributes, name=, bounds)
component_instance.rb # Enhanced (transformation, definition, entityID)
attribute_dictionary.rb # set_attribute/get_attribute mixin for all entities
transformation.rb # Geom::Transformation (from point, from array, to_a, origin)
face.rb # MockFace with mesh() -> MockPolygonMesh
polygon_mesh.rb # MockPolygonMesh (1-based: point_at, normal_at, polygon_at)
units.rb # Numeric#mm, Numeric#to_mm (SketchUp unit conversion)
geometry.rb # Geom::Point3d, Vector3d, BoundingBox
ui.rb # UI module with blocking loop mode for headless operation
entity_registry.rb # Auto-incrementing entity IDs + global entity lookup
camera.rb # MockCamera, MockView
selection.rb # MockSelection
materials.rb # MockMaterials, MockMaterial, MockColor
layers.rb # MockLayers, MockLayer
test_control.rb # _test.* JSON-RPC handlers for Python test setup/teardown
test/
test_mock_api.rb # Self-tests verifying mock API completeness
Same as production: each TCP connection handles hello + one tool call, then closes. Tests reconnect per command. This ensures tests verify the full protocol path.
The bridge_server.rb uses UI.start_timer(interval, true) { handle_requests }. In the mock, UI.enable_blocking_mode! switches to a direct blocking loop (no SketchUp UI thread to cooperate with):
# In su-mock.rb:
UI.enable_blocking_mode!
server = SupexRuntime::BridgeServer.new(port: port)
server.start # calls UI.start_timer -> stores block
UI.run_blocking_loop! # loop { block.call; sleep(interval) }Special _test.* tool names dispatched before normal tools in the mock's patched execute_tool:
| Method | Purpose |
|---|---|
_test.reset |
Reset all model state to clean |
_test.add_definition |
Create ComponentDefinition with attributes |
_test.add_instance |
Place instance of a definition |
_test.add_entity |
Add entity (Face, Edge, Group) with properties |
_test.set_attribute |
Set attribute dict entry on entity by ID |
_test.get_entity |
Return entity state by ID (for assertions) |
_test.set_manifold |
Configure manifold? return value |
_test.get_state |
Full model state snapshot |
Called via standard send_command("_test.reset") from Python — goes through the normal hello + tools/call TCP protocol.
definitions.import(obj_path) parses OBJ minimally (vertex positions + face indices), creates and returns a MockComponentDefinition with computed BoundingBox and stored mesh data. Does NOT model full SketchUp topology — just enough for SketchUp 2026 import semantics used by vcad tools.
The su-mock builds its own mock hierarchy in mock/sketchup_api/ rather than extending runtime/test/helpers/sketchup_mocks.rb. The existing mocks use rand() for entity IDs, lack module organization, and are coupled to Ruby minitest patterns. The su-mock needs deterministic IDs, proper Sketchup:: namespace, and full vcad API surface. Existing test mocks remain untouched.
P0 (core — vcad_tools.rb testing):
- Entity ID auto-increment registry with global
find_entity_by_id - MockDefinitionList with
import(),to_a,select,find - Attribute dictionaries on all entities
MockEntities.add_instance(defn, tr)andclear!Geom::Transformation.new(point),#to_a,#originNumeric#mmand#to_mm(SketchUp uses inches internally; vcad uses mm)- Enhanced MockComponentDefinition/Instance, MockModel
P1 (Phase native-mesh testing):
MockFace#mesh(flags)-> MockPolygonMesh (1-based indexing)MockEntities#manifold?(configurable via test control)
# driver/tests/conftest.py (or fixtures/su_mock.py)
@pytest.fixture(scope="session")
def su_mock_process():
"""Start su-mock Ruby process once per test session."""
# subprocess.Popen(['ruby', 'mock/su-mock.rb'])
# Wait for TCP ready, yield, terminate
@pytest.fixture
def su_mock(su_mock_process):
"""Per-test connection with clean state."""
conn = SketchupConnection(port=su_mock_port)
conn.send_command("_test.reset")
yield connscripts/launch-su-mock.sh — starts the mock Ruby process with configurable port. Used by pytest fixtures and manual testing.
cd /Users/darwin/x/p/supex-ws/supex/mock && ruby test/test_mock_api.rbWire all layers together — sidecar lifecycle management, full E2E pipeline from .cmp.oo file to component in SketchUp. Add launch scripts and configuration.
#!/usr/bin/env bash
# Launch vcad Rust sidecar for development
set -e
# Resolve symlinks (macOS-compatible, no readlink -f)
SCRIPT_PATH="${BASH_SOURCE[0]}"
while [ -L "$SCRIPT_PATH" ]; do
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
[[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH"
done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
SIDECAR_DIR="$(cd "$SCRIPT_DIR/../vcad/sidecar" && pwd)"
# Build if needed
if [ ! -f "$SIDECAR_DIR/target/release/supex-vcad-sidecar" ]; then
echo "Building vcad sidecar..." >&2
cargo build --release --manifest-path "$SIDECAR_DIR/Cargo.toml"
fi
exec "$SIDECAR_DIR/target/release/supex-vcad-sidecar" "$@"Script policy: any new launch wrapper in this project should reuse the same symlink-safe boilerplate as mcp/supex (avoid readlink -f).
Similar to existing mcp and supex wrappers. Starts sidecar with logging.
Document env vars in README section:
VCAD_HOST— sidecar host (default: 127.0.0.1)VCAD_PORT— sidecar port (default: 9877)VCAD_TIMEOUT— request timeout (default: 30s)VCAD_AUTH_TOKEN— sidecar auth token (required when auth enabled)VCAD_ALLOW_REMOTE— allow non-loopback bind (default: 0; requiresVCAD_AUTH_TOKEN)VCAD_MAX_QUEUE— maximum queued eval jobs in sidecar (default: 64)VCAD_EVAL_TIMEOUT_MS— max wait per queued eval request (default: 120000)VCAD_TEMP_TTL_SEC— max age of sidecar temp OBJ + manifest files in seconds (default: 3600)VCAD_TEMP_MAX_FILES— max number of retained artifact sets (OBJ + manifest) (default: 500)VCAD_ADT_CACHE_MAX— max entries in sidecar ADT cache, LRU eviction (default: 256)VCAD_SIDECAR_PATH— path to sidecar binary (default: auto-detect from repo)VCAD_TEMP_DIR— temp directory for OBJ files (default: system temp)VCAD_STATE_PATH— persisted driver state file (default:<workspace>/.supex/vcad-state.json)
- Build sidecar:
cargo build --release --manifest-path vcad/sidecar/Cargo.toml - Start sidecar:
./vcad-sidecar - Start SketchUp with supex runtime
- Via MCP or CLI:
- Create a .cmp.oo file with a simple solid
- Call
vcad_place("test-bracket", "bracket.cmp.oo") - Verify the component appears in SketchUp
- Call
vcad_list_nodes()— must return "test-bracket" - Modify the .cmp.oo file
- Call
vcad_update("test-bracket") - Verify the geometry was updated
def test_vcad_eval_code():
"""Test that sidecar evaluates Loon and returns OBJ path."""
conn = get_vcad_connection()
result = conn.eval_code("[cube 10.0 20.0 30.0]")
assert "obj_path" in result
assert os.path.exists(result["obj_path"])
assert result["volume"] > 0
def test_vcad_eval_file(tmp_path):
"""Test eval_file with a .cmp.oo file."""
loon_file = tmp_path / "test.cmp.oo"
loon_file.write_text("[pipe [cube 50.0 10.0 30.0] [fillet 2.0]]")
result = conn.eval_file(str(loon_file))
assert result["volume"] > 0
def test_vcad_place_in_sketchup():
"""Test full pipeline: eval -> OBJ -> SketchUp import."""
# Requires running SketchUp + sidecar
result = vcad_place("e2e-test", "test.cmp.oo", position=[0, 0, 0])
assert result["success"]
nodes = vcad_list_nodes()
assert any(n["node_id"] == "e2e-test" for n in nodes)
def test_vcad_eval_file_rejects_path_escape():
"""Path policy rejects paths outside workspace."""
conn = get_vcad_connection()
result = conn.eval_file("../outside/test.cmp.oo")
assert result.get("error_code") == "PATH_NOT_ALLOWED"Add to docs/vcad.md:
- Architecture overview
- How to write .cmp.oo files (Loon CAD API reference)
- Loon language quick reference (ADT, pipe, use)
- MCP tools reference
- Troubleshooting
# Thorough integration tests via su-mock (CI-friendly)
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/ -v -k vcad_e2e_mock
# Security checks:
# - start sidecar with non-loopback host and no VCAD_AUTH_TOKEN -> startup must fail
# - verify token mismatch on hello returns AUTH_INVALID
# - verify path traversal attempts return PATH_NOT_ALLOWED
# - assert standardized `error_code` values in responses
# restart/recovery checks:
# - restart driver only (SketchUp still running) and verify state reconcile restores DAG/revisions
# - restart sidecar during active queue and verify deterministic recovered state (no duplicate imports)
# - reconnect viewer after burst of updates and verify scene snapshot matches latest revisions
# launch script portability:
# - run scripts/launch-vcad-sidecar.sh directly and via symlink
# - verify both resolve SIDECAR_DIR correctly on macOS (no readlink -f dependency)vcad nodes can import data references from SketchUp entities. This phase covers data extracts (:dimensions, :bbox, :transform) from any entity. Establishes the import resolution protocol between sidecar, driver, and Ruby bridge. No ADT cache involved — data extracts are simple Loon map bindings.
Execution notes for plan-runner success:
- Keep this phase focused on data extract imports only (
:solidstays in Phase adt-imports). - Reuse existing driver scaffolding in
driver/src/supex_driver/connection/vcad_state.pyonly as needed for revision safety; full DAG orchestration remains in Phase dag. - Update sidecar
hello.capabilitiesso advertised import capabilities match implemented JSON-RPC methods.
| Extract type | Any SketchUp entity |
|---|---|
:dimensions |
SketchUp bounds → Loon map {:width :height :depth} |
:bbox |
SketchUp bounds → Loon map {:min [...] :max [...]} |
:transform |
Instance transformation → Loon map {:matrix [...]} |
; Import dimensions from any SketchUp component (vcad-backed or not)
[let host-dims [import :dimensions "entity:12345"]]
; host-dims = {:width 100.0 :height 200.0 :depth 50.0}
[pipe [cube [get host-dims :width] 10.0 [get host-dims :height]]
[fillet 2.0]]
import is not a native Loon keyword. In supex, it is a reserved sidecar-recognized call pattern resolved before evaluation.
Supported import syntax in this phase:
[let <binding> [import <extract-keyword> "entity:<id>"]]
Validation rules:
<extract-keyword>must be one of:dimensions,:bbox,:transform<entity-ref>must matchentity:<integer-id>- any other
importusage is rejected withIMPORT_FORM_INVALID
- Driver reads the
.cmp.oosource and callsvcad.extract_imports(source) - Sidecar parses source via
loon_lang::parser::parse(), walks the AST, and matchesletbindings with[import ...](no regex) - Sidecar rewrites every matched import expression to an internal symbol (e.g.
__vcad_import_0) and returns:imports[]:{import_id, binding_name, extract, entity_ref, injected_symbol}transformed_source: source with no raw[import ...]calls
- For each import, driver asks SketchUp bridge:
resolve_vcad_import({entity_id, extract}) - Bridge resolves data extracts from SketchUp directly, returns
{data: {...}, vcad_node_id: "base-plate" | null} - Driver calls
vcad.eval_with_imports(transformed_source, ...)with resolved import payload - Sidecar prepends
[let <injected_symbol> ...]bindings for resolved values and evaluates the transformed source vcad_node_idfrom any resolved import is recorded for DAG tracking (Phase dag)
impl Evaluator {
/// Parse source, extract supported import forms, and rewrite them.
/// Example rewrite:
/// [let host-dims [import :dimensions "entity:12345"]]
/// becomes
/// [let host-dims __vcad_import_0]
fn extract_and_rewrite_imports(&self, source: &str) -> Result<ExtractedImports, String> {
let exprs = loon_lang::parser::parse(source)
.map_err(|e| format!("Parse error: {:?}", e))?;
let mut imports = Vec::new();
let rewritten_exprs = rewrite_import_bindings(exprs, &mut imports)?;
let transformed_source = render_exprs(&rewritten_exprs)?;
Ok(ExtractedImports {
imports,
transformed_source,
})
}
/// Evaluate transformed source with resolved imports injected as let-bindings.
/// Raw [import ...] calls never reach the Loon interpreter.
fn eval_with_data_imports(
&mut self,
transformed_source: &str,
base_dir: Option<&Path>,
imports: &HashMap<String, ResolvedDataImport>, // key: import_id
) -> Result<EvalResult, String> {
// 1. Build preamble for injected symbols produced by extract_and_rewrite_imports
let mut preamble = String::new();
for (_import_id, import) in imports {
preamble.push_str(&format_data_binding(&import.injected_symbol, &import.data));
}
let augmented_source = format!("{}\n{}", preamble, transformed_source);
// 2. Evaluate augmented source via standard pipeline
let doc = eval_vcad(&augmented_source, base_dir)?;
self.evaluate_and_export(&doc, "import-eval")
}
}vcad.extract_imports(source: string) — parse, validate, and return {imports, transformed_source}
vcad.eval_with_imports(transformed_source: string, — evaluate transformed source with resolved imports injected
base_dir: string | null,
imports: {import_id: {extract, injected_symbol, data}})
def resolve_vcad_import(params, workspace: nil)
entity_id = params['entity_id'].to_i
extract_type = params['extract']
entity = Sketchup.active_model.find_entity_by_id(entity_id)
raise "Entity not found: #{entity_id}" unless entity
defn = entity.respond_to?(:definition) ? entity.definition : nil
vcad_node_id = defn&.get_attribute('vcad', 'node_id')
case extract_type
when 'dimensions'
bb = entity.bounds
{ extract: 'dimensions', vcad_node_id: vcad_node_id,
data: { width: bb.width.to_mm, height: bb.height.to_mm, depth: bb.depth.to_mm } }
when 'bbox'
bb = entity.bounds
{ extract: 'bbox', vcad_node_id: vcad_node_id,
data: { min: [bb.min.x.to_mm, bb.min.y.to_mm, bb.min.z.to_mm],
max: [bb.max.x.to_mm, bb.max.y.to_mm, bb.max.z.to_mm] } }
when 'transform'
raise "Entity #{entity_id} has no transformation" unless entity.respond_to?(:transformation)
{ extract: 'transform', vcad_node_id: vcad_node_id,
data: { matrix: entity.transformation.to_a } }
when 'solid'
raise "Solid import not supported in this phase — use Phase adt-imports"
end
endEvery response includes vcad_node_id (or null) — the driver uses this for DAG dependency tracking.
@mcp.tool()
def vcad_place_with_imports(
ctx: McpContext,
node_id: str,
source_file: str,
position: list[float] | None = None,
) -> str:
"""Place vcad node with data references from SketchUp entities.
Imports are declared inline in source using:
[let <binding> [import :dimensions|:bbox|:transform "entity:<id>"]]
and are resolved by sidecar+driver before evaluation."""cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_data_imports.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_data_imports_mock.py -vAdd two missing loon-lang capabilities required by supex integration:
eval_program_with_env_and_base_dir(...)for evaluating with a pre-populatedEnv(required by Phase adt-imports solid ADT injection)loaded_module_paths()plus a module-tracking evaluation wrapper (required by Phase mod-track)
Maintained as a patch on the loon repo's supex-patches branch.
At least two files in /Users/darwin/x/p/supex-ws/loon:
1. crates/loon-lang/src/interp/mod.rs — env-seeded evaluation entrypoint:
Add a public API that evaluates with caller-provided environment:
pub fn eval_program_with_env_and_base_dir(
exprs: &[Expr],
env: &mut Env,
base_dir: Option<&Path>,
) -> IResult {
// same pipeline as eval_program_with_base_dir, but:
// - reuses provided env (does not allocate a fresh one)
// - uses provided base_dir for [use ...] module resolution
// - preserves existing builtins and globals in env
}2. crates/loon-lang/src/module.rs — expose loaded module paths:
Loon's ModuleCache internally tracks loaded modules in modules: HashMap<PathBuf, ModuleState> (private field). Current public API: new(), with_manifest(), with_manifest_and_lockfile(), manifest(), set_manifest(), lockfile(), resolve_path(), load_module(). There is NO method to retrieve loaded module paths. Additions are needed:
// Addition to loon-lang::module::ModuleCache
impl ModuleCache {
/// Returns paths of all modules loaded during evaluation.
pub fn loaded_module_paths(&self) -> Vec<PathBuf> {
self.modules.keys().cloned().collect()
}
}
// Addition to loon-lang::interp
/// Evaluate program and return both result and list of loaded module paths.
pub fn eval_program_with_module_tracking(
exprs: &[Expr],
base_dir: Option<&Path>,
) -> Result<(Value, Vec<PathBuf>), InterpError> {
// ... existing eval logic, but extract loaded_module_paths() before dropping cache
}# Create patch branch in loon repo
cd /Users/darwin/x/p/supex-ws/loon
git checkout -b supex-patches
# ... apply changes ...
git commit -m "Add env-seeded eval API and expose loaded module paths"
# When loon updates, rebase our patch
git fetch origin
git rebase origin/mainAfter applying the patch, rebuild the supex sidecar (cargo build picks up the changed crate via path deps).
cd /Users/darwin/x/p/supex-ws/loon && cargo test -p loon-lang
# sidecar still compiles against patched loon APIs
cd /Users/darwin/x/p/supex-ws/supex/vcad/sidecar && cargo buildAdd ImportedMesh support path for native SketchUp solids with mandatory CSG participation. Native imported meshes must work inside boolean/CSG composition (difference/union/intersection), not only as root output.
Maintained as a patch on top of vcad repo.
Target behavior is fixed for this project:
- ImportedMesh is CSG-compatible and can be used inside
difference,union, andintersectionbranches.
At least three files in /Users/darwin/x/p/supex-ws/vcad:
1. cad-lib/src/lib.loon — add ADT variant:
[type Solid
; ... existing variants ...
[ImportedMesh (Vec f64) (Vec Int) (Vec f64)]] ; positions, indices, normals
2. crates/vcad-loon/src/convert.rs — add conversion:
// In convert_solid(), add match arm:
"ImportedMesh" => {
let positions = extract_f64_vec(&fields[0])?;
let indices = extract_u32_vec(&fields[1])?;
let normals = extract_f64_vec(&fields[2])?;
CsgOp::ImportedMesh {
positions,
indices,
normals: Some(normals),
source: None, // CsgOp::ImportedMesh also has `source: Option<String>`
}
}This maps directly to the existing CsgOp::ImportedMesh in vcad-ir:
// Already in vcad-ir (no changes needed):
CsgOp::ImportedMesh {
positions: Vec<f64>,
indices: Vec<u32>,
normals: Option<Vec<f64>>,
source: Option<String>,
}3. crates/vcad-eval/src/evaluate.rs — evaluator requirement:
- patch evaluator so ImportedMesh participates correctly when encountered in non-root CSG branches
vcad-kernel changes are not expected.
# Create patch branch in vcad repo
cd /Users/darwin/x/p/supex-ws/vcad
git checkout -b supex-patches
# ... apply changes ...
git commit -m "Add CSG-compatible ImportedMesh support for external mesh import"
# When vcad updates, rebase our patch
git fetch origin
git rebase origin/mainAfter applying the patch, rebuild the supex sidecar (cargo build picks up the changed crates via path deps).
# Verify patch applies cleanly and tests pass
cd /Users/darwin/x/p/supex-ws/vcad && cargo test -p vcad-loon
cd /Users/darwin/x/p/supex-ws/vcad && cargo test -p vcad-eval
# Acceptance:
# - add/execute test proving ImportedMesh in boolean branch evaluates successfullyDependency graph tracking all edges between vcad nodes (entity imports, vcad-to-vcad solid imports). Manual cascading re-evaluation via MCP tool — no automatic watching yet.
Use existing scaffolding in driver/src/supex_driver/connection/vcad_state.py (RevisionTracker, EvalQueue, VCADPersistentState, VCADReconciler) and wire DAG logic around it. Avoid duplicating already-implemented state primitives.
@dataclass
class ImportRef:
binding_name: str # Loon var name
entity_ref: str # "entity:12345"
extract: str # "dimensions", "bbox", "transform", "solid"
resolved_type: str # "native", "vcad", or "native_mesh"
source_node_id: str | None # set if resolved_type == "vcad"
@dataclass
class VcadNode:
node_id: str
source_file: str
imports: list[ImportRef]
revision: int = 0 # monotonic trigger counter
applied_revision: int = 0 # last successfully applied revision
last_entity_id: int | None = None
status: str = "active" # active | degraded | orphan
@dataclass
class PersistedNodeState:
node_id: str
source_file: str
revision: int
applied_revision: int
last_entity_id: int | None
status: str
class VcadStateStore:
def load(self) -> dict[str, PersistedNodeState]: ...
def save(self, nodes: dict[str, VcadNode]) -> None: ...
def atomic_write(self, payload: dict[str, Any]) -> None: ...
class VcadDag:
def __init__(self):
self.nodes: dict[str, VcadNode] = {}
self.store = VcadStateStore()
def add_node(self, node: VcadNode) -> None: ...
def remove_node(self, node_id: str) -> None: ...
def get_evaluation_order(self) -> list[str]:
"""Topological sort of all nodes."""
def get_downstream(self, node_id: str) -> list[str]:
"""All nodes that depend (transitively) on node_id."""
def get_dependents_of_entity(self, entity_id: int) -> list[str]:
"""All nodes that import this SketchUp entity (native or vcad-backed)."""
def bump_revision(self, node_id: str) -> int:
"""Increment and return next expected revision for node_id."""
def current_revision(self, node_id: str) -> int: ...
def mark_applied(self, node_id: str, revision: int) -> None: ...
def load_persisted_state(self) -> None: ...
def persist_state(self) -> None: ...
def reconcile_with_sketchup(self, sketchup_nodes: list[dict[str, Any]]) -> dict[str, list[str]]: ...
def detect_cycle(self) -> list[str] | None: ...When vcad_place or vcad_place_with_imports is called, the driver registers the node and its imports in the DAG. When vcad_update re-evaluates a node, the DAG entry is refreshed (imports may have changed).
Persistence + reconciliation rules:
- Any successful place/update/remove updates in-memory DAG and immediately persists state.
- On driver startup, call
load_persisted_state()before accepting MCP vcad operations. - Then call
reconcile_with_sketchup(list_vcad_nodes())to classify drift (missing_node,orphan_definition,revision_gap,source_missing). - For
source_missing, keep node in DAG asdegradedand emitSOURCE_FILE_MISSINGuntil source is restored. - For reconcilable drift, schedule one deduplicated topological cascade and persist fresh state after apply.
@mcp.tool()
def vcad_update_cascade(ctx: McpContext, node_id: str) -> str:
"""Re-evaluate node and all downstream dependents in topological order.
ADT composition: each node's ADT tree is cached in the sidecar,
so downstream nodes that import :solid get the fresh ADT directly."""
dag = get_vcad_dag()
affected = [node_id] + dag.get_downstream(node_id)
order = dag.topological_sort(affected)
results = []
for nid in order:
revision = dag.bump_revision(nid)
# Resolve imports — vcad imports use freshly cached ADTs from earlier in the cascade
# Eval + update sidecar cache + update SketchUp mesh
# Apply only if revision is still current (drop stale late results).
results.append(vcad_update_with_revision(ctx, nid, revision))
return resultsApply/commit rule for each node update:
- Implement via a driver-internal helper (e.g.
vcad_update_with_revision(ctx, node_id, revision)) so MCP-facingvcad_updatesignature stays stable. - Sidecar eval response is considered tentative until driver checks
revisionfreshness. - If stale (
revision < dag.current_revision(node_id)): skip SketchUpupdate_vcad_node, skip viewer publish, increment stale-drop metric. - If current: apply in SketchUp, publish to viewer, set
applied_revision = revision, updatelast_entity_id, persist state.
This is a manually triggered tool — the agent (or user) calls it explicitly after editing a source file. Automatic triggering comes in Phase fs-watch-mod-track.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_dag.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_connection.py -v -k Reconciler
# recovery/reconcile check:
# - seed persisted vcad-state.json, restart driver, reconcile with list_vcad_nodes snapshot
# - verify drift buckets (missing_node, orphan_definition, revision_gap, source_missing)
# - verify source_missing nodes are marked degraded and return SOURCE_FILE_MISSING deterministicallyEnable :solid import from vcad-backed entities. The sidecar caches each node's evaluated Value::Adt tree and injects it as a Loon binding when another node imports it. This allows full CSG composition across vcad nodes — the kernel optimizes the combined tree in one pass. Extends Phase data-imports's import resolution protocol.
- Phase data-imports (import resolution protocol, AST extraction, data bindings)
- Phase loon-patch (required
eval_program_with_env_and_base_dirAPI in loon-lang)
If loon-patch is not applied, :solid imports must return a structured SOLID_IMPORT_UNAVAILABLE error and this phase remains design-only.
| Extract type | vcad-backed entity |
|---|---|
:solid |
Cached Value::Adt from ADT cache, injected as Loon binding |
Non-vcad-backed entities requesting :solid return error (native mesh support in Phase native-mesh).
; Import vcad-backed Solid — cached ADT tree, full CSG composition
[let bracket [import :solid "entity:67890"]]
; bracket is Value::Adt (e.g., Fillet(Difference(Cube(...), Cylinder(...)), 2.0))
[pipe [cube 20.0 20.0 50.0]
[difference bracket]
[translate 0.0 0.0 10.0]]
Loon produces pure ADT data — geometry is represented as nested Value::Adt trees (e.g., Fillet(Difference(Cube(...), Cylinder(...)), 2.0)). Cross-node composition works at the Loon evaluation level:
- When node A is evaluated,
eval_vcad_to_value()produces aValue::Adttree - Sidecar stores this in
AdtCache:adt_cache.set("node-a", adt_value) - When node B imports
:solidfrom node A:- Driver resolves import → knows it's vcad-backed with node_id "node-a"
- Sidecar retrieves
adt_cache.get("node-a")→Value::Adt(...) - The cached ADT is injected as a Loon binding in node B's environment before evaluation
- Node B's code operates on the imported ADT with normal cad-lib functions:
[difference bracket]producesValue::Adt("Difference", [node_b_solid, imported_adt])- The combined ADT tree flows through
value_to_document()→evaluate_document()as one unit
- The kernel evaluates the full CSG tree (including node A's subtree) in one pass
Why ADT-level composition:
Value::Adtis what cad-lib functions expect —[difference ...],[fillet ...]etc. take and return ADTs- ADT composition lets the kernel optimize the full combined CSG tree, not just use a pre-computed result
- No modifications to vcad-ir or vcad-eval needed — it's a pure Loon-level operation
impl Evaluator {
/// Evaluate with imports injected as Loon bindings.
/// Extends Phase data-imports eval_with_data_imports with solid ADT injection.
/// Input source must already be transformed by extract_and_rewrite_imports
/// so no raw [import ...] calls remain.
fn eval_with_imports(
&mut self,
transformed_source: &str,
base_dir: Option<&Path>,
imports: &HashMap<String, ResolvedImport>, // key: import_id
) -> Result<EvalResult, String> {
// 1. Build Loon preamble for data imports using injected symbols
let mut preamble = String::new();
for (_import_id, import) in imports {
match import.extract.as_str() {
"dimensions" | "bbox" | "transform" => {
preamble.push_str(&format_data_binding(&import.injected_symbol, &import.data));
}
"solid" => {
// Solid imports: injected into Loon Env directly (step 3)
}
_ => {}
}
}
let augmented_source = format!("{}\n{}", preamble, transformed_source);
// 2. Parse the augmented source
let exprs = loon_lang::parser::parse(&augmented_source)?;
// 3. Set up Loon environment with solid ADT bindings
let mut env = loon_lang::interp::Env::new();
for (_import_id, import) in imports {
if import.extract == "solid" {
if let Some(cached_adt) = self.adt_cache.get(&import.vcad_node_id) {
env.set(import.injected_symbol.clone(), cached_adt.clone());
}
}
}
// 4. Evaluate Loon with pre-populated environment (loon-patch API)
let result_value = loon_lang::interp::eval_program_with_env_and_base_dir(
&exprs,
&mut env,
base_dir,
)?;
// 5. Cache the result ADT for future imports
// adt_cache.set(node_id, result_value.clone());
// 6. Convert to Document and evaluate to mesh
let doc = vcad_loon::value_to_document(&result_value)?;
self.evaluate_and_export(&doc, "import-eval")
}
}The AdtCache (defined in Phase sidecar, adt_cache.rs) is now actively used:
- Every
eval_code/eval_filecall also stores the result ADT viaeval_vcad_to_value() eval_with_importsretrieves cached ADTs for:solidbindings- Cache invalidation: when a node is re-evaluated, its cache entry is replaced
- Remove
#[allow(dead_code)]aroundAdtCacheonce usage is wired.
Extend Phase data-imports's resolve_vcad_import with :solid support:
when 'solid'
if vcad_node_id
{ extract: 'solid', source: 'vcad', vcad_node_id: vcad_node_id }
else
raise "Entity #{entity_id} is not vcad-backed — :solid import unavailable (native mesh support in Phase native-mesh)"
endvcad_place_with_imports now also resolves inline :solid import declarations — the driver orchestrates two-phase resolution (data from Ruby bridge, solid from sidecar ADT cache).
cd /Users/darwin/x/p/supex-ws/loon && cargo test -p loon-lang
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_solid_imports.py -vAutomatically re-evaluate vcad nodes when their .cmp.oo source files change on disk. Uses the DAG from Phase dag to cascade downstream updates.
Add notify dependency to vcad/sidecar/Cargo.toml:
notify = { version = "7", features = ["macos_fsevent"] }New module vcad/sidecar/src/watcher.rs:
use notify::{Watcher, RecursiveMode, Event, EventKind};
use std::path::PathBuf;
use std::sync::mpsc;
pub struct FileWatcher {
watcher: notify::RecommendedWatcher,
rx: mpsc::Receiver<Event>,
watch_dir: Option<PathBuf>,
}
impl FileWatcher {
pub fn new() -> Self { ... }
/// Start watching a project directory.
pub fn watch(&mut self, dir: &Path) -> Result<(), String> { ... }
/// Poll for changed files. Non-blocking.
pub fn poll_changes(&self) -> Vec<FileChange> { ... }
}
pub struct FileChange {
pub path: PathBuf,
pub kind: FileChangeKind,
}
pub enum FileChangeKind {
VcadLoon, // .cmp.oo file
Loon, // .loon library (handled in Phase mod-track)
}vcad.watch_start(dir: string) — start watching project directory
vcad.watch_stop() — stop watching
vcad.watch_poll() — returns list of changed files since last poll
The driver polls the sidecar for file changes (or receives push notifications). When a .cmp.oo file changes:
- Find the vcad node whose
source_filematches the changed path - Call
vcad_update_cascade(node_id)from Phase dag - All downstream nodes are re-evaluated in topological order
- Watch starts when the first
vcad_placeis called (auto-detect project root from source_file) - Watch stops when driver disconnects or explicitly via
vcad.watch_stop()
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_fs_watch.py -vEnable :solid import from native SketchUp geometry (non-vcad-backed). Ruby bridge extracts the triangulated mesh from SketchUp solids, sidecar wraps it as ImportedMesh ADT. Non-solid entities (open meshes) are strictly rejected.
- Phase adt-imports (import system)
- Phase vcad-patch (CSG-compatible ImportedMesh support)
The :solid extract now handles three cases:
| Entity type | Behavior |
|---|---|
| vcad-backed | Cached Value::Adt from ADT cache (Phase adt-imports, unchanged) |
Native solid (manifold? == true) |
Mesh extracted, wrapped as ImportedMesh ADT, participates in CSG |
Native non-solid (manifold? == false) |
Error — "entity is not a solid" |
def extract_solid_mesh(entity)
defn = entity.respond_to?(:definition) ? entity.definition : entity
raise "Entity is not a solid" unless defn.entities.manifold?
positions = []
indices = []
normals = []
vertex_offset = 0
defn.entities.grep(Sketchup::Face).each do |face|
mesh = face.mesh(0) # 0 = no UVs, just geometry
# PolygonMesh: vertices are 1-based
mesh.count_points.times do |i|
pt = mesh.point_at(i + 1)
positions.push(pt.x.to_mm, pt.y.to_mm, pt.z.to_mm)
n = mesh.normal_at(i + 1)
normals.push(n.x, n.y, n.z)
end
mesh.count_polygons.times do |i|
tri = mesh.polygon_at(i + 1) # returns array of vertex indices (1-based, may be negative)
tri.each { |vi| indices.push(vi.abs - 1 + vertex_offset) }
end
vertex_offset += mesh.count_points
end
{ positions: positions, indices: indices, normals: normals }
endExtends the Phase adt-imports resolve tool with native mesh support:
when 'solid'
if vcad_node_id
# vcad-backed: return node_id, sidecar will use cached ADT
{ extract: 'solid', source: 'vcad', vcad_node_id: vcad_node_id }
elsif defn && defn.entities.manifold?
# Native SketchUp solid: extract mesh
mesh_data = extract_solid_mesh(defn)
{ extract: 'solid', source: 'native_mesh', mesh: mesh_data }
else
raise "Entity #{entity_id} is not a solid — :solid import unavailable"
endWhen the driver sends a resolved import with source: "native_mesh", the sidecar wraps the mesh data as a Loon ADT:
fn wrap_native_mesh(mesh: &MeshData) -> Value {
Value::Adt(
"ImportedMesh".to_string(),
vec![
Value::Vec(mesh.positions.iter().map(|&v| Value::Float(v)).collect()),
Value::Vec(mesh.indices.iter().map(|&v| Value::Int(v as i64)).collect()),
Value::Vec(mesh.normals.iter().map(|&v| Value::Float(v)).collect()),
],
)
}This ADT is injected as a Loon binding, same as vcad-backed imports. User code is identical:
; Works the same whether entity is vcad-backed or native SketchUp solid
[let wall [import :solid "entity:11111"]]
[pipe [cube 200.0 100.0 300.0]
[difference wall]]
The importing code doesn't need to know the source — both sources behave as Solid ADT values in CSG.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_native_imports.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_native_imports_mock.py -v
# CSG check:
# - native mesh + difference/union/intersection succeedsTrack which .loon library files each vcad node depends on (via [use ...]). When a shared library file changes, identify all affected nodes and cascade re-evaluation.
- Phase fs-watch (filesystem watcher)
- Phase loon-patch (loon-lang patch — env-seeded eval API + loaded_module_paths)
struct ModuleTracker {
// Maps: node_id -> Set<file_path> (all .loon files loaded via [use ...])
node_modules: HashMap<String, HashSet<PathBuf>>,
// Inverse: file_path -> Set<node_id> (which nodes depend on this file)
file_dependents: HashMap<PathBuf, HashSet<String>>,
}
impl ModuleTracker {
fn record_evaluation(&mut self, node_id: &str, loaded_paths: Vec<PathBuf>);
fn get_affected_nodes(&self, changed_file: &Path) -> Vec<String>;
fn clear_node(&mut self, node_id: &str); // called before re-eval to rebuild
}After each evaluation, the sidecar calls record_evaluation(node_id, loaded_paths) with the paths returned from eval_program_with_module_tracking. When a .loon file changes, get_affected_nodes() returns all vcad nodes that need re-evaluation. No AST scanning or regex needed — the tracking comes directly from the interpreter's module resolution.
The watcher from Phase fs-watch already detects .loon file changes (tagged as FileChangeKind::Loon). In this phase, the driver handles them:
.loonfile change detected- Query module tracker:
get_affected_nodes(changed_path)→ list of node_ids - For each affected node:
vcad_update_cascade(node_id)(deduplicated)
vcad nodes share constants, helpers and parametric functions through Loon's native [use ...] module system. The user's project is a set of .loon files — Loon resolves them from the project root.
; src/dims.loon — shared dimensions library
[pub let plate-thickness 10.0]
[pub let bolt-radius 4.0]
; base-plate.cmp.oo
[use dims]
[cube 200.0 200.0 dims.plate-thickness]
; bracket.cmp.oo
[use dims]
[pipe [cube 20.0 20.0 50.0]
[translate 0.0 0.0 dims.plate-thickness]]
No custom export/import forms needed for code sharing — Loon's [use ...] handles it natively. The sidecar passes the project root as base_dir to eval_vcad_file().
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_module_tracking.py -vDetect SketchUp entity modifications and trigger re-evaluation of dependent vcad nodes. Add batch mode (pause/resume) for agent workflows that edit multiple files at once.
- Phase dag (DAG)
- Phase fs-watch (filesystem watcher)
class VcadModelObserver < Sketchup::ModelObserver
def onTransactionCommit(model)
# Detect which entity IDs changed and enqueue them into bridge-local buffer.
# No push transport: driver pulls these IDs via vcad.observer_poll.
end
endWhen a SketchUp entity is modified (moved, scaled, edited), the observer appends changed entity IDs into a deduplicating queue in Ruby runtime. The driver polls this queue and looks up the DAG: get_dependents_of_entity(entity_id) → list of vcad nodes that import this entity → cascade re-evaluation.
Bridge exposes pull-based observer methods on the existing TCP JSON-RPC channel:
vcad.observer_start— attach observer + initialize queuevcad.observer_stop— detach observer + clear queuevcad.observer_poll— return deduplicated changed IDs since last poll
Example vcad.observer_poll response:
{
"changed_entity_ids": [12345, 98765],
"dropped": 0,
"queue_size": 2
}Queue behavior:
- deduplicate IDs per poll window
- bounded queue (
VCAD_OBSERVER_MAX_QUEUE, default 2048) - overflow increments
droppedcounter for diagnostics
Driver runs a periodic poll (VCAD_OBSERVER_POLL_MS, default 250ms):
- ensure startup reconciliation finished (
reconcile_with_sketchup) before enabling reactive loop - call
vcad.observer_poll - merge with filesystem/module-triggered pending set
- append events into trigger-coalescing buffer (
VCAD_TRIGGER_COALESCE_MS) - respect
vcad_watch_pause()state - when coalescing window closes, trigger one deduplicated topological cascade
- Observer attached when first vcad node is placed
- Observer detached when all vcad nodes are removed or driver disconnects
- Ruby bridge exposes
vcad.observer_start/vcad.observer_stop/vcad.observer_pollJSON-RPC methods
Agent or user can suspend reactive re-evaluation for batch operations — e.g. when the agent edits multiple .cmp.oo files in sequence as part of a single task.
@mcp.tool()
def vcad_watch_pause(ctx: McpContext) -> str:
"""Pause reactive watching. Changes accumulate but don't trigger re-evaluation."""
@mcp.tool()
def vcad_watch_resume(ctx: McpContext) -> str:
"""Resume watching and flush: re-evaluate all nodes affected by accumulated changes (deduplicated, single cascade)."""While paused:
- File changes and SketchUp entity modifications are collected in a pending set
- No re-evaluation happens
- On resume: all accumulated changes are merged, deduplicated, topologically sorted, and executed as a single cascade — avoids redundant intermediate evaluations
Typical agent workflow:
vcad_watch_pause()- Edit
src/dims.loon,bracket.cmp.oo,plate.cmp.oo vcad_watch_resume()— one cascade covers all changes
- Change detected (SketchUp entity queue via poll,
.cmp.oofile, or.loonlibrary) - If watching paused: add to pending set, return
- Driver adds event to coalescing window buffer (multi-source)
- On coalescing window close, compute union of affected node IDs via DAG + module tracker
- For each affected node, bump monotonic
revisionbefore enqueueing eval - Topological sort of affected subgraph
- Sequential re-evaluation — each step updates sidecar ADT cache before next step
- Apply result only if revision is still current; stale late results are dropped
- SketchUp meshes updated per-node (re-import via
obj_path, currently pointing to.daeartifacts) - Viewer notified to refresh (Phase viewer-live) with same
revision
Coalescing safety:
- no parallel cascades; while one cascade runs, new events accumulate for next coalescing batch
- bounded wait behavior must prevent starvation under sustained change bursts
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_observer.py -v
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_observer_mock.py -v
# storm/dedup check:
# - apply multiple rapid edits to the same entity inside one poll window
# - vcad.observer_poll returns one deduplicated entity ID
# - verify one cascade run (no re-eval storm)
# coalescing check:
# - emit fs-watch + mod-track + observer changes within one coalesce window
# - verify one merged cascade for unioned node set
# - verify continuous stream still executes bounded batches (no starvation)
# stale-result race check:
# - trigger two updates for one node (older eval intentionally slower than newer)
# - verify only newer revision is applied in SketchUp + viewer
# - verify stale-drop metric/log increments and no rollback flicker appearsAdd a dedicated observability phase for runtime telemetry, health diagnostics, and trace correlation across MCP driver, sidecar, SketchUp bridge, and viewer relay.
- Phase vcad-conn
- Phase dag
- Phase viewer-live
- Phase su-observer
Expose a machine-readable metrics snapshot with stable names:
| Metric | Type | Meaning |
|---|---|---|
queue_depth |
gauge | current sidecar eval queue depth observed by driver |
queue_rejected_total |
counter | rejected evals (queue full) |
eval_timeout_total |
counter | eval timeout count |
stale_dropped_total |
counter | stale results dropped by revision guard |
superseded_dropped_total |
counter | pending jobs marked superseded by same-node enqueue |
superseded_skipped_before_eval_total |
counter | superseded jobs skipped by worker before compute starts |
coalesce_batches_total |
counter | number of coalesced trigger batches executed |
coalesce_merged_events_total |
counter | total source events merged into coalesced batches |
coalesce_window_ms_effective |
gauge | currently effective coalescing window in milliseconds |
artifact_manifests_written_total |
counter | eval artifact manifests written successfully |
artifact_manifests_retained_current |
gauge | currently retained artifact manifests in temp storage |
artifact_pair_recovery_total |
counter | incomplete artifact pairs recovered on startup |
artifact_pair_incomplete_detected_total |
counter | incomplete pair markers detected during recovery scan |
reconcile_runs_total |
counter | startup/recovery reconciliation runs |
reconcile_drift_total |
counter | total drift items found by reconcile |
degraded_nodes_current |
gauge | nodes currently in degraded state |
viewer_reconnect_total |
counter | accepted viewer reconnect handshakes |
Rules:
- Counters are monotonic for one driver process lifetime.
- Gauges represent current state and can move up/down.
- Metric names and units are part of the public contract and covered by tests.
New MCP diagnostics module (e.g. driver/src/supex_driver/mcp/vcad_diagnostics.py):
@mcp.tool()
def vcad_health(ctx: McpContext) -> str:
"""Return liveness/readiness + negotiated protocol/capability summary for sidecar and viewer relay."""
@mcp.tool()
def vcad_metrics(ctx: McpContext) -> str:
"""Return current telemetry snapshot (stable JSON schema)."""
@mcp.tool()
def vcad_reconcile_status(ctx: McpContext) -> str:
"""Return last reconcile run: timestamp, drift summary, pending corrective actions."""Diagnostics response contract:
vcad_health: includesstatus,sidecar.protocol_version,viewer.protocol_version,capabilities,last_error.vcad_metrics: includes all required metrics pluscollected_attimestamp.vcad_reconcile_status: includeslast_run_at,driftbuckets,pending_nodes, andlast_outcome.vcad_metricsmay includelast_artifact_manifestwhen artifact manifest phase is enabled.- diagnostics outputs may include
manifest_read_errorsfor non-fatal per-artifact read/validation issues. - diagnostics may include
in_progress_artifactsfor visibility/debug only; committed lists must exclude them.
- Every MCP vcad request carries a
request_id(correlation_id) from tool entry to:- driver logs
- sidecar JSON-RPC metadata
- SketchUp bridge calls
- viewer relay messages/events
- Structured log events (JSON) must include at least:
timestamp,level,request_id,node_id(when available),event,error_code(when failure)
- Required event names:
eval_enqueued,eval_started,eval_finished,eval_timeout,stale_dropped,reconcile_started,reconcile_finished,viewer_reconnected.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_observability.py -v
# metrics determinism check:
# - run stale-result race and verify stale_dropped_total increments exactly once for one dropped result
# - run queue overflow scenario and verify queue_rejected_total increments with stable counts
# - run same-node burst and verify superseded_dropped_total / superseded_skipped_before_eval_total are deterministic
# - run multi-source burst and verify coalesce_batches_total / coalesce_merged_events_total are deterministic
# - crash-recovery scenario updates artifact_pair_incomplete_detected_total / artifact_pair_recovery_total deterministically
# correlation check:
# - execute one vcad_place flow and verify same request_id appears in driver, sidecar, and relay logs
# recovery diagnostics check:
# - force reconcile drift and verify vcad_reconcile_status reports buckets + pending actions
# committed visibility check:
# - in-progress/staged artifacts never appear in committed artifact outputs
# - optional in_progress_artifacts section reflects pending markers without polluting committed resultsDefine explicit, versioned JSON schema contracts for payloads exchanged across MCP, driver, sidecar, SketchUp bridge, and viewer relay to prevent silent integration breakage.
- Phase vcad-conn
- Phase mcp-tools
- Phase viewer-live
- Phase ops-observability
Add versioned contract files:
docs/contracts/
v1/
handshake.schema.json
tools-call.schema.json
vcad-tools-results.schema.json
viewer-relay.schema.json
error-envelope.schema.json
artifact-manifest.schema.json
examples/
handshake.ok.json
handshake.protocol-mismatch.json
vcad_place.success.json
vcad_place.error.json
viewer.ready.ok.json
scene.snapshot.ok.json
artifact.applied.json
artifact.stale_dropped.json
artifact.superseded.json
Scope of required schemas:
handshake.schema.json:hellorequest/response (protocol_version,capabilities,limits)tools-call.schema.json:tools/callinput envelope (name,arguments,request_id)vcad-tools-results.schema.json: canonical success payloads forvcad_*MCP toolsviewer-relay.schema.json: relay messages (mesh.update,mesh.remove,scene.snapshot,viewer.ready, screenshot metadata)error-envelope.schema.json: stable error shape (error_code,message, conditionally requireddetails)artifact-manifest.schema.json: deterministic artifact manifest shape (status, paths, hashes, geometry summary, timings)
Manifest schema requirements:
- required common fields:
status,source_hash,obj_path,finished_at- note:
obj_pathis a compatibility field name and currently points to.daeartifacts
- note:
- conditional requirements by
status:applied: requirerevision,bbox,volume,surface_areastale_droppedandsuperseded: require drop/supersede reason details
- strict types and RFC3339 timestamp format
- Additive changes (new optional fields) are allowed within the same
protocol_version. - Breaking changes require
protocol_versionmajor bump and a new contract folder (e.g.docs/contracts/v2/). - Deprecated fields must stay readable for one transition window and be documented in schema
description. - Parser behavior must be forward-tolerant for unknown fields unless schema marks them forbidden.
- Driver validates incoming sidecar/viewer payloads against negotiated schema version.
- Sidecar validates
tools/call.argumentsand handshake payload before execution. - Driver-side diagnostics validate loaded artifact manifests against
artifact-manifest.schema.json. - Diagnostics manifest reads are best-effort: one invalid/missing manifest must not fail the whole diagnostics response.
- Validation failures return
SCHEMA_VALIDATION_FAILEDwithdetails.pathanddetails.expectedwhen available. - Error messages remain human-friendly, but tests assert only
error_code+ machine fields.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_schema_contracts.py -v
# schema fixture checks:
# - validate all examples/*.json against docs/contracts/v1/*.schema.json
# - keep golden fixtures for each vcad_* tool success + failure payload
# - validate artifact.*.json fixtures against artifact-manifest.schema.json
# negative validation checks:
# - missing required handshake field -> SCHEMA_VALIDATION_FAILED
# - wrong type in viewer relay payload -> SCHEMA_VALIDATION_FAILED
# - missing required artifact field / invalid status-conditional field -> SCHEMA_VALIDATION_FAILED
# - mixed valid+invalid manifests still return partial diagnostics results with per-item errors
# compatibility checks:
# - previous fixtures remain accepted by current parser when only additive fields changed
# - breaking fixture requires protocol bump and fails deterministically under old versionReduce wasted evaluation work by skipping superseded pending jobs for the same node while preserving deterministic DAG order and stale-result safety.
Implementation note: base behavior already exists in driver/src/supex_driver/connection/vcad_state.py::EvalQueue; this phase wires and hardens that logic in production flow instead of rewriting it from scratch.
- Phase vcad-conn
- Phase dag
- Phase ops-observability
- Queue tracks job metadata:
{job_id, node_id, revision, state}wherestate in {pending, running, done, superseded}. - Enqueue rule: when a new job for
node_idarrives, mark olderpendingjobs for the samenode_idassuperseded. - Worker pre-start rule: immediately before eval compute, if job is
superseded, skip compute and emit skip telemetry. - Running jobs are not cancelled; their results still pass through stale-result guard before apply.
- Client-visible behavior is unchanged (no new public error code for supersede pruning).
- Pruning is allowed only among jobs for the same
node_id. - Pruning must not reorder dependencies across different nodes in one cascade.
- For a topological cascade batch, keep per-node latest revision and preserve inter-node order constraints.
- Increment
superseded_dropped_totalwhen older pending job is marked superseded. - Increment
superseded_skipped_before_eval_totalwhen worker skips superseded job pre-compute. - Include queue-pruning events in structured logs with
request_id,node_id,revision,event.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_queue_pruning.py -v
# same-node burst check:
# - enqueue rapid edits for one node
# - verify only latest pending revision is evaluated
# topo safety check:
# - run upstream/downstream cascade with overlaps
# - verify dependency order is preserved across different nodes
# race/latency check:
# - combine long-running eval + rapid superseding updates
# - verify no rollback, no starvation, deterministic supersede countersStandardize details payload shape for key error_code values so callers can rely on deterministic machine-readable diagnostics.
- Phase mcp-tools
- Phase ops-observability
- Phase schema-contracts
For these error codes, details is mandatory and must include the listed keys:
| error_code | Required details keys |
|---|---|
PROTOCOL_MISMATCH |
expected_protocol, actual_protocol, operation |
CAPABILITY_UNAVAILABLE |
required_capability, negotiated_capabilities, operation |
PATH_NOT_ALLOWED |
path, workspace, operation |
SOURCE_FILE_MISSING |
node_id, source_file, operation |
STATE_RECONCILE_REQUIRED |
drift, pending_nodes, operation |
ARTIFACT_READ_FAILED |
manifest_path, reason, operation |
Normalization rules:
- Keep
error_codestable from origin (sidecar/ruby/driver/viewer relay) to MCP response. - Driver must not replace upstream
error_code; it may only fill missingdetails.operation. - If a required key is missing at boundary output, normalize response to
SCHEMA_VALIDATION_FAILEDwith path info. - Diagnostics may return per-item errors (e.g., manifest read failures) while preserving partial successful results.
- Human-readable
messageis informative only; agent logic must consumeerror_code+details.
- Reflect required
detailskeys indocs/contracts/v1/error-envelope.schema.jsonwith conditional requirements pererror_code. - Keep example fixtures for each normalized error in
docs/contracts/examples/.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_error_details.py -v
# required-details checks:
# - each mapped error_code returns all required details keys
# - message-only errors without required details fail tests
# passthrough checks:
# - upstream error_code is preserved unchanged through MCP boundary
# - driver enrichment is limited to missing details.operation
# negative checks:
# - omit one required detail key and verify SCHEMA_VALIDATION_FAILED at boundaryMerge rapid trigger bursts from multiple sources into deterministic batched cascades to reduce redundant re-evaluations without changing external APIs.
Implementation note: core coalescer behavior already exists in driver/src/supex_driver/connection/vcad_state.py::TriggerCoalescer; this phase wires it to watcher/module/observer sources and adds contract-level telemetry.
- Phase fs-watch
- Phase mod-track
- Phase su-observer
- Phase dag
- Phase ops-observability
- Configure coalescing window with
VCAD_TRIGGER_COALESCE_MS(default150). - Sources merged into one trigger stream:
- filesystem watcher (
.cmp.oo) - module tracker (
.loondependencies) - SketchUp observer poll
- manual update triggers
- filesystem watcher (
- Within one window, accumulate a deduplicated event set and compute union of affected
node_ids. - On window close, execute one topological cascade for that union.
- At most one cascade runs at a time.
- If new events arrive during an active cascade, buffer them for the next coalescing window.
- No parallel cascade execution; deterministic batch boundaries only.
- Implement bounded-wait flush behavior to prevent starvation during continuous event streams.
- Increment
coalesce_batches_totalper executed coalesced batch. - Increment
coalesce_merged_events_totalby number of source events merged. - Publish effective window via
coalesce_window_ms_effective.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_trigger_coalescing.py -v
# multi-source burst check:
# - emit fs-watch/mod-track/observer events in one window
# - verify one cascade over unioned affected nodes
# active-cascade deferral check:
# - inject events during running cascade
# - verify they run in next batch only (no parallel cascade)
# starvation bound check:
# - sustain long event stream and verify bounded flush intervals + forward progressIntroduce one shared boundary error builder pattern so all layers emit the same validated error envelope (error_code, message, details) without ad-hoc formatting.
- Phase error-details
- Phase schema-contracts
Define a canonical helper in each runtime boundary:
- Driver:
build_error(code, message, details, operation) - Sidecar: equivalent helper for JSON-RPC error
data - Viewer relay: equivalent helper for relay/MCP-facing error payloads
Builder behavior:
- validates required
detailskeys for mapped error codes (from Phase error-details) - auto-fills missing
details.operationfrom current operation context - preserves upstream
error_codewhen forwarding across boundaries - if internal payload is invalid, converts to
SCHEMA_VALIDATION_FAILEDwith path diagnostics
- Replace manual error envelope assembly in MCP vcad tools with builder calls.
- Replace sidecar manual
error_response(...)payload assembly with builder-backed generation. - Replace viewer relay capability/protocol error assembly with builder-backed generation.
- Keep human-readable
message, but machine behavior must depend onerror_code+detailsonly.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_error_builder.py -v
# builder unit checks:
# - each mapped error_code validates required details keys
# - missing details.operation is auto-filled from operation context
# regression checks:
# - no boundary path returns message-only error without structured details
# - invalid internal payload normalizes to SCHEMA_VALIDATION_FAILED
# snapshot checks:
# - JSON error envelope shape is stable for representative errorsEmit a deterministic manifest for each evaluation artifact, define committed visibility semantics, and make diagnostics resilient to partial manifest failures — so debugging, replay, and post-mortem tooling can reference exact inputs/outputs without inspecting ad-hoc logs.
- Phase sidecar
- Phase ops-observability
- Phase queue-pruning
- Phase trigger-coalescing
- Phase schema-contracts
- Phase error-details
For each eval artifact, write <obj_filename>.manifest.json atomically with fields:
status(applied,stale_dropped,superseded)node_id,revision,request_idsource_file,source_hashimportsmetadata snapshot (references/types only; no heavy payload duplication)- output summary:
obj_path,bbox,volume,surface_area- note:
obj_pathmay contain a.daepath
- note:
- timings:
queued_at,started_at,finished_at
Determinism rules:
- canonical JSON field ordering and stable timestamp format (RFC3339 UTC)
source_hashcomputed from canonicalized source/document input- stale/superseded paths still emit manifest with terminal status and reason context
Schema contract alignment:
- Manifest payloads must validate against
docs/contracts/v1/artifact-manifest.schema.json. - Diagnostics that read manifests must return
SCHEMA_VALIDATION_FAILEDfor invalid manifest files.
- Treat mesh artifact (
obj_path, currently.dae) + manifest as one artifact set for TTL/count retention. - Cleanup must remove pairs together and avoid orphan files.
- Restart sequence handling must keep unique filenames and pair integrity.
- Write staged files first:
*.<mesh-ext>.tmpand*.manifest.json.tmp. - Create a pending pair marker (e.g.
*.pair.pending) before publish. - Publish by rename sequence, then remove pending marker only after both files are committed.
- Readers/diagnostics ignore staged files and any pair with pending marker.
- On sidecar startup, run recovery scan:
- detect pending markers
- remove incomplete staged/half-published artifacts
- remove stale pending markers
- increment recovery counters
manifest_pathis derived internally from acceptedobj_path; no user-supplied manifest paths.- Before write/read, canonicalize path and verify it remains under workspace-scoped allowed root.
- Reject
../traversal and symlink escapes withPATH_NOT_ALLOWED. - Diagnostics lookup must enforce the same policy when opening historical manifest files.
An artifact is committed only when all are true:
- mesh file referenced by
obj_pathexists - matching
.manifest.jsonexists - no matching
*.pair.pendingmarker exists
Visibility behavior:
- staged files (
*.tmp) and pending pairs are excluded from committed listings - diagnostics may expose optional
in_progress_artifactsfor debugging - committed artifact queries (
last_artifact_manifest, history lookups) must use the same shared commit-check helper - retention operates on committed pairs only
- recovery handles in-progress artifacts and pending markers
- after recovery, no half-visible committed artifacts may remain
- Diagnostics manifest reads are best-effort and item-scoped.
- Valid manifests contribute normal diagnostic data.
- Invalid/missing/unreadable manifests append entries to
manifest_read_errorsand processing continues. - Staged files and
*.pair.pendingmarkers are treated as in-progress artifacts and skipped from normal reads. - Response-level failure is reserved for transport/protocol failures, not individual manifest defects.
Per-item error mapping:
- schema/type violations:
SCHEMA_VALIDATION_FAILED - missing/unreadable/corrupt file:
ARTIFACT_READ_FAILED - path traversal/symlink escape outside allowed root:
PATH_NOT_ALLOWED
Per-item error payload shape:
manifest_patherror_codemessagedetails(includes at leastoperationand reason/path context)
vcad_metricsincludes:- normal metrics payload
- optional
last_artifact_manifest - optional
manifest_read_errors: []
- Deterministic ordering:
manifest_read_errorssorted bymanifest_paththenerror_code. - If all manifests fail, diagnostics still return metrics + populated
manifest_read_errors. - observability counters include successful manifest writes and current retained manifest count.
cd /Users/darwin/x/p/supex-ws/supex/driver && uv run pytest tests/test_vcad_artifact_manifest.py -v
# reproducibility check:
# - same input/revision yields stable manifest keys and source_hash
# status-path check:
# - stale/superseded eval paths write manifest with correct terminal status
# retention pairing check:
# - cleanup removes mesh-artifact/manifest pairs together (no orphan files)
# atomic pair commit check:
# - inject failure between publish steps and verify no committed mesh-without-manifest (or inverse)
# - restart sidecar and verify recovery scan resolves pending/incomplete pairs
# path hardening check:
# - traversal/symlink manifest path attempts fail with PATH_NOT_ALLOWED
# - valid internally derived manifest paths still read/write successfully
# committed visibility check:
# - interrupt pair publish mid-sequence -> committed list remains consistent
# - once both files committed and marker removed, artifact appears exactly once
# - after restart/recovery, no half-visible state remains in committed outputs
# diagnostics reader check:
# - prepare valid + invalid + missing manifests -> partial success + per-item errors
# - invalid schema => SCHEMA_VALIDATION_FAILED
# - missing/unreadable file => ARTIFACT_READ_FAILED
# - path escape/symlink outside workspace => PATH_NOT_ALLOWED
# - no crash on malformed manifest files
# - deterministic ordering of manifest_read_errors
# diagnostics integration check:
# - vcad_metrics returns last_artifact_manifest when available
# compatibility check:
# - additive manifest fields remain accepted by current parser/schema without protocol bumpRecommended implementation sequence:
sidecarvcad-connruby-toolsmcp-toolssu-mocke2edata-importsloon-patchadt-imports->dag->fs-watchvcad-patch->native-meshmod-trackviewer->viewer-livesu-observerops-observabilityschema-contractserror-detailsboundary-error-builderqueue-pruningtrigger-coalescingartifact-manifest
sidecar ← vcad-conn ← ruby-tools ← mcp-tools ← data-imports ← adt-imports ← dag ← fs-watch ← mod-track
| | | | ^ |
viewer | su-mock --------+ loon-patch |
| | vcad-patch
viewer-live ←-+ |
native-mesh
su-observer ← dag + fs-watch
ops-observability ← vcad-conn + dag + viewer-live + su-observer
schema-contracts ← vcad-conn + mcp-tools + viewer-live + ops-observability
error-details ← mcp-tools + ops-observability + schema-contracts
boundary-error-builder ← error-details + schema-contracts
queue-pruning ← vcad-conn + dag + ops-observability
trigger-coalescing ← fs-watch + mod-track + su-observer + dag + ops-observability
artifact-manifest ← sidecar + ops-observability + queue-pruning + trigger-coalescing + schema-contracts + error-details
e2e ← sidecar + vcad-conn + ruby-tools + mcp-tools (+ su-mock for CI)
- Phase sidecar is the foundation — everything else depends on it
- Phase vcad-conn depends on Phase sidecar (VcadConnection + sidecar lifecycle)
- Phase ruby-tools depends on existing runtime code and establishes SketchUp-side vcad operations used by later driver orchestration
- Phase mcp-tools depends on Phase vcad-conn + Phase ruby-tools (driver orchestration of sidecar + SketchUp runtime)
- Phase su-mock depends on Phase ruby-tools (loads real runtime code including vcad tool dispatch); used for CI-friendly integration tests
- Phase e2e depends on sidecar + vcad-conn + ruby-tools + mcp-tools; su-mock provides the thorough CI path while real SketchUp remains smoke-only
- Phase viewer depends on Phase sidecar only (standalone viewer, loads mesh artifacts from files)
- Phase viewer-live depends on Phase viewer + Phase vcad-conn (driver relay WebSocket + MCP tools)
- Phase data-imports extends mcp-tools+ruby-tools with import resolution protocol + data extracts (supex only); su-mock-based pytest verification
- Phase loon-patch is required before adt-imports and mod-track (adds env-seeded eval API and loaded module path tracking)
- Phase adt-imports depends on data-imports + loon-patch for :solid import via ADT cache composition
- Phase vcad-patch is a vcad repo patch that provides CSG-compatible ImportedMesh behavior
- Phase native-mesh depends on adt-imports+vcad-patch (native mesh import needs both import system and CSG-compatible ImportedMesh); su-mock-based pytest verification
- Phase dag extends adt-imports with DAG data structure + manual cascade (no watchers)
- Phase fs-watch extends dag with filesystem watching for .cmp.oo files
- Phase mod-track extends fs-watch+loon-patch with module tracking + .loon library watching
- Phase su-observer extends fs-watch with SketchUp model observer + batch mode (pause/resume); su-mock-based pytest verification
- Phase ops-observability depends on vcad-conn+dag+viewer-live+su-observer and defines runtime telemetry + diagnostics contracts
- Phase schema-contracts depends on vcad-conn+mcp-tools+viewer-live+ops-observability and defines versioned payload schemas + compatibility policy
- Phase error-details depends on mcp-tools+ops-observability+schema-contracts and normalizes required
detailskeys per error code - Phase boundary-error-builder depends on error-details+schema-contracts and centralizes boundary error envelope generation
- Phase queue-pruning depends on vcad-conn+dag+ops-observability and adds same-node pending-job supersede pruning without API changes
- Phase trigger-coalescing depends on fs-watch+mod-track+su-observer+dag+ops-observability and batches multi-source trigger bursts
- Phase artifact-manifest depends on sidecar+ops-observability+queue-pruning+trigger-coalescing+schema-contracts+error-details and covers deterministic eval artifacts, committed visibility semantics, and resilient diagnostics reader
Phases vcad-patch and loon-patch introduce changes to the vcad/loon repos maintained as patch branches:
vcad patches (Phase vcad-patch):
- Branch:
supex-patchesin/Users/darwin/x/p/supex-ws/vcad - Files:
cad-lib/src/lib.loon(add ImportedMesh ADT variant)crates/vcad-loon/src/convert.rs(add conversion)crates/vcad-eval/src/evaluate.rs(enable ImportedMesh in boolean/CSG branches)
- Note:
CsgOp::ImportedMeshalready exists in vcad-ir; evaluator behavior for CSG composition must be explicitly validated
loon-lang patches (Phase loon-patch):
- Branch:
supex-patchesin/Users/darwin/x/p/supex-ws/loon - Files:
crates/loon-lang/src/interp/mod.rs(addeval_program_with_env_and_base_dir(...))crates/loon-lang/src/module.rs(addloaded_module_paths()toModuleCache)
- Note: this patch is required for both adt-imports (env-seeded eval) and mod-track (module tracking)
Maintenance:
- Rebase on upstream updates:
git rebase origin/main - After rebase:
cargo buildin supex sidecar to pick up changes
Four testing tiers:
- Unit tests:
cargo test(Rust), minitest (Ruby), pytest with mocks (Python), vitest (viewer TypeScript) — fast, no external processes - Viewer headless tests: vitest +
@react-three/test-renderer+ happy-dom — scene graph structure, store logic, WebSocket relay client; CI-friendly, no browser or display server needed - Integration tests (su-mock): pytest against su-mock Ruby process — real runtime code, mock SketchUp API, TCP protocol exercised end-to-end, CI-friendly
- E2E tests: real SketchUp + sidecar — smoke tests only, not CI-friendly
Per-phase breakdown:
- Phase sidecar:
cargo testunit tests (Loon eval, DAE export, TCP server) - Phase vcad-conn: pytest for VcadConnection + sidecar lifecycle
- Phase viewer: vitest headless (store/logic +
@react-three/test-rendererscene graph) + manualnpm run tauri devfor visual check - Phase viewer-live: vitest headless (DriverRelayClient, revision guard, reconnect/snapshot) + manual
npm run tauri devfor live relay check - Phase mcp-tools: pytest for MCP vcad tools with mock sidecar
- Phase su-mock: Ruby self-tests (
mock/test/test_mock_api.rb) verifying mock API completeness - Phase ruby-tools: minitest in Ruby (vcad_tools module) + pytest against su-mock (
-k vcad_ruby_mock) - Phase e2e: thorough tests via su-mock; real SketchUp only for smoke tests
- Phase data-imports: pytest for import extraction + data binding injection; su-mock-based integration tests
- Phase adt-imports: pytest for ADT cache + solid import composition
- Phase vcad-patch:
cargo test -p vcad-loon+cargo test -p vcad-evalin vcad repo, plus ImportedMesh-in-CSG acceptance test - Phase native-mesh: pytest + Ruby tests for mesh extraction; include CSG composition check (native mesh in difference/union/intersection succeeds)
- Phase dag: pytest for DAG structure + manual cascade
- Phase fs-watch: pytest for filesystem watcher integration
- Phase loon-patch:
cargo test -p loon-langin loon repo - Phase mod-track: pytest for module tracking + sidecar integration
- Phase su-observer: pytest + Ruby tests for model observer + batch mode; su-mock-based integration tests
- Phase ops-observability: pytest for health/metrics/reconcile diagnostics and request-id correlation across layers
- Phase schema-contracts: schema fixture validation + compatibility/negative payload tests
- Phase error-details: pytest for required error-details keys, passthrough guarantees, and message-only regression checks
- Phase boundary-error-builder: unit/snapshot tests for centralized builder and boundary regression checks
- Phase queue-pruning: pytest for same-node pruning behavior, topology safety, and supersede telemetry counters
- Phase trigger-coalescing: pytest for merged multi-source batches, deferral during active cascades, and starvation bounds
- Phase artifact-manifest: pytest for manifest determinism, schema fixture validation, status coverage, retention pairing, committed visibility invariants, partial diagnostics with mixed valid/invalid/missing manifests, and deterministic per-item errors
Loon language:
- Syntax: square brackets
[fn name [args] body], not parentheses - Types: ADT via
[type Name [Variant fields...]] - Subject-last convention:
[fillet 2.0 solid]— pipe-compatible - Pipe:
[pipe expr [fn1 arg1] [fn2 arg2]](thread-last) - Modules:
[use module-name]resolves from project root (src/module-name.loon) - Public exports:
[pub let name val],[pub fn name ...] - Values:
Value::Adt(tag, fields)— e.g.,Adt("Cube", [50.0, 30.0, 5.0]) - Comments:
;to end of line
vcad evaluation pipeline:
vcad_loon::eval_vcad(source: &str, base_dir: Option<&Path>) -> Result<Document, String>— prepends lib.loon, parses, evaluates, converts Value -> Documentvcad_loon::eval_vcad_file(path: &Path) -> Result<Document, String>— reads file, derives base_dir automaticallyvcad_loon::eval_vcad_to_value(source: &str, base_dir: Option<&Path>) -> Result<Value, String>— returns rawValue::Adt(for ADT caching)vcad_loon::value_to_document(value: &Value) -> Result<Document, String>— convert ADT tree to IR Documentvcad_eval::evaluate_document(doc: &Document, options: &EvalOptions) -> Result<EvaluatedScene, EvalError>EvalOptions { skip_clash_detection: bool, clock: Option<...> }(current sidecar usesclock: None)EvaluatedScene { parts: Vec<EvaluatedPart>, part_defs, instances, clashes }EvaluatedPart { mesh: EvaluatedMesh, material: String, solid: Option<Solid> }EvaluatedMesh { positions: Vec<f32>, indices: Vec<u32>, normals: Option<Vec<f32>> }
vcad kernel:
vcad_kernel::Solid— BRep solid, supports .volume(), .surface_area(), .bounding_box(), .is_empty(), .to_mesh(segments)TriangleMesh { vertices: Vec<f32>, indices: Vec<u32>, normals: Vec<f32> }— note: field isverticesnotpositionsCsgOp::ImportedMesh { positions: Vec<f64>, indices: Vec<u32>, normals: Option<Vec<f64>>, source: Option<String> }— exists in vcad-ir- ImportedMesh must participate in boolean/CSG composition; this requirement must be covered by tests
- Units: mm throughout
loon-lang:
loon_lang::parser::parse(source: &str) -> Result<Vec<Expr>, ParseError>loon_lang::interp::eval_program(exprs: &[Expr]) -> IResult(alias forResult<Value, InterpError>)loon_lang::interp::eval_program_with_base_dir(exprs: &[Expr], base_dir: Option<&Path>) -> IResultloon_lang::interp::eval_program_with_env_and_base_dir(exprs: &[Expr], env: &mut Env, base_dir: Option<&Path>) -> IResult(added by Phase loon-patch)Value::Adt(String, Vec<Value>)— ADT constructor valuesModuleCache— tracks loaded modules internally inHashMap<PathBuf, ModuleState>; Phase loon-patch exposesloaded_module_paths()
Cargo dependency paths:
- All path deps are relative from
supex-ws/supex/vcad/sidecar/to siblingsupex-ws/vcad/andsupex-ws/loon/crates - Example:
vcad-loon = { path = "../../../vcad/crates/vcad-loon" }resolves tosupex-ws/vcad/crates/vcad-loon - Changes to vcad or loon crates require
cargo buildin the sidecar - The sidecar binary is self-contained after compilation (no runtime deps on source)
Python driver — SketchupConnection (driver/src/supex_driver/connection/sketchup_connection.py):
@dataclass
class SketchupConnection:
host: str; port: int; timeout: float; agent: str
token: str | None; workspace: str | None
sock: socket.socket | None; _identified: bool; _last_activity: float
def send_command(self, method: str, params: dict[str, Any] | None = None,
request_id: Any = None) -> dict[str, Any]: ...
def get_sketchup_connection(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT,
agent: str = "unknown") -> SketchupConnection: ...
# Env: SUPEX_HOST, SUPEX_PORT, SUPEX_TIMEOUT, SUPEX_RETRIES, SUPEX_AUTH_TOKEN, SUPEX_WORKSPACE
# VcadConnection follows the same transport shape and additionally negotiates
# `protocol_version` + `capabilities` during `hello`.Python driver — MCP server (driver/src/supex_driver/mcp/mcp_server.py):
mcp = FastMCP("Supex")
def call_tool(ctx: McpContext, method: str, params: dict[str, Any] | None = None,
operation: str = "operation") -> str: ...
# Existing tools (all @mcp.tool()):
# check_sketchup_status, export_scene, eval_ruby, console_capture_status,
# eval_ruby_file, get_model_info, list_entities, get_selection, get_layers,
# get_materials, get_camera_info, take_screenshot, take_batch_screenshots,
# open_model, save_modelRuby runtime — bridge_server (runtime/src/supex_runtime/bridge_server.rb):
# Tool dispatch chain:
def execute_tool(tool_name, args, workspace)
execute_core_tool(tool_name, args, workspace) ||
execute_introspection_tool(tool_name, args, workspace) ||
(raise "Unknown tool: #{tool_name}")
end
# To add vcad tools: add execute_vcad_tool as third fallback before raiseRuby runtime — tools (runtime/src/supex_runtime/tools.rb):
module SupexRuntime::Tools
# Existing: model_info, list_entities, selection_info, layers_info,
# materials_info, camera_info, take_screenshot, batch_screenshot,
# open_model, save_model
end