Add audited, versioned MCP save workflow (#1452)

- `--export ... --target-version`, and explicit target_format /
  target_version on automation and protocol saves; a conversion that
  would drop unsupported records is refused unless allow_lossy is set.
- `audit` read op: dangling layers/blocks, duplicate handles, non-finite
  bounds, serialization, lossy records and the source DXF handle graph.
- `save_verified`: writes the requested version from the same snapshot a
  normal save writes, reopens it, checks the version and the entity
  manifest, audits the ASCII DXF handle graph and returns a SHA-256.
- ZOOM EXTENTS includes SDF text glyphs.
- Live MCP acceptance script and an optional reference-console audit
  runner under docs/automation.

Squashed from the PR with review fixes: merged with the DWT save path,
the save snapshot, the tool lists added since, and vendor-neutral
harness names; the dated verification record is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Luis MartinezandClaude Opus 5.5 authored and Hakan Seven committed 2026-09-24 16:49:11 +03:00
1 parent 0f99b77004
commit dddf60d79e
16 files changed
+1102 -22

No files matched your search

+2 -1
View File
@@ -179,12 +179,13 @@ The desktop binary supports one-shot conversion, a persistent headless server, a
```bash
OpenCADStudio --export input.dwg output.dxf
OpenCADStudio --export input-r12.dxf output.dwg --target-version R14
OpenCADStudio --serve
OpenCADStudio --serve --port 4242
OpenCADStudio --mcp
```
The automation server exchanges one JSON object per line over standard input/output or a local TCP socket. The self-contained MCP endpoint exposes the live desktop editor through the same tools to every compatible client. To connect a client, configure it to launch `OpenCADStudio --mcp`. See the [MCP control guide](docs/automation/README.md).
The automation server exchanges one JSON object per line over standard input/output or a local TCP socket. The self-contained MCP endpoint exposes the live desktop editor through the same tools to every compatible client. Its `audit` and `save_verified` operations check an explicit DWG/DXF target version, lossy records, raw DXF handle references, reopen the output, compare its semantic manifest, and return a SHA-256 hash. To connect a client, configure it to launch `OpenCADStudio --mcp`. See the [MCP control guide](docs/automation/README.md).
## Plugins
+49
View File
@@ -20,6 +20,19 @@ The server provides four tools:
- `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor.
- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window.
Run `python docs/automation/mcp_acceptance.py target/debug/OpenCADStudio.exe`
for a repeatable end-to-end acceptance. It creates visible geometry through
MCP, audits and verified-saves DWG 2000/2013/2018 plus DXF 2000, and records a
viewport PNG and JSON report below `target/acceptance/`.
On a Windows workstation with a reference CAD application's command-line
console, run
`docs/automation/reference_acceptance.ps1 -Directory target/acceptance/TIMESTAMP -ConsolePath <console.exe>`.
It opens the three generated DWGs read-only in isolated console profiles, runs
AUDIT without repairs, and keeps one log per version. A console that stays
alive after `QUIT` is terminated after the timeout (only the process the runner
launched), and `ForcedTermination` is recorded separately from the audit result.
Native builds also accept a second, headless entry point (`OpenCADStudio --serve`, one
JSON request per line) for scripts and CI; the two native channels, their startup dialogs
and their limits are described in [native.md](native.md).
@@ -40,6 +53,42 @@ The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `oc
Call `ocs_read` with `op: "capabilities"` before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The `records.collections` list gives every available database collection, its record count, and whether it can be edited.
Before delivery, call `ocs_read` with `op: "audit"` and the intended
`target_format` / `target_version`. The report includes entity manifests,
layers, blocks, finite bounds, duplicate entity handles, DXF dangling handle
references, source hash, and `dropped_on_save`. A clean in-memory document can
still be lossy for a different format, so target selection is part of the
audit:
```json
{
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
"op": "audit",
"parameters": {"target_format": "dwg", "target_version": "2000"}
}
```
Use `save_verified` for the delivery write. It requires an explicit absolute
path, refuses overwrite and lossy conversion unless separately acknowledged,
writes the requested version, reopens it, checks the actual version, compares
the semantic entity manifest, audits raw DXF handle references, and returns a
SHA-256 hash. A verification failure preserves the output for diagnosis and
reports it as failed instead of silently declaring success.
```json
{
"ocs_session_id": "SESSION_FROM_OCS_SESSIONS",
"request": {
"op": "save_verified",
"request_id": "deliver-2026-09-22-1",
"path": "C:\\drawings\\issued\\plan.dwg",
"target_format": "dwg",
"target_version": "2000",
"overwrite": false
}
}
```
Call `ocs_read` with `op: "record_schema"` before editing an unfamiliar record. With no parameters it lists the complete generated type registry. A collection returns the record types accepted by that collection even when the current drawing has no instance of a type. Supplying both `collection` and `type` returns the type's complete dependency graph, flattened property paths, JSON types, optional and sequence markers, enum variants, integer bounds, unambiguous unit annotations, identity fields, and write rules:
```json
+152
View File
@@ -0,0 +1,152 @@
"""End-to-end GUI/MCP acceptance with versioned, verified CAD outputs."""
import base64
import json
from pathlib import Path
import struct
import sys
import time
import uuid
from mcp_eval import Client, META
def request_id(prefix: str) -> str:
return f"accept-{prefix}-{uuid.uuid4().hex}"
def main() -> None:
server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio.exe").resolve()
root = Path(sys.argv[2] if len(sys.argv) > 2 else "target/acceptance").resolve()
output = root / time.strftime("%Y%m%d-%H%M%S")
output.mkdir(parents=True, exist_ok=False)
client = Client(server)
session = None
report: dict = {"ok": False, "server": str(server), "output": str(output)}
try:
sessions = client.tool("ocs_sessions", {"launch_if_none": True})["result"]
session = sessions[0]["session_id"]
while sessions[0].get("modal"):
client.tool(
"ocs_execute",
{
"ocs_session_id": session,
"request": {
"op": "action",
"request_id": request_id("close-modal"),
"name": "close_modal",
},
},
)
sessions = client.tool("ocs_sessions", {"launch_if_none": False})["result"]
client.tool(
"ocs_execute",
{
"ocs_session_id": session,
"request": {"op": "new", "request_id": request_id("new")},
},
)
drawing = client.tool(
"ocs_execute",
{
"ocs_session_id": session,
"response_detail": "changed_entities",
"request": {
"op": "batch",
"request_id": request_id("draw"),
"steps": [
{"op": "run", "cmd": "PLINE 0,0 200,0 200,120 0,120 C"},
{"op": "run", "cmd": "CIRCLE 50,60 15"},
{"op": "run", "cmd": "CIRCLE 150,60 15"},
{"op": "run", "cmd": "LINE 0,60 200,60"},
{"op": "run", "cmd": "TEXT 100,135 8 0 OPEN CAD MCP ACCEPTANCE"},
{"op": "action", "name": "zoom_extents"},
],
},
},
)
assert drawing["completed_steps"] == 6, drawing
audits = {}
saves = {}
targets = [("dwg", "2000"), ("dwg", "2013"), ("dwg", "2018"), ("dxf", "2000")]
for target_format, target_version in targets:
key = f"{target_format}-{target_version}"
audit = client.tool(
"ocs_read",
{
"ocs_session_id": session,
"op": "audit",
"parameters": {
"target_format": target_format,
"target_version": target_version,
},
},
)
assert audit["ok"] is True, audit
audits[key] = audit
destination = output / f"mcp-acceptance-{target_version}.{target_format}"
saved = client.tool(
"ocs_execute",
{
"ocs_session_id": session,
"response_detail": "full",
"request": {
"op": "save_verified",
"request_id": request_id(f"save-{key}"),
"path": str(destination),
"target_format": target_format,
"target_version": target_version,
},
},
)
verified = saved.get("result", saved)
assert verified["verified"] is True, saved
assert destination.is_file(), destination
saves[key] = verified
capture = client.rpc(
"tools/call",
{
"name": "ocs_capture",
"arguments": {
"ocs_session_id": session,
"scope": "viewport",
"max_dimension": 1600,
},
"_meta": META,
},
)
image = capture["content"][0]
assert image["type"] == "image" and image["mimeType"] == "image/png", capture
png = base64.b64decode(image["data"], validate=True)
assert png[:8] == b"\x89PNG\r\n\x1a\n", "capture is not a PNG"
width, height = struct.unpack(">II", png[16:24])
capture_path = output / "viewport.png"
capture_path.write_bytes(png)
report.update(
{
"ok": True,
"session_id": session,
"drawn_entities": drawing["changed_entities"],
"audits": audits,
"saves": saves,
"capture": {"path": str(capture_path), "width": width, "height": height},
"metrics": {
"tool_calls": client.tool_calls,
"tasks": client.tasks,
"rpc_calls": client.rpc_calls,
},
}
)
finally:
client.close()
report_path = output / "report.json"
report_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
+15 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import subprocess
import sys
import time
import uuid
MODERN = "2026-07-28"
@@ -91,6 +92,19 @@ def main() -> None:
assert "io.modelcontextprotocol/tasks" in discovery["capabilities"]["extensions"]
sessions = client.tool("ocs_sessions", {"launch_if_none": True})["result"]
session = sessions[0]["session_id"]
while sessions[0].get("modal"):
client.tool(
"ocs_execute",
{
"ocs_session_id": session,
"request": {
"op": "action",
"request_id": f"eval-close-modal-{uuid.uuid4().hex}",
"name": "close_modal",
},
},
)
sessions = client.tool("ocs_sessions", {"launch_if_none": False})["result"]
active = next(
document for document in sessions[0]["documents"]
if document["id"] == sessions[0]["document_id"]
@@ -98,7 +112,7 @@ def main() -> None:
if active.get("start"):
client.tool(
"ocs_execute",
{"ocs_session_id": session, "request": {"op": "new", "request_id": "eval-new"}},
{"ocs_session_id": session, "request": {"op": "new", "request_id": f"eval-new-{uuid.uuid4().hex}"}},
)
base = 1_000_000 + int(time.time()) % 100_000
+5 -2
View File
@@ -52,7 +52,7 @@ def legacy(server: Path) -> None:
names = set(definitions)
assert names == TOOLS, names
execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"]
assert len(execute_request["oneOf"]) == 35
assert len(execute_request["oneOf"]) == 38
op_enum = execute_request["properties"]["op"]["enum"]
for shipped in ("entities_create", "entities_delete", "entities_transform",
"block_define", "block_delete", "file_identity", "xdata_set",
@@ -65,9 +65,12 @@ def legacy(server: Path) -> None:
assert execute_request["properties"]["steps"]["maxItems"] == 64
assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10"
assert "set_properties" in execute_request["properties"]["op"]["enum"]
assert "save_verified" in execute_request["properties"]["op"]["enum"]
assert "record_schema" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
assert "audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"]
read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"]
assert {"collection", "where", "paths"} <= set(read_parameters)
assert {"collection", "where", "paths", "target_format", "target_version"} <= set(read_parameters)
assert execute_request["properties"]["target_version"]["enum"][0] == "R14"
assert execute_request["properties"]["kind"]["enum"] == [
"text", "token", "point", "entity", "structure", "selection", "enter"
]
+17 -4
View File
@@ -33,8 +33,8 @@ Two request styles share the channel, told apart by the `protocol` field:
| Style | Shape | Operations |
|---|---|---|
| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` |
| legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` |
| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `audit`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` |
| legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `audit`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` |
The two styles differ in how a waiting command is treated. A protocol-1 `run`
or `start` while a command is still active is refused with `command_busy`:
@@ -104,8 +104,19 @@ README describes.
`OpenCADStudio --export IN OUT` loads `IN` and writes `OUT` without a window or
a session. The output format follows the extension: `.dxf` writes DXF, any other
extension writes DWG at the document's own version. The exit code is 0 on
success and 1 when the input cannot be read or the output cannot be written.
extension writes DWG at the document's own version. Add `--target-version R14`
(or `2000`, `2004`, `2007`, `2010`, `2013`, `2018`) to choose explicitly. The
exit code is 0 on success, 1 when the input/output fails, and 2 for an invalid
target version.
The verified interoperability lane uses version 2000 for a DWG -> DXF
round-trip. R14 DWG files can be written and reopened, but the current R14
DWG -> DXF path can emit object dictionaries that are orphaned from the root,
which leaves layout and placeholder ownership invalid. `audit` and
`save_verified` reject that ASCII DXF instead of reporting a false green. Do
not deliver that conversion unless `save_verified` passes. Raw
group-code handle validation is not applied to binary DXF and is reported as
skipped in the audit result.
## Interactive steps
@@ -129,3 +140,5 @@ For commands whose answers cannot be written on one line, use `start` and then
| measure | `{"protocol":1,"op":"measure","handles":["2A"]}` — kernel-computed length, area, bounds and mass properties; no window needed | `ocs_read` with `op: "measure"` and `parameters.handles` |
| capture | `{"protocol":1,"op":"capture","path":"out.png"}` — needs the editor window, so it answers `gui_required` here | `ocs_capture` with `scope` (`viewport` or `window`) and `max_dimension` |
| `set_properties` | requires `collection`; colours are the serialised enum — `{"Index": 1}` or `"ByLayer"`; a colour name such as `"Red"` is rejected with `invalid_value` | same request through `ocs_execute` |
| audit | `{"protocol":1,"op":"audit","target_format":"dwg","target_version":"2000"}` | `ocs_read` with `op: "audit"` and the same target parameters |
| save verified | protocol-1 `save_verified` with absolute `path`, unique `request_id`, and target fields | `ocs_execute` with `op: "save_verified"`; returns hash, reopened version and semantic manifest |
+67
View File
@@ -0,0 +1,67 @@
param(
[Parameter(Mandatory = $true)]
[string]$Directory,
[Parameter(Mandatory = $true)]
[string]$ConsolePath,
[int]$TimeoutSeconds = 25
)
$ErrorActionPreference = 'Stop'
$drawingDirectory = (Resolve-Path -LiteralPath $Directory).Path
$scriptPath = Join-Path $PSScriptRoot 'reference_audit.scr'
$targets = @(
@{ Version = '2000'; File = 'mcp-acceptance-2000.dwg' },
@{ Version = '2013'; File = 'mcp-acceptance-2013.dwg' },
@{ Version = '2018'; File = 'mcp-acceptance-2018.dwg' }
)
$results = foreach ($target in $targets) {
$drawing = Join-Path $drawingDirectory $target.File
if (-not (Test-Path -LiteralPath $drawing -PathType Leaf)) {
throw "Missing acceptance drawing: $drawing"
}
$isolation = Join-Path $env:TEMP "ocs-reference-acceptance-$($target.Version)"
New-Item -ItemType Directory -Force -Path $isolation | Out-Null
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $ConsolePath
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
$start.StandardOutputEncoding = [Text.Encoding]::Unicode
$start.StandardErrorEncoding = [Text.Encoding]::Unicode
@(
'/i', $drawing,
'/s', $scriptPath,
'/l', 'en-US',
'/isolate', "ocsaccept$($target.Version)", $isolation,
'/readonly'
) | ForEach-Object { [void]$start.ArgumentList.Add($_) }
$process = [Diagnostics.Process]::new()
$process.StartInfo = $start
[void]$process.Start()
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
$forcedTermination = -not $process.WaitForExit($TimeoutSeconds * 1000)
if ($forcedTermination) {
$process.Kill($true)
$process.WaitForExit()
}
$text = $stdout.Result + $stderr.Result
$log = Join-Path $drawingDirectory "reference-audit-$($target.Version).log"
[IO.File]::WriteAllText($log, $text, [Text.UTF8Encoding]::new($false))
$passed = [regex]::IsMatch($text, 'Total errors found\s+0\s+fixed\s+0')
[pscustomobject]@{
Version = $target.Version
Passed = $passed
ForcedTermination = $forcedTermination
ExitCode = $process.ExitCode
Log = $log
}
}
$results | Format-Table -AutoSize
if ($results.Passed -contains $false) {
exit 1
}
+3
View File
@@ -0,0 +1,3 @@
_.AUDIT
_N
_.QUIT
+662 -5
View File
@@ -16,9 +16,12 @@
#[cfg(not(target_arch = "wasm32"))]
use std::io::{BufRead, Write};
use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
use serde_json::{json, Value};
#[cfg(not(target_arch = "wasm32"))]
use sha2::{Digest, Sha256};
use super::OpenCADStudio;
@@ -38,7 +41,11 @@ pub fn serve() {
/// writes `output` (format chosen from `output`'s extension), and returns a
/// process exit code (0 on success). No window is created.
#[cfg(not(target_arch = "wasm32"))]
pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32 {
pub fn export_headless(
input: &std::path::Path,
output: &std::path::Path,
target_version: Option<&str>,
) -> i32 {
let doc = match crate::io::load_file(input) {
Ok(doc) => doc,
Err(e) => {
@@ -46,7 +53,17 @@ pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32
return 1;
}
};
match crate::io::save(&doc, output) {
let version = match target_version {
Some(value) => match crate::io::parse_target_version(value) {
Ok(version) => version,
Err(e) => {
eprintln!("export: {e}");
return 2;
}
},
None => doc.version,
};
match crate::io::save_as_version(&doc, output, version) {
Ok(()) => {
println!("Exported {} → {}", input.display(), output.display());
0
@@ -311,6 +328,202 @@ fn projected_fields(mut entity: Value, fields: Option<&Vec<Value>>) -> Value {
entity
}
pub(super) fn requested_save_target(
req: &Value,
default_version: acadrust::DxfVersion,
default_is_dxf: bool,
path: Option<&std::path::Path>,
) -> Result<(acadrust::DxfVersion, bool), String> {
let version = match req["target_version"].as_str() {
Some(value) => crate::io::parse_target_version(value)?,
None => default_version,
};
let path_format = path
.and_then(std::path::Path::extension)
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
.filter(|value| value == "dxf" || value == "dwg");
let requested_format = match req["target_format"].as_str() {
Some(value) if value.eq_ignore_ascii_case("dxf") => Some("dxf".to_string()),
Some(value) if value.eq_ignore_ascii_case("dwg") => Some("dwg".to_string()),
Some(value) => return Err(format!("unsupported target format {value:?}; use dwg or dxf")),
None => None,
};
if let (Some(requested), Some(extension)) = (&requested_format, &path_format) {
if requested != extension {
return Err(format!(
"target_format {requested:?} conflicts with output extension .{extension}"
));
}
}
let is_dxf = requested_format
.or(path_format)
.map_or(default_is_dxf, |value| value == "dxf");
Ok((version, is_dxf))
}
fn document_manifest(document: &acadrust::CadDocument) -> Value {
let mut by_type: BTreeMap<String, u64> = BTreeMap::new();
let mut by_layer: BTreeMap<String, u64> = BTreeMap::new();
let mut total = 0u64;
for entity in document.entities() {
*by_type
.entry(crate::entities::names::ui_name(entity).to_string())
.or_default() += 1;
*by_layer.entry(entity.common().layer.clone()).or_default() += 1;
total += 1;
}
json!({"total":total,"by_type":by_type,"by_layer":by_layer})
}
#[cfg(not(target_arch = "wasm32"))]
fn sha256_file(path: &std::path::Path) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
let digest = Sha256::digest(bytes);
Ok(format!("{digest:x}"))
}
#[cfg(not(target_arch = "wasm32"))]
fn audit_ascii_dxf_references(path: &std::path::Path) -> Result<Value, String> {
let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
if bytes.starts_with(b"AutoCAD Binary DXF") {
return Ok(json!({
"ok":true,
"skipped":"binary_dxf",
"reason":"raw group-code handle audit applies only to ASCII DXF"
}));
}
let text = String::from_utf8_lossy(&bytes).replace("\r\n", "\n");
let lines: Vec<&str> = text.lines().collect();
if lines.len() % 2 != 0 {
return Ok(json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]}));
}
let mut declared = HashSet::from(["0".to_string()]);
let mut duplicate = std::collections::BTreeSet::new();
let mut references = Vec::new();
let mut pairs = Vec::new();
let mut version = None;
let mut variable = None;
for pair in lines.chunks_exact(2) {
let Ok(code) = pair[0].trim().parse::<i32>() else {
return Ok(json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]}));
};
let value = pair[1].trim().to_ascii_uppercase();
pairs.push((code, value.clone()));
if code == 9 {
variable = Some(value.clone());
} else if code == 1 && variable.as_deref() == Some("$ACADVER") {
version = Some(value.clone());
variable = None;
}
if code == 5 || code == 105 {
if !declared.insert(value.clone()) {
duplicate.insert(value.clone());
}
} else if [330, 340, 350, 360, 390].contains(&code) {
references.push(value);
}
}
let dangling: std::collections::BTreeSet<String> = references
.into_iter()
.filter(|handle| !declared.contains(handle))
.collect();
let mut in_objects = false;
let mut pending_section = false;
let mut records: Vec<(String, Vec<(i32, String)>)> = Vec::new();
let mut current: Option<(String, Vec<(i32, String)>)> = None;
for (code, value) in &pairs {
if *code == 0 && value == "SECTION" {
pending_section = true;
continue;
}
if pending_section && *code == 2 {
in_objects = value == "OBJECTS";
pending_section = false;
continue;
}
if *code == 0 && value == "ENDSEC" {
if let Some(record) = current.take() {
records.push(record);
}
in_objects = false;
continue;
}
if !in_objects {
continue;
}
if *code == 0 {
if let Some(record) = current.take() {
records.push(record);
}
current = Some((value.clone(), Vec::new()));
} else if let Some((_, fields)) = &mut current {
fields.push((*code, value.clone()));
}
}
let root = records.iter().find_map(|(kind, fields)| {
let handle = fields
.iter()
.find(|(code, _)| *code == 5 || *code == 105)?
.1
.clone();
let owner = fields
.iter()
.rev()
.find(|(code, _)| *code == 330)?
.1
.as_str();
(kind == "DICTIONARY" && owner == "0").then_some(handle)
});
let mut orphaned_objects = std::collections::BTreeSet::new();
if let Some(root_handle) = root {
let root_targets: HashSet<String> = records
.iter()
.find(|(_, fields)| {
fields
.iter()
.any(|(code, value)| (*code == 5 || *code == 105) && value == &root_handle)
})
.map(|(_, fields)| {
fields
.iter()
.filter(|(code, _)| *code == 350 || *code == 360)
.map(|(_, value)| value.clone())
.collect()
})
.unwrap_or_default();
for (_, fields) in &records {
let Some(handle) = fields
.iter()
.find(|(code, _)| *code == 5 || *code == 105)
.map(|(_, value)| value)
else {
continue;
};
let owner = fields
.iter()
.rev()
.find(|(code, _)| *code == 330)
.map(|(_, value)| value.as_str());
if owner == Some(root_handle.as_str())
&& handle != &root_handle
&& !root_targets.contains(handle)
{
orphaned_objects.insert(handle.clone());
}
}
}
Ok(json!({
"ok":duplicate.is_empty() && dangling.is_empty() && orphaned_objects.is_empty(),
"malformed_pairs":false,
"declared_handles":declared.len() - 1,
"duplicate_handles":duplicate,
"dangling_handles":dangling,
"orphaned_objects":orphaned_objects,
"declared_version":version,
}))
}
impl OpenCADStudio {
/// Handle one JSON request line and return the JSON response.
#[cfg(any(test, not(target_arch = "wasm32")))]
@@ -464,6 +677,7 @@ impl OpenCADStudio {
})
}
"entities" => self.entity_summary(),
"audit" => self.document_audit(&req),
"query" => self.entity_query(&req),
"records" => self.record_query(&req),
"record_schema" => self.record_schema(&req),
@@ -583,7 +797,7 @@ impl OpenCADStudio {
".{}.tmp.dwg",
path.file_name().unwrap().to_string_lossy()
));
let document = self.tabs[i].scene.document.clone();
let document = self.tabs[i].scene.document_for_save();
if let Err(e) = crate::io::save(&document, &scratch) {
return err(format!("save: {e}"));
}
@@ -592,14 +806,48 @@ impl OpenCADStudio {
}
return json!({ "ok": true, "saved": path.to_string_lossy() });
}
let default_is_dxf = crate::io::source_is_dxf(
self.tabs[i].current_path.as_deref(),
&self.tabs[i].scene.document,
);
let (version, is_dxf) = match requested_save_target(
&req,
self.tabs[i].scene.document.version,
default_is_dxf,
Some(&path),
) {
Ok(target) => target,
Err(error) => return err(format!("save: {error}")),
};
let dropped = crate::io::dropped_on_save_count(
&self.tabs[i].scene.document,
version,
is_dxf,
);
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
return err(format!(
"save: conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"
));
}
#[cfg(not(target_arch = "wasm32"))]
let result = self.save_tab_synchronously_protected(i, path.clone(), true);
let result = self.save_tab_synchronously_protected_as(
i,
path.clone(),
version,
true,
);
#[cfg(target_arch = "wasm32")]
let result = crate::io::save(&self.tabs[i].scene.document, &path)
.map_err(crate::io::SaveFailure::other);
match result {
Ok(()) => {
json!({ "ok": true, "saved": path.to_string_lossy() })
json!({
"ok": true,
"saved": path.to_string_lossy(),
"target_format": if is_dxf { "dxf" } else { "dwg" },
"target_version": format!("{version:?}"),
"dropped_on_save": dropped,
})
}
Err(e) => err(format!("save: {e}")),
}
@@ -888,6 +1136,276 @@ impl OpenCADStudio {
})
}
/// Pre-delivery check of the active document against an intended output
/// format and version: dangling layers/blocks, duplicate handles,
/// non-finite bounds, lossy records and the source DXF handle graph.
fn document_audit(&self, req: &Value) -> Value {
let i = self.active_tab;
let tab = &self.tabs[i];
let document = &tab.scene.document;
let source_is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), document);
let path = req["path"]
.as_str()
.map(std::path::Path::new)
.or(tab.current_path.as_deref());
let (target_version, target_is_dxf) = match requested_save_target(
req,
document.version,
source_is_dxf,
path,
) {
Ok(target) => target,
Err(error) => return err(format!("audit: {error}")),
};
let declared_layers: HashSet<String> = document
.layers
.iter()
.map(|layer| layer.name.to_ascii_uppercase())
.collect();
let block_names: HashSet<String> = document
.block_records
.iter()
.map(|block| block.name.to_ascii_uppercase())
.collect();
let mut missing_layers = std::collections::BTreeSet::new();
let mut missing_blocks = std::collections::BTreeSet::new();
let mut duplicate_handles = std::collections::BTreeSet::new();
let mut seen_handles = HashSet::new();
let mut serialization_errors = Vec::new();
let mut non_finite_bounds = Vec::new();
let mut unknown_entities = 0usize;
let mut bounds: Option<([f64; 3], [f64; 3])> = None;
for entity in document.entities() {
let common = entity.common();
if !declared_layers.contains(&common.layer.to_ascii_uppercase()) {
missing_layers.insert(common.layer.clone());
}
if !seen_handles.insert(common.handle.value()) {
duplicate_handles.insert(format!("{:X}", common.handle.value()));
}
if let acadrust::EntityType::Insert(insert) = entity {
if !block_names.contains(&insert.block_name.to_ascii_uppercase()) {
missing_blocks.insert(insert.block_name.clone());
}
}
if matches!(entity, acadrust::EntityType::Unknown(_)) {
unknown_entities += 1;
}
if let Err(error) = serde_json::to_value(entity) {
serialization_errors.push(format!("{:X}: {error}", common.handle.value()));
}
let (min, max) = crate::scene::convert::tess::entity_bounds(entity);
if min.into_iter().chain(max).any(|value| !value.is_finite()) {
non_finite_bounds.push(format!("{:X}", common.handle.value()));
} else {
bounds = Some(match bounds {
None => (min, max),
Some((mut all_min, mut all_max)) => {
for axis in 0..3 {
all_min[axis] = all_min[axis].min(min[axis]);
all_max[axis] = all_max[axis].max(max[axis]);
}
(all_min, all_max)
}
});
}
}
for (handle, object) in &document.objects {
if let Err(error) = serde_json::to_value(object) {
serialization_errors.push(format!("object {:X}: {error}", handle.value()));
}
}
let dropped = crate::io::dropped_on_save_count(
document,
target_version,
target_is_dxf,
);
let mut issues = Vec::new();
if !missing_layers.is_empty() {
issues.push(json!({"severity":"error","code":"undeclared_layer","values":missing_layers}));
}
if !missing_blocks.is_empty() {
issues.push(json!({"severity":"error","code":"missing_block","values":missing_blocks}));
}
if !duplicate_handles.is_empty() {
issues.push(json!({"severity":"error","code":"duplicate_entity_handle","values":duplicate_handles}));
}
if !serialization_errors.is_empty() {
issues.push(json!({"severity":"error","code":"non_serializable_record","values":serialization_errors}));
}
if !non_finite_bounds.is_empty() {
issues.push(json!({"severity":"error","code":"non_finite_bounds","handles":non_finite_bounds}));
}
if dropped > 0 {
issues.push(json!({
"severity":"warning",
"code":"lossy_conversion",
"message":format!("{dropped} unsupported record(s) would be dropped"),
}));
}
#[cfg(not(target_arch = "wasm32"))]
let source_dxf_structure = if source_is_dxf {
tab.current_path
.as_deref()
.filter(|path| path.is_file())
.and_then(|path| audit_ascii_dxf_references(path).ok())
} else {
None
};
#[cfg(target_arch = "wasm32")]
let source_dxf_structure: Option<Value> = None;
if source_dxf_structure
.as_ref()
.is_some_and(|audit| audit["ok"] != true)
{
issues.push(json!({
"severity":"error","code":"invalid_dxf_handle_graph",
"details":source_dxf_structure.clone(),
}));
}
let errors = issues.iter().filter(|issue| issue["severity"] == "error").count();
let warnings = issues.iter().filter(|issue| issue["severity"] == "warning").count();
#[cfg(not(target_arch = "wasm32"))]
let source_sha256 = tab.current_path.as_deref().filter(|path| path.is_file())
.and_then(|path| sha256_file(path).ok());
#[cfg(target_arch = "wasm32")]
let source_sha256: Option<String> = None;
json!({
"ok": errors == 0,
"status": if errors > 0 { "failed" } else if warnings > 0 { "warning" } else { "passed" },
"document_id": tab.id,
"source": {
"path": tab.current_path,
"format": if source_is_dxf { "dxf" } else { "dwg" },
"version": format!("{:?}", document.version),
"sha256": source_sha256,
"dxf_structure": source_dxf_structure,
},
"target": {
"format": if target_is_dxf { "dxf" } else { "dwg" },
"version": format!("{target_version:?}"),
"dropped_on_save": dropped,
"lossless": dropped == 0,
},
"manifest": document_manifest(document),
"bounds": bounds.map(|(min,max)| json!({"min":min,"max":max})),
"unknown_entities": unknown_entities,
"issues": issues,
"summary": {"errors":errors,"warnings":warnings},
})
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn save_verified_request(&mut self, req: &Value) -> Result<Value, Value> {
let i = self.active_tab;
let Some(raw_path) = req["path"].as_str() else {
return Err(json!({"ok":false,"status":"failed","code":"path_required","error":"save_verified requires an explicit absolute path"}));
};
let path = std::path::PathBuf::from(raw_path);
if !path.is_absolute() {
return Err(json!({"ok":false,"status":"failed","code":"absolute_path_required","error":"save_verified path must be absolute"}));
}
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase);
if !matches!(extension.as_deref(), Some("dwg" | "dxf")) {
return Err(json!({"ok":false,"status":"failed","code":"unsupported_format","error":"save_verified path must end in .dwg or .dxf"}));
}
if path.exists() && req["overwrite"].as_bool() != Some(true) {
return Err(json!({"ok":false,"status":"failed","code":"destination_exists","error":"destination exists; set overwrite=true to replace it"}));
}
let document = &self.tabs[i].scene.document;
let source_is_dxf = crate::io::source_is_dxf(self.tabs[i].current_path.as_deref(), document);
let (version, is_dxf) = requested_save_target(
req,
document.version,
source_is_dxf,
Some(&path),
)
.map_err(|error| json!({"ok":false,"status":"failed","code":"invalid_target","error":error}))?;
let audit = self.document_audit(req);
if audit["summary"]["errors"].as_u64().unwrap_or(0) > 0 {
return Err(json!({
"ok":false,"status":"failed","code":"audit_failed",
"error":"pre-save structural audit failed","audit":audit,
}));
}
let dropped = crate::io::dropped_on_save_count(document, version, is_dxf);
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
return Err(json!({
"ok":false,"status":"failed","code":"lossy_conversion_not_acknowledged",
"error":format!("conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"),
"dropped_on_save":dropped,
}));
}
self.prepare_native_save(i);
// The same snapshot a normal save writes: display-only overrides and
// resolved xref content stay out of the file.
let snapshot = self.tabs[i].scene.document_for_save();
let before = document_manifest(&snapshot);
crate::io::save_as_version(&snapshot, &path, version)
.map_err(|error| json!({
"ok":false,"status":"failed","code":"save_failed","error":error,
}))?;
let sha256 = sha256_file(&path).map_err(|error| json!({
"ok":false,"status":"failed","code":"hash_failed","error":error,
"saved":path,
}))?;
let dxf_structure = if is_dxf {
Some(audit_ascii_dxf_references(&path).map_err(|error| json!({
"ok":false,"status":"failed","code":"dxf_audit_failed","error":error,
"saved":path,"sha256":sha256,
}))?)
} else {
None
};
if dxf_structure
.as_ref()
.is_some_and(|audit| audit["ok"] != true)
{
return Err(json!({
"ok":false,"status":"failed","code":"invalid_dxf_handle_graph",
"error":"saved DXF contains duplicate or dangling handle references; file was preserved for diagnosis",
"saved":path,"sha256":sha256,"dxf_structure":dxf_structure,
"target_version":format!("{version:?}"),
}));
}
let reopened = crate::io::load_file(&path).map_err(|error| json!({
"ok":false,"status":"failed","code":"reopen_failed","error":error,
"saved":path,"sha256":sha256,
}))?;
if reopened.version != version {
return Err(json!({
"ok":false,"status":"failed","code":"version_mismatch",
"error":"saved drawing reopened with a different CAD version",
"saved":path,"sha256":sha256,"requested":format!("{version:?}"),
"actual":format!("{:?}",reopened.version),"dxf_structure":dxf_structure,
}));
}
let after = document_manifest(&reopened);
if before != after {
return Err(json!({
"ok":false,"status":"failed","code":"semantic_mismatch",
"error":"saved drawing reopened but its entity manifest changed; file was preserved for diagnosis",
"saved":path,"sha256":sha256,"before":before,"after":after,
"target_format":if is_dxf { "dxf" } else { "dwg" },
"target_version":format!("{version:?}"),"dropped_on_save":dropped,
}));
}
Ok(json!({
"ok":true,"status":"completed","verified":true,
"saved":path,"sha256":sha256,"bytes":std::fs::metadata(&path).map(|m|m.len()).unwrap_or(0),
"target_format":if is_dxf { "dxf" } else { "dwg" },
"target_version":format!("{version:?}"),"dropped_on_save":dropped,
"manifest":after,"audit":audit,"dxf_structure":dxf_structure,
}))
}
/// Count of entities in the active document, total and by type.
fn entity_summary(&self) -> Value {
let i = self.active_tab;
@@ -948,6 +1466,118 @@ mod tests {
use crate::app::OpenCADStudio;
use serde_json::{json, Value};
#[test]
fn audit_rejects_an_undeclared_entity_layer() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#)["ok"],
true
);
let i = app.active_tab;
app.tabs[i]
.scene
.document
.entities_mut()
.next()
.expect("line")
.common_mut()
.layer = "NOT_DECLARED".into();
let audit = app.automation_op(r#"{"op":"audit","target_format":"dwg","target_version":"R14"}"#);
assert_eq!(audit["ok"], false, "{audit}");
assert_eq!(audit["issues"][0]["code"], "undeclared_layer", "{audit}");
}
#[test]
fn save_verified_writes_reopens_hashes_and_matches_manifest() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#)["ok"],
true
);
let path = std::env::temp_dir().join(format!(
"ocs_verified_save_{}_{}.dwg",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let _ = std::fs::remove_file(&path);
let result = app
.save_verified_request(&serde_json::json!({
"path":path,
"target_format":"dwg",
"target_version":"R14",
"overwrite":true,
}))
.expect("verified save");
assert_eq!(result["verified"], true, "{result}");
assert_eq!(result["target_version"], "AC1014", "{result}");
assert_eq!(result["manifest"]["total"], 1, "{result}");
assert_eq!(result["sha256"].as_str().map(str::len), Some(64));
assert!(path.is_file());
let _ = std::fs::remove_file(path);
}
#[test]
fn explicit_target_version_parser_never_silently_defaults() {
assert_eq!(
crate::io::parse_target_version("R14").unwrap(),
acadrust::DxfVersion::AC1014
);
assert_eq!(
crate::io::parse_target_version("AC1015").unwrap(),
acadrust::DxfVersion::AC1015
);
assert!(crate::io::parse_target_version("R12").is_err());
assert!(crate::io::parse_target_version("future").is_err());
}
#[test]
fn raw_dxf_audit_detects_dangling_handle_references() {
let path = std::env::temp_dir().join(format!(
"ocs_dangling_handle_{}.dxf",
std::process::id()
));
std::fs::write(&path, " 0\nLINE\n 5\n1\n330\n2\n").unwrap();
let audit = super::audit_ascii_dxf_references(&path).unwrap();
assert_eq!(audit["ok"], false, "{audit}");
assert_eq!(audit["dangling_handles"], serde_json::json!(["2"]));
let _ = std::fs::remove_file(path);
}
#[test]
fn raw_dxf_audit_marks_binary_input_as_skipped() {
let path = std::env::temp_dir().join(format!(
"ocs_binary_dxf_{}.dxf",
std::process::id()
));
std::fs::write(&path, b"AutoCAD Binary DXF\r\n\x1a\0").unwrap();
let audit = super::audit_ascii_dxf_references(&path).unwrap();
assert_eq!(audit["ok"], true, "{audit}");
assert_eq!(audit["skipped"], "binary_dxf", "{audit}");
let _ = std::fs::remove_file(path);
}
#[test]
fn raw_dxf_audit_detects_objects_orphaned_from_the_root_dictionary() {
let path = std::env::temp_dir().join(format!(
"ocs_orphaned_object_{}.dxf",
std::process::id()
));
std::fs::write(
&path,
"0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n0\nDICTIONARY\n5\n23\n330\nC\n0\nENDSEC\n0\nEOF\n",
)
.unwrap();
let audit = super::audit_ascii_dxf_references(&path).unwrap();
assert_eq!(audit["ok"], false, "{audit}");
assert_eq!(audit["orphaned_objects"], serde_json::json!(["23"]));
let _ = std::fs::remove_file(path);
}
#[test]
fn layout_notice_skips_grid_camera_and_scene_builds() {
let mut app = OpenCADStudio::new_for_test();
@@ -1018,6 +1648,33 @@ mod tests {
assert_eq!(assert_one_text(&app), "hello world");
}
#[test]
fn zoom_extents_includes_sdf_text_outside_other_geometry() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let line = app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 200,0"}"#);
assert_eq!(line["added"], 1, "{line}");
let text = app.automation_op(
r#"{"op":"run","cmd":"TEXT 100,135 8 0 OPEN CAD MCP ACCEPTANCE"}"#,
);
assert_eq!(text["added"], 1, "{text}");
app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 800.0);
app.tabs[i].scene.fit_all();
let (_, max) = app.tabs[i]
.scene
.camera
.borrow()
.fitted_model_bounds()
.expect("ZOOM EXTENTS did not fit the drawing");
assert!(
max.y >= 142.0,
"text at y=135..143 was clipped from fitted bounds: {max:?}"
);
}
#[test]
fn a_stray_editor_is_not_hijacked_by_the_next_line() {
let mut app = OpenCADStudio::new_for_test();
+38 -3
View File
@@ -444,7 +444,7 @@ impl OpenCADStudio {
"mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})),
"text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial,
"operation":self.control.pending.as_ref().map(|p| &p.id),
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","user_select","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","user_select","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query","audit","verified_save","explicit_save_version"]
})
}
@@ -469,6 +469,7 @@ impl OpenCADStudio {
| "history"
| "xdata_get"
| "get_selection"
| "audit"
);
if !query && !self.control.enabled {
return (
@@ -974,8 +975,36 @@ impl OpenCADStudio {
.map(std::path::PathBuf::from)
.or_else(|| self.tabs[i].current_path.clone())
.ok_or_else(|| failure("path_required", "Supply a save path"))?;
let default_is_dxf = crate::io::source_is_dxf(
self.tabs[i].current_path.as_deref(),
&self.tabs[i].scene.document,
);
let (version, is_dxf) = super::automation::requested_save_target(
req,
self.tabs[i].scene.document.version,
default_is_dxf,
Some(&path),
)
.map_err(|error| failure("invalid_target", error))?;
let dropped = crate::io::dropped_on_save_count(
&self.tabs[i].scene.document,
version,
is_dxf,
);
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
return Err(failure(
"lossy_conversion_not_acknowledged",
format!(
"Conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"
),
));
}
self.set_control_result(json!({
"saved":path,"target_format":if is_dxf { "dxf" } else { "dwg" },
"target_version":format!("{version:?}"),"dropped_on_save":dropped,
}));
if self.main_window.is_none() {
self.save_tab_synchronously_protected(i, path, true)
self.save_tab_synchronously_protected_as(i, path, version, true)
.map_err(|e| failure("save_failed", e))?;
Task::none()
} else {
@@ -983,7 +1012,7 @@ impl OpenCADStudio {
self.queue_native_save(
i,
path,
acadrust::DxfVersion::AC1032,
version,
super::SavePurpose::SaveAs,
super::SaveContinuation::None,
true,
@@ -991,6 +1020,12 @@ impl OpenCADStudio {
)
}
}
#[cfg(not(target_arch = "wasm32"))]
"save_verified" => {
let result = self.save_verified_request(req)?;
self.set_control_result(result);
Task::none()
}
"capture" => {
let window = self
.main_window
+16 -1
View File
@@ -1446,6 +1446,21 @@ impl OpenCADStudio {
i: usize,
path: std::path::PathBuf,
set_current_path: bool,
) -> Result<(), crate::io::SaveFailure> {
let version = self.tabs[i].scene.document.version;
self.save_tab_synchronously_protected_as(i, path, version, set_current_path)
}
/// Synchronous protected save with an explicit output version. Automation
/// uses this path so format conversion never depends on an implicit source
/// version or silently upgrades to the newest DWG.
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn save_tab_synchronously_protected_as(
&mut self,
i: usize,
path: std::path::PathBuf,
version: acadrust::DxfVersion,
set_current_path: bool,
) -> Result<(), crate::io::SaveFailure> {
let previous_autosave = self.autosave_target(i);
if self.tabs[i].recovery_save_as_required
@@ -1499,7 +1514,6 @@ impl OpenCADStudio {
Self::native_save_verification(&path, lease, expected_fingerprint)?;
self.prepare_native_save(i);
let version = self.tabs[i].scene.document.version;
let snapshot = self.tabs[i].scene.document_for_save();
crate::io::save_owned_as_version_atomic(
snapshot,
@@ -1512,6 +1526,7 @@ impl OpenCADStudio {
if set_current_path {
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
}
self.refresh_native_edit_guard_after_save(i, &path, path_changed, destination_lease);
self.tabs[i].dirty = false;
+5
View File
@@ -95,6 +95,11 @@ pub struct Cli {
#[arg(help = crate::t!("Convert IN to OUT using the output file extension, then exit.").into_owned(), long_help = None)]
pub export: Option<Vec<PathBuf>>,
/// Explicit output CAD version for --export (R14, 2000, 2004, 2007,
/// 2010, 2013 or 2018). Without it the source document version is kept.
#[arg(long, value_name = "VERSION", requires = "export")]
pub target_version: Option<String>,
/// Run a command script at startup: one command line per line of FILE.
#[arg(long, value_name = "FILE")]
#[arg(help = crate::t!("Run a command script at startup, one command per line.").into_owned(), long_help = None)]
+19
View File
@@ -1360,6 +1360,25 @@ pub fn parse_save_format(format: &str) -> (&'static str, acadrust::DxfVersion) {
(ext, version)
}
/// Parse an explicit automation/CLI target version without silently falling
/// back to a newer format. Accepts either release names (`R14`, `2000`, …)
/// or their DXF codes (`AC1014`, `AC1015`, …).
pub fn parse_target_version(value: &str) -> Result<acadrust::DxfVersion, String> {
use acadrust::DxfVersion;
match value.trim().to_ascii_uppercase().as_str() {
"R14" | "14" | "AC1014" => Ok(DxfVersion::AC1014),
"2000" | "AC1015" => Ok(DxfVersion::AC1015),
"2004" | "AC1018" => Ok(DxfVersion::AC1018),
"2007" | "AC1021" => Ok(DxfVersion::AC1021),
"2010" | "AC1024" => Ok(DxfVersion::AC1024),
"2013" | "AC1027" => Ok(DxfVersion::AC1027),
"2018" | "AC1032" => Ok(DxfVersion::AC1032),
_ => Err(format!(
"unsupported target version {value:?}; use R14, 2000, 2004, 2007, 2010, 2013 or 2018"
)),
}
}
/// Reverse of [`parse_save_format`]: the Save-dialog format string for a
/// version + DXF/DWG choice (e.g. `AC1018, is_dxf=false` -> `"DWG 2004"`).
/// Used to default the Save-As dropdown to the loaded file's version so a
+1 -1
View File
@@ -119,7 +119,7 @@ fn main() -> iced::Result {
}
if let Some(io) = &args.export {
// clap enforces exactly two values for --export.
let code = app::export_headless(&io[0], &io[1]);
let code = app::export_headless(&io[0], &io[1], args.target_version.as_deref());
std::process::exit(code);
}
+27 -4
View File
@@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path.";
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path. Before delivery call audit with the intended target_format and target_version; use save_verified with an explicit absolute path to save, reopen, hash and compare the semantic manifest.";
const READ_OPS: &[&str] = &[
"state",
"hello",
@@ -40,6 +40,7 @@ const READ_OPS: &[&str] = &[
"events",
"operation",
"xdata_get",
"audit",
];
const EXECUTE_OPS: &[&str] = &[
"new",
@@ -77,6 +78,7 @@ const EXECUTE_OPS: &[&str] = &[
"page_setup_set",
"file_identity",
"save",
"save_verified",
"stop",
"batch",
];
@@ -394,7 +396,7 @@ impl GuiClient {
self.state["document_id"].clone(),
);
insert_default(&mut object, "revision", self.state["revision"].clone());
if ["input", "property", "run", "action", "save", "undo", "redo"].contains(&op.as_str())
if ["input", "property", "run", "action", "save", "save_verified", "undo", "redo"].contains(&op.as_str())
{
insert_default(&mut object, "selection", self.state["selection"].clone());
}
@@ -846,6 +848,12 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> {
r#"{"op":"page_setup_set","layout":"Plan","paper":"ISO_A4_(210.00_x_297.00_MM)"}"#,
)
}
"save_verified" if request["path"].as_str().is_none_or(str::is_empty) => {
missing(
"path",
r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#,
)
}
_ => Ok(()),
}
}
@@ -990,6 +998,9 @@ fn batch_step_schema() -> Value {
"document_id":{"type":"integer","minimum":0},
"cmd":{"type":"string","minLength":1},
"path":{"type":"string","minLength":1},
"target_format":{"type":"string","enum":["dwg","dxf"]},
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"]},
"allow_lossy":{"type":"boolean","default":false},
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"]},
"text":{"type":"string"},
"point":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},
@@ -1031,7 +1042,7 @@ fn execute_request_schema() -> Value {
"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},
"selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."},
"cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]},
"path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot."},
"path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."},
"block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."},
"linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."},
"layout":{"type":"string","description":"plot: Model (default), a layout name, or all."},
@@ -1074,6 +1085,10 @@ fn execute_request_schema() -> Value {
"set":{"type":"object","description":"sysvar: name=value pairs to write in one undo step.","additionalProperties":true},
"select":{"type":"boolean","description":"selection_set_load: also select the recalled entities (default true)."},
"where":{"type":"array","description":"query: cross-property filters over entity properties with RFC 6901 paths and the records operator set.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}},
"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Explicit output format; it must match the path extension."},
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Explicit CAD output version. Omit only to preserve the document version."},
"allow_lossy":{"type":"boolean","default":false,"description":"Acknowledge dropping unsupported passthrough records reported by audit."},
"overwrite":{"type":"boolean","default":false,"description":"For save_verified only: replace an existing destination."},
"at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."},
"width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."},
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."},
@@ -1138,6 +1153,7 @@ fn execute_request_schema() -> Value {
{"properties":{"op":{"const":"page_setup_set"}},"required":["layout"]},
{"properties":{"op":{"const":"file_identity"}}},
{"properties":{"op":{"const":"save"}}},
{"properties":{"op":{"const":"save_verified"}},"required":["path"]},
{"properties":{"op":{"const":"stop"}}},
{"properties":{"op":{"const":"batch"}},"required":["steps"]}
]
@@ -1182,7 +1198,7 @@ fn tool_definitions() -> Value {
{
"name":"ocs_read",
"description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"outputSchema":read_output_schema(),
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
@@ -1552,7 +1568,14 @@ mod tests {
assert!(READ_OPS.contains(&"capabilities"));
assert!(READ_OPS.contains(&"records"));
assert!(READ_OPS.contains(&"record_schema"));
assert!(READ_OPS.contains(&"audit"));
assert!(EXECUTE_OPS.contains(&"set_properties"));
assert!(EXECUTE_OPS.contains(&"save_verified"));
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["properties"]
["target_version"]["enum"][0],
"R14"
);
assert_eq!(
tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"]
["op"]["enum"],
+24
View File
@@ -1428,6 +1428,19 @@ impl Scene {
sy += y as f64;
n += 1;
}
// SDF text is carried as textured glyph quads, not ordinary wire
// points. Include those vertices so a text-only wire participates
// in the robust-centre filter used by ZOOM EXTENTS.
for vertex in &wire.text_verts {
let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64;
let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64;
if !x.is_finite() || !y.is_finite() {
continue;
}
sx += x;
sy += y;
n += 1;
}
if n > 0 {
cents.push(WireCent {
idx,
@@ -1486,6 +1499,17 @@ impl Scene {
max = max.max(glam::Vec3::new(x, y, z));
}
}
for vertex in &wire.text_verts {
let point = glam::Vec3::new(
vertex.pos[0] + vertex.pos_low[0],
vertex.pos[1] + vertex.pos_low[1],
vertex.pos[2] + vertex.pos_low[2],
);
if point.is_finite() {
min = min.min(point);
max = max.max(point);
}
}
}
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
for ([ax, ay, bx, by], [az, bz]) in &mesh_aabbs {