diff --git a/README.md b/README.md index 38413d747..4bd949a2a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 84951d132..1e5790c6c 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -43,6 +43,7 @@ SS, *STRETCH S, *STRETCH O, *OFFSET TR, *TRIM +ER, *EXTERNALREFERENCES EX, *EXTEND F, *FILLET CHA, *CHAMFER diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 445393aaa..22e1d80dc 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -603,6 +603,103 @@ pub trait InteractiveCommand: Send { fn on_object_pick(&mut self, _handle: Handle, _pt: [f64; 3]) -> CommandStep { CommandStep::Cancel } + + /// Real-time preview geometry (lines, arcs, polylines) to render as the cursor moves. + /// Each wire can specify custom vertices and an optional color (defaults to host cyan). + fn on_cursor_move(&mut self, _pt: [f64; 3]) -> Vec { + Vec::new() + } +} + +/// A preview wire rendered in real-time during interactive commands. +/// Supports straight polylines as well as analytical circles and arcs +/// that render via GPU shaders with infinite smoothness. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))] +pub enum PreviewWire { + /// A connected sequence of straight line segments in world space. + Polyline { + points: Vec<[f64; 3]>, + color: Option<[f32; 4]>, + }, + /// An analytical circle rendered with sub-pixel GPU anti-aliasing. + Circle { + center: [f64; 3], + radius: f64, + color: Option<[f32; 4]>, + }, + /// An analytical circular arc swept counter-clockwise from `start_angle_rad` to `end_angle_rad`. + Arc { + center: [f64; 3], + radius: f64, + start_angle_rad: f64, + end_angle_rad: f64, + color: Option<[f32; 4]>, + }, +} + +impl PreviewWire { + /// Create a polyline preview wire with default host cyan color. + pub fn new(points: Vec<[f64; 3]>) -> Self { + Self::Polyline { points, color: None } + } + + /// Optional RGBA color (0.0 to 1.0) specified for this preview wire. + pub fn color(&self) -> Option<[f32; 4]> { + match self { + Self::Polyline { color, .. } => *color, + Self::Circle { color, .. } => *color, + Self::Arc { color, .. } => *color, + } + } + + /// Create a polyline preview wire with a custom RGBA color. + pub fn with_color(points: Vec<[f64; 3]>, color: [f32; 4]) -> Self { + Self::Polyline { + points, + color: Some(color), + } + } + + /// Create a straight line segment preview between two points. + pub fn line(from: [f64; 3], to: [f64; 3], color: Option<[f32; 4]>) -> Self { + Self::Polyline { + points: vec![from, to], + color, + } + } + + /// Create an analytical circle preview. + pub fn circle(center: [f64; 3], radius: f64, color: Option<[f32; 4]>) -> Self { + Self::Circle { + center, + radius, + color, + } + } + + /// Create an analytical circular arc preview. + pub fn arc( + center: [f64; 3], + radius: f64, + start_angle_rad: f64, + end_angle_rad: f64, + color: Option<[f32; 4]>, + ) -> Self { + Self::Arc { + center, + radius, + start_angle_rad, + end_angle_rad, + color, + } + } +} + +impl From> for PreviewWire { + fn from(points: Vec<[f64; 3]>) -> Self { + Self::new(points) + } } /// The outcome of an [`InteractiveCommand`] step. @@ -619,6 +716,14 @@ pub enum CommandStep { Done, /// Cancel the command. Cancel, + // New variants go after the existing ones: the step crosses the plugin + // IPC, which encodes a variant by its position, so inserting one earlier + // would make a plugin built against the previous API send `Done` and + // have the host read it as something else. + /// Commit multiple entities to the document and keep collecting points. + CommitMany(Vec), + /// Commit multiple entities to the document and end the command. + CommitManyAndEnd(Vec), } /// Export a `BuiltinPlugin` from a `cdylib` so the host can load it at runtime. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 5d86fe6b2..70d7513df 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -23,6 +23,7 @@ use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; pub use acadrust::xdata::{ExtendedDataRecord, XDataValue}; pub use acadrust::{CadDocument, EntityType, Handle}; +pub use crate::host::PreviewWire; /// Events the host forwards to an active plugin `InteractiveCommand`. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -83,6 +84,11 @@ pub enum HostRequest { }, /// V7: release an interactive command after completion or cancellation. DropInteractive { command_id: u64 }, + /// V7: cursor move preview update during an interactive command. + CursorMove { + command_id: u64, + pt: [f64; 3], + }, } /// Responses the plugin runner sends back for `HostRequest`. @@ -95,6 +101,7 @@ pub enum HostResponse { Manifest(OwnedPluginManifest), Error(String), CodeExecutionResult(crate::host::ExecutionResult), + PreviewWires(Vec), } /// Requests the plugin runner sends to the host. diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 5f8086a08..161ff97a1 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -222,6 +222,7 @@ fn base_max_floor(base: Duration, kind: &'static str) -> Duration { "GetManifest" | "GetRibbon" => Duration::from_secs(5), "Dispatch" => Duration::from_secs(10), "InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2), + "CursorMove" => Duration::from_millis(500), "ExecuteCode" => execute_code_timeout(), _ => Duration::from_secs(1), }; @@ -238,6 +239,7 @@ pub(crate) fn request_kind(req: &HostRequest) -> &'static str { HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", HostRequest::ExecuteCode { .. } => "ExecuteCode", HostRequest::DropInteractive { .. } => "DropInteractive", + HostRequest::CursorMove { .. } => "CursorMove", HostRequest::Shutdown => "Shutdown", } } @@ -681,6 +683,35 @@ impl PluginProcess { } } + /// Ask the plugin process for real-time preview wires for the cursor position. + pub fn on_cursor_move( + &self, + command_id: u64, + pt: [f64; 3], + ) -> Result, PluginError> { + if let Some(v4) = &self.v4 { + let resp = v4.call( + &mut NullHost, + HostRequest::CursorMove { command_id, pt }, + &mut |_| {}, + )?; + match resp { + HostResponse::PreviewWires(w) => Ok(w), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } + } else { + self.call_simple( + HostRequest::CursorMove { command_id, pt }, + "CursorMove", + "cursor_move", + |resp| match resp { + HostResponse::PreviewWires(w) => Ok(w), + other => Err(Box::new(other)), + }, + ) + } + } + pub fn is_alive(&self) -> bool { if let Some(v4) = &self.v4 { return v4.is_alive(); diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 1c7627f9d..237ddc982 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -197,6 +197,7 @@ fn handle_host_request( CommandStep::Done | CommandStep::Cancel | CommandStep::CommitAndEnd(_) + | CommandStep::CommitManyAndEnd(_) ); (Ok(s), is_done) } @@ -243,6 +244,21 @@ fn handle_host_request( None => HostResponse::Error(format!("unknown interactive command {command_id}")), } } + HostRequest::CursorMove { command_id, pt } => { + let result = { + let mut registry = interactive.borrow_mut(); + registry.get_mut(&command_id).map(|cmd| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cmd.on_cursor_move(pt) + })) + }) + }; + match result { + Some(Ok(wires)) => HostResponse::PreviewWires(wires), + Some(Err(_)) => HostResponse::PreviewWires(Vec::new()), + None => HostResponse::PreviewWires(Vec::new()), + } + } HostRequest::ExecuteCode { .. } => { HostResponse::Error("ExecuteCode requires V4 protocol".to_string()) } @@ -320,6 +336,7 @@ fn handle_host_request_v4( CommandStep::Done | CommandStep::Cancel | CommandStep::CommitAndEnd(_) + | CommandStep::CommitManyAndEnd(_) ); (Ok(s), is_done) } @@ -366,6 +383,21 @@ fn handle_host_request_v4( None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))), } } + HostRequest::CursorMove { command_id, pt } => { + let result = { + let mut registry = interactive.borrow_mut(); + registry.get_mut(&command_id).map(|cmd| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cmd.on_cursor_move(pt) + })) + }) + }; + match result { + Some(Ok(wires)) => Some(HostResponse::PreviewWires(wires)), + Some(Err(_)) => Some(HostResponse::PreviewWires(Vec::new())), + None => Some(HostResponse::PreviewWires(Vec::new())), + } + } HostRequest::ExecuteCode { command_id, source, diff --git a/docs/automation/README.md b/docs/automation/README.md index a4b24f672..8c700d7f1 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -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 `. +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 diff --git a/docs/automation/mcp_acceptance.py b/docs/automation/mcp_acceptance.py new file mode 100644 index 000000000..42d5d3d51 --- /dev/null +++ b/docs/automation/mcp_acceptance.py @@ -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() diff --git a/docs/automation/mcp_eval.py b/docs/automation/mcp_eval.py index db904bbb9..ccb9fee79 100644 --- a/docs/automation/mcp_eval.py +++ b/docs/automation/mcp_eval.py @@ -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 diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index 438ed9cff..dd1585f49 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -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" ] diff --git a/docs/automation/native.md b/docs/automation/native.md index 9211f85c6..d86cb6b99 100644 --- a/docs/automation/native.md +++ b/docs/automation/native.md @@ -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 | diff --git a/docs/automation/reference_acceptance.ps1 b/docs/automation/reference_acceptance.ps1 new file mode 100644 index 000000000..08f19599a --- /dev/null +++ b/docs/automation/reference_acceptance.ps1 @@ -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 +} diff --git a/docs/automation/reference_audit.scr b/docs/automation/reference_audit.scr new file mode 100644 index 000000000..ab25598d4 --- /dev/null +++ b/docs/automation/reference_audit.scr @@ -0,0 +1,3 @@ +_.AUDIT +_N +_.QUIT diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index df7ca174e..45b48ac0c 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2316,6 +2316,15 @@ common = .specify-on-screen = تحديد على الشاشة .to-layout = مع التخطيط .us-survey-feet = أقدام مسح أمريكية + .entire-drawing = الرسم بالكامل + .delete-from-drawing = حذف من الرسم + .destination = الوجهة + .file-name-and-path = اسم الملف والمسار: + .insert-units-label = وحدات الإدراج: + .wblock-err-no-block-name = يرجى تحديد أو إدخال اسم الكتلة. + .wblock-err-block-not-found = الكتلة "__ocs_fmt_0__" غير موجودة في الرسم. + .wblock-err-no-path = يرجى تحديد اسم الملف والمسار. + .wblock-help-info = يقوم WBLOCK بكتابة كائنات أو كتلة أو الرسم بالكامل إلى ملف رسم جديد. errors = .save-warning = تحذير الحفظ .unable-to-save-drawing = تعذر حفظ الرسم @@ -3540,15 +3549,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3580,6 +3582,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = سحب النقاط @@ -3873,6 +3896,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK حدد الكائنات: .block-specify-insertion-base-point = BLOCK حدد نقطة قاعدة الإدراج: + .wblock-specify-insertion-base-point = WBLOCK حدد نقطة أساس الإدراج: model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 3366f4da7..aa9805851 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = Указване на екрана .to-layout = с оформлението .us-survey-feet = Геодезически фута (САЩ) + .entire-drawing = Цял чертеж + .delete-from-drawing = Изтриване от чертежа + .destination = Местоназначение + .file-name-and-path = Име и път на файла: + .insert-units-label = Мерни единици за вмъкване: + .wblock-err-no-block-name = Моля, изберете или въведете име на блок. + .wblock-err-block-not-found = Блокът "__ocs_fmt_0__" не съществува в чертежа. + .wblock-err-no-path = Моля, задайте име и път на файла. + .wblock-help-info = WBLOCK записва обекти, блок или целия чертеж в нов чертожен файл. errors = .save-warning = Предупреждение при запазване .unable-to-save-drawing = Чертежът не може да бъде запазен @@ -3527,15 +3536,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3567,6 +3569,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Облаци от точки @@ -3860,6 +3883,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Изберете обекти: .block-specify-insertion-base-point = BLOCK Укажете базова точка на вмъкване: + .wblock-specify-insertion-base-point = WBLOCK Задайте базова точка на вмъкване: model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 133005d28..9b67c2c60 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = Zadat na obrazovce .to-layout = rozvržení .us-survey-feet = Zeměměřické stopy (USA) + .entire-drawing = Celý výkres + .delete-from-drawing = Vymazat z výkresu + .destination = Cíl + .file-name-and-path = Název souboru a cesta: + .insert-units-label = Jednotky vložení: + .wblock-err-no-block-name = Vyberte nebo zadejte název bloku. + .wblock-err-block-not-found = Blok "__ocs_fmt_0__" ve výkresu neexistuje. + .wblock-err-no-path = Zadejte název souboru a cestu. + .wblock-help-info = WBLOCK zapíše objekty, blok nebo celý výkres do nového výkresového souboru. errors = .save-warning = Uložit varování .unable-to-save-drawing = Nákres nelze uložit @@ -3525,15 +3534,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3565,6 +3567,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Bod Mraky @@ -3858,6 +3881,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Vyberte objekty: .block-specify-insertion-base-point = BLOCK Zadejte základní bod vložení: + .wblock-specify-insertion-base-point = WBLOCK Určete základní bod vložení: model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index f68cd9bff..55a622296 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2304,6 +2304,15 @@ common = .specify-on-screen = Am Bildschirm bestimmen .to-layout = an Layout .us-survey-feet = US-Vermessungsfuß + .entire-drawing = Gesamte Zeichnung + .delete-from-drawing = Aus Zeichnung löschen + .destination = Ziel + .file-name-and-path = Dateiname und Pfad: + .insert-units-label = Einfüge-Einheiten: + .wblock-err-no-block-name = Bitte wählen Sie einen Blocknamen aus oder geben Sie ihn ein. + .wblock-err-block-not-found = Block "__ocs_fmt_0__" ist in der Zeichnung nicht vorhanden. + .wblock-err-no-path = Bitte geben Sie einen Dateinamen und Pfad an. + .wblock-help-info = WBLOCK schreibt Objekte, einen Block oder die gesamte Zeichnung in eine neue Zeichnungsdatei. errors = .save-warning = Warnhinweise speichern .unable-to-save-drawing = Zeichnung konnte nicht gespeichert werden @@ -3528,15 +3537,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3568,6 +3570,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punkt- wolken @@ -3859,6 +3882,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Objekte auswählen: .block-specify-insertion-base-point = BLOCK Einfügebasispunkt angeben: + .wblock-specify-insertion-base-point = WBLOCK Basispunkt für Einfügung angeben: model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 231796ef6..5a1ba7b7c 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = Καθορισμός στην οθόνη .to-layout = με τη διάταξη .us-survey-feet = Τοπογραφικά πόδια ΗΠΑ + .entire-drawing = Ολόκληρο σχέδιο + .delete-from-drawing = Διαγραφή από το σχέδιο + .destination = Προορισμός + .file-name-and-path = Όνομα και διαδρομή αρχείου: + .insert-units-label = Μονάδες εισαγωγής: + .wblock-err-no-block-name = Επιλέξτε ή πληκτρολογήστε ένα όνομα μπλοκ. + .wblock-err-block-not-found = Το μπλοκ "__ocs_fmt_0__" δεν υπάρχει στο σχέδιο. + .wblock-err-no-path = Καθορίστε ένα όνομα και διαδρομή αρχείου. + .wblock-help-info = Η εντολή WBLOCK εγγράφει αντικείμενα, ένα μπλοκ ή ολόκληρο το σχέδιο σε ένα νέο αρχείο σχεδίου. errors = .save-warning = Προειδοποίηση αποθήκευσης .unable-to-save-drawing = Αδυναμία αποθήκευσης σχεδίου @@ -3528,15 +3537,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3568,6 +3570,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .revolve-creation-mode-solid-surface = REVOLVE Τρόπος δημιουργίας [Solid/Surface] .revolve-enter-angle-expression = REVOLVE Δώστε έκφραση γωνίας: .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Επιλέξτε γραμμή, ημιευθεία ή βοηθητική ευθεία ως άξονα: @@ -3861,6 +3884,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Επιλέξτε αντικείμενα: .block-specify-insertion-base-point = BLOCK Καθορίστε σημείο βάσης εισαγωγής: + .wblock-specify-insertion-base-point = WBLOCK Καθορίστε σημείο βάσης εισαγωγής: model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 3b9882510..2e6aa003e 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2304,6 +2304,16 @@ common = .specify-on-screen = Specify On-screen .to-layout = to layout .us-survey-feet = US Survey Feet + .entire-drawing = Entire drawing + .delete-from-drawing = Delete from drawing + .destination = Destination + .file-name-and-path = File name and path: + .insert-units-label = Insert units: + .wblock-err-no-block-name = Please select or enter a block name. + .wblock-err-block-not-found = Block "__ocs_fmt_0__" does not exist in drawing. + .wblock-err-no-path = Please specify a file name and path. + .wblock-err-current-drawing = Cannot write to the current drawing file. + .wblock-help-info = WBLOCK writes objects, a block, or the entire drawing to a new drawing file. errors = .save-warning = Save Warning .unable-to-save-drawing = Unable to Save Drawing @@ -3529,15 +3539,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3569,6 +3572,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Point Clouds @@ -3862,6 +3886,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Select objects: .block-specify-insertion-base-point = BLOCK Specify insertion base point: + .wblock-specify-insertion-base-point = WBLOCK Specify insertion base point: model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 8f009e65d..405689fac 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2304,6 +2304,15 @@ common = .specify-on-screen = Precisar en pantalla .to-layout = a la presentación .us-survey-feet = Pies de agrimensura de EE. UU. + .entire-drawing = Dibujo completo + .delete-from-drawing = Suprimir del dibujo + .destination = Destino + .file-name-and-path = Nombre de archivo y ruta: + .insert-units-label = Unidades de inserción: + .wblock-err-no-block-name = Seleccione o escriba un nombre de bloque. + .wblock-err-block-not-found = El bloque "__ocs_fmt_0__" no existe en el dibujo. + .wblock-err-no-path = Especifique un nombre de archivo y una ruta. + .wblock-help-info = WBLOCK escribe objetos, un bloque o el dibujo completo en un nuevo archivo de dibujo. errors = .save-warning = Advertencia de guardado .unable-to-save-drawing = No se puede guardar el dibujo @@ -3528,15 +3537,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3568,6 +3570,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Nubes de puntos @@ -3861,6 +3884,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Designar objetos: .block-specify-insertion-base-point = BLOCK Precise punto base de inserción: + .wblock-specify-insertion-base-point = WBLOCK Precise punto base de inserción: model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index fce51811c..8ab4f3f23 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = Määritä näytöltä .to-layout = asetteluun .us-survey-feet = US Survey -jalat + .entire-drawing = Koko piirustus + .delete-from-drawing = Poista piirustuksesta + .destination = Kohde + .file-name-and-path = Tiedoston nimi ja polku: + .insert-units-label = Lisäysyksiköt: + .wblock-err-no-block-name = Valitse tai anna lohkon nimi. + .wblock-err-block-not-found = Lohkoa "__ocs_fmt_0__" ei ole piirustuksessa. + .wblock-err-no-path = Määritä tiedoston nimi ja polku. + .wblock-help-info = WBLOCK kirjoittaa kohteet, lohkon tai koko piirustuksen uuteen piirustustiedostoon. errors = .save-warning = Tallenna varoitus .unable-to-save-drawing = Piirustusta ei voi tallentaa @@ -3527,15 +3536,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3567,6 +3569,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Point Pilviä @@ -3860,6 +3883,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Valitse kohteet: .block-specify-insertion-base-point = BLOCK Määritä sijoituksen peruspiste: + .wblock-specify-insertion-base-point = WBLOCK Määritä lisäyksen peruspiste: model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index f5820abbf..a3fb1ffd8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2304,6 +2304,15 @@ common = .specify-on-screen = Spécifier à l'écran .to-layout = sur la présentation .us-survey-feet = Pieds d'arpentage US + .entire-drawing = Dessin entier + .delete-from-drawing = Supprimer du dessin + .destination = Destination + .file-name-and-path = Nom de fichier et chemin d'accès : + .insert-units-label = Unités d'insertion : + .wblock-err-no-block-name = Veuillez sélectionner ou entrer un nom de bloc. + .wblock-err-block-not-found = Le bloc « __ocs_fmt_0__ » n'existe pas dans le dessin. + .wblock-err-no-path = Veuillez spécifier un nom de fichier et un chemin d'accès. + .wblock-help-info = WBLOCK écrit des objets, un bloc ou l'ensemble du dessin dans un nouveau fichier de dessin. errors = .save-warning = Enregistrer l' avertissement .unable-to-save-drawing = Impossible de sauvegarder le dessin @@ -3528,15 +3537,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3568,6 +3570,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Nuages de points @@ -3861,6 +3884,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Sélectionnez les objets : .block-specify-insertion-base-point = BLOCK Spécifiez le point de base d'insertion : + .wblock-specify-insertion-base-point = WBLOCK Spécifiez le point de base d'insertion : model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 37de41fb3..71dd29788 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2292,6 +2292,15 @@ common = .specify-on-screen = स्क्रीन पर निर्दिष्ट करें .to-layout = लेआउट के अनुसार .us-survey-feet = यूएस सर्वेक्षण फीट + .entire-drawing = संपूर्ण रेखाचित्र + .delete-from-drawing = रेखाचित्र से हटाएं + .destination = गंतव्य + .file-name-and-path = फ़ाइल का नाम और पथ: + .insert-units-label = सम्मिलन इकाइयाँ: + .wblock-err-no-block-name = कृपया एक ब्लॉक नाम चुनें या दर्ज करें। + .wblock-err-block-not-found = ब्लॉक "__ocs_fmt_0__" रेखाचित्र में मौजूद नहीं है। + .wblock-err-no-path = कृपया फ़ाइल का नाम और पथ निर्दिष्ट करें। + .wblock-help-info = WBLOCK ऑब्जेक्ट, एक ब्लॉक, या संपूर्ण रेखाचित्र को एक नई रेखाचित्र फ़ाइल में लिखता है। errors = .save-warning = चेतावनी सहेजें .unable-to-save-drawing = ड्रॉइंग सहेजी नहीं जा सकी @@ -3516,15 +3525,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3556,6 +3558,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = बिंदु बादल @@ -3849,6 +3872,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK ऑब्जेक्ट चुनें: .block-specify-insertion-base-point = BLOCK सम्मिलन आधार बिंदु निर्दिष्ट करें: + .wblock-specify-insertion-base-point = WBLOCK सम्मिलन आधार बिंदु निर्दिष्ट करें: model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 85545811d..81330fc3f 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = Megadás a képernyőn .to-layout = az elrendezéshez .us-survey-feet = USA geodéziai láb + .entire-drawing = Teljes rajz + .delete-from-drawing = Törlés a rajzból + .destination = Cél + .file-name-and-path = Fájlnév és elérési út: + .insert-units-label = Beillesztési mértékegységek: + .wblock-err-no-block-name = Válasszon vagy adjon meg egy blokknevet. + .wblock-err-block-not-found = A(z) "__ocs_fmt_0__" blokk nem létezik a rajzban. + .wblock-err-no-path = Adja meg a fájlnevet és az elérési utat. + .wblock-help-info = A WBLOCK objektumokat, egy blokkot vagy a teljes rajzot új rajzfájlba menti. errors = .save-warning = Figyelmeztetés mentése .unable-to-save-drawing = Nem sikerült menteni a rajzot @@ -3527,15 +3536,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3567,6 +3569,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Pont Felhők @@ -3860,6 +3883,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Válasszon objektumokat: .block-specify-insertion-base-point = BLOCK Adja meg a beillesztési bázispontot: + .wblock-specify-insertion-base-point = WBLOCK Adja meg a beillesztési bázispontot: model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index f5f39c555..4fd41cc35 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = Specifica sullo schermo .to-layout = al layout .us-survey-feet = Piedi topografici USA + .entire-drawing = Intero disegno + .delete-from-drawing = Elimina dal disegno + .destination = Destinazione + .file-name-and-path = Nome file e percorso: + .insert-units-label = Unità di inserimento: + .wblock-err-no-block-name = Selezionare o immettere il nome di un blocco. + .wblock-err-block-not-found = Il blocco "__ocs_fmt_0__" non esiste nel disegno. + .wblock-err-no-path = Specificare un nome file e un percorso. + .wblock-help-info = WBLOCK scrive gli oggetti, un blocco o l'intero disegno in un nuovo file di disegno. errors = .save-warning = Salva avviso .unable-to-save-drawing = Impossibile salvare il disegno @@ -3527,15 +3536,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3567,6 +3569,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punto Nuvole @@ -3860,6 +3883,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Selezionare oggetti: .block-specify-insertion-base-point = BLOCK Specificare punto base di inserimento: + .wblock-specify-insertion-base-point = WBLOCK Specificare il punto base di inserimento: model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 8e68144ce..f5c292296 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2294,6 +2294,15 @@ common = .specify-on-screen = 画面上で指定 .to-layout = レイアウトに合わせる .us-survey-feet = 米国測量フィート + .entire-drawing = 図面全体 + .delete-from-drawing = 図面から削除 + .destination = 保存先 + .file-name-and-path = ファイル名とパス: + .insert-units-label = 挿入単位: + .wblock-err-no-block-name = ブロック名を選択または入力してください。 + .wblock-err-block-not-found = ブロック "__ocs_fmt_0__" は図面に存在しません。 + .wblock-err-no-path = ファイル名とパスを指定してください。 + .wblock-help-info = WBLOCK はオブジェクト、ブロック、または図面全体を新しい図面ファイルに書き出します。 errors = .save-warning = 保存の警告 .unable-to-save-drawing = 図面を保存できません @@ -3518,15 +3527,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3558,6 +3560,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 点群 .attsync-block-name-to-sync = ATTSYNC 同期するブロック名: .attsync-no-block-named-arg = ATTSYNC: "__ocs_fmt_0__" というブロックはありません。 @@ -3849,6 +3872,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK オブジェクトを選択: .block-specify-insertion-base-point = BLOCK 挿入基点を指定: + .wblock-specify-insertion-base-point = WBLOCK 挿入基点を指定: model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index ae37dc99b..f949495eb 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = 화면상에 지정 .to-layout = 배치에 맞춤 .us-survey-feet = 미국 측량 피트 + .entire-drawing = 도면 전체 + .delete-from-drawing = 도면에서 삭제 + .destination = 대상 + .file-name-and-path = 파일 이름 및 경로: + .insert-units-label = 삽입 단위: + .wblock-err-no-block-name = 블록 이름을 선택하거나 입력하십시오. + .wblock-err-block-not-found = 블록 "__ocs_fmt_0__"이(가) 도면에 존재하지 않습니다. + .wblock-err-no-path = 파일 이름과 경로를 지정하십시오. + .wblock-help-info = WBLOCK은 객체, 블록 또는 도면 전체를 새 도면 파일로 내보냅니다. errors = .save-warning = 경고 저장 .unable-to-save-drawing = 도면을 저장할 수 없습니다 @@ -3527,15 +3536,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3567,6 +3569,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 포인트 구름 @@ -3860,6 +3883,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK 객체 선택: .block-specify-insertion-base-point = BLOCK 삽입 기준점 지정: + .wblock-specify-insertion-base-point = WBLOCK 삽입 기준점 지정: model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index fa0a2eed5..8dd42158e 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2304,6 +2304,15 @@ common = .specify-on-screen = Op scherm opgeven .to-layout = aan lay-out .us-survey-feet = US Survey Feet + .entire-drawing = Volledige tekening + .delete-from-drawing = Verwijderen uit tekening + .destination = Bestemming + .file-name-and-path = Bestandsnaam en pad: + .insert-units-label = Invoegeenheden: + .wblock-err-no-block-name = Selecteer of voer een bloknaam in. + .wblock-err-block-not-found = Blok "__ocs_fmt_0__" bestaat niet in de tekening. + .wblock-err-no-path = Geef een bestandsnaam en pad op. + .wblock-help-info = WBLOCK schrijft objecten, een blok of de volledige tekening naar een nieuw tekeningbestand. errors = .save-warning = Waarschuwing opslaan .unable-to-save-drawing = Kan tekening niet opslaan @@ -3526,15 +3535,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3566,6 +3568,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punten- wolken @@ -3859,6 +3882,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Objecten selecteren: .block-specify-insertion-base-point = BLOCK Invoegbasispunt opgeven: + .wblock-specify-insertion-base-point = WBLOCK Geef basisinvoegpunt op: model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 59ddd80dd..f47c1dd18 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = Określ na ekranie .to-layout = do układu .us-survey-feet = Stopy geodezyjne USA + .entire-drawing = Cały rysunek + .delete-from-drawing = Usuń z rysunku + .destination = Cel + .file-name-and-path = Nazwa pliku i ścieżka: + .insert-units-label = Jednostki wstawiania: + .wblock-err-no-block-name = Wybierz lub wprowadź nazwę bloku. + .wblock-err-block-not-found = Blok „__ocs_fmt_0__” nie istnieje w rysunku. + .wblock-err-no-path = Podaj nazwę pliku i ścieżkę. + .wblock-help-info = WBLOCK zapisuje obiekty, blok lub cały rysunek do nowego pliku rysunku. errors = .save-warning = Zapisz ostrzeżenie .unable-to-save-drawing = Nie można zapisać rysunku @@ -3527,15 +3536,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3567,6 +3569,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Punkt Chmury @@ -3860,6 +3883,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Wybierz obiekty: .block-specify-insertion-base-point = BLOCK Określ bazowy punkt wstawienia: + .wblock-specify-insertion-base-point = WBLOCK Określ punkt bazowy wstawienia: model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index b649b0cb7..6dc7e0aa0 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2304,6 +2304,15 @@ common = .specify-on-screen = Especificar na tela .to-layout = ao layout .us-survey-feet = Pés topográficos dos EUA + .entire-drawing = Desenho inteiro + .delete-from-drawing = Excluir do desenho + .destination = Destino + .file-name-and-path = Nome do arquivo e caminho: + .insert-units-label = Unidades de inserção: + .wblock-err-no-block-name = Selecione ou insira o nome de um bloco. + .wblock-err-block-not-found = O bloco "__ocs_fmt_0__" não existe no desenho. + .wblock-err-no-path = Especifique um nome de arquivo e caminho. + .wblock-help-info = O WBLOCK grava objetos, um bloco ou o desenho inteiro em um novo arquivo de desenho. errors = .save-warning = Aviso de salvamento .unable-to-save-drawing = Não foi possível salvar o desenho @@ -3528,15 +3537,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3568,6 +3570,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Nuvens de pontos @@ -3861,6 +3884,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Selecionar objetos: .block-specify-insertion-base-point = BLOCK Especificar ponto base de inserção: + .wblock-specify-insertion-base-point = WBLOCK Especifique o ponto base de inserção: model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index b2f2826dc..c904fac37 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2307,6 +2307,15 @@ common = .specify-on-screen = Указать на экране .to-layout = с листом .us-survey-feet = Геодезические футы США + .entire-drawing = Весь чертеж + .delete-from-drawing = Удалить из чертежа + .destination = Место назначения + .file-name-and-path = Имя файла и путь: + .insert-units-label = Единицы вставки: + .wblock-err-no-block-name = Выберите или введите имя блока. + .wblock-err-block-not-found = Блок "__ocs_fmt_0__" отсутствует в чертеже. + .wblock-err-no-path = Укажите имя файла и путь к нему. + .wblock-help-info = WBLOCK записывает объекты, блок или весь чертеж в новый файл чертежа. errors = .save-warning = Спасти предостережение .unable-to-save-drawing = Не удалось сохранить чертёж @@ -3529,15 +3538,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3569,6 +3571,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = Облака точек @@ -3862,6 +3885,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Выберите объекты: .block-specify-insertion-base-point = BLOCK Укажите базовую точку вставки: + .wblock-specify-insertion-base-point = WBLOCK Укажите базовую точку вставки: model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index e07490489..4676d5c5d 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2004,7 +2004,7 @@ common = .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. .formula = Formula - .full-path = Full path + .full-path = Tam yol .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom .grid-on-f7 = Grid On (F7) .grid-x-spacing = Grid X spacing: @@ -2059,7 +2059,7 @@ common = .named-parameters = Named Parameters .named-range = Named range .nearest-to-face = Nearest to face - .no-path = No path + .no-path = Yol yok .no-preview-available = No preview available. .no-surfaces-selected = No surfaces selected. .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. @@ -2099,7 +2099,7 @@ common = .recent-colors = Recent Colors .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. .refine-data = Refine data - .relative-path = Relative path + .relative-path = Göreli yol .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. .requires-numeric-distance-or-two-points = Requires numeric distance or two points. @@ -2288,6 +2288,15 @@ common = .specify-on-screen = Ekranda Belirt .to-layout = düzene .us-survey-feet = ABD Ölçüm Fiti + .entire-drawing = Tüm çizim + .delete-from-drawing = Çizimden sil + .destination = Hedef + .file-name-and-path = Dosya adı ve yolu: + .insert-units-label = Ekleme birimleri: + .wblock-err-no-block-name = Lütfen bir blok adı seçin veya girin. + .wblock-err-block-not-found = "__ocs_fmt_0__" bloğu çizimde mevcut değil. + .wblock-err-no-path = Lütfen bir dosya adı ve yolu belirtin. + .wblock-help-info = WBLOCK nesneleri, bir bloğu veya tüm çizimi yeni bir çizim dosyasına yazar. errors = .save-warning = Kaydetme Uyarısı .unable-to-save-drawing = Çizim Kaydedilemiyor @@ -3502,15 +3511,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = Bu dosya herhangi bir katman içermiyor. .pdfui-search-for-layer = Katman ara .pdfui-reference-name = Referans adı: - .pdfui-show-details = Ayrıntıları Göster - .pdfui-hide-details = Ayrıntıları Gizle .pdfui-page-size = Sayfa boyutu: - .pdfui-saved-path = Kayıtlı yol: - .pdfui-found-in = Bulunduğu yer: - .pdfui-angle = Açı: - .pdfui-insertion-point = Ekleme noktası .pdfui-select-one-or-more-pages-from-the-pdf-file = PDF dosyasından bir veya daha fazla sayfa seçin: - .pdfui-browse = Gözat... .pdfui-import-pdf = PDF İçe Aktar .pdfui-pdf-import-settings = PDF İçe Aktarma Ayarları .pdfui-underlay-layers = Altlık Katmanları @@ -3542,6 +3544,27 @@ insert = Tek renkli göster .select-pdf-file = PDF Dosyası Seç + .xref-attach-dialog-title = Harici Referans Ekle + .xref-attach-browse = Gözat... + .xref-attach-reference-type = Referans Türü + .xref-attach-uniform-scale = Eşit ölçek + .xref-attach-insertion-point = Ekleme noktası + .xref-attach-angle = Açı: + .xref-attach-block-unit = Blok birimi + .xref-attach-unit = Birim: + .xref-attach-factor = Çarpan: + .xref-attach-found-in = Bulunduğu yer: + .xref-attach-saved-path = Kayıtlı yol: + .xref-attach-show-details = Ayrıntıları Göster + .xref-attach-hide-details = Ayrıntıları Gizle + .xref-attach-select-file = Referans Dosyası Seç + .xref-attach-all-files = Tüm Referans Dosyaları + .xref-attach-help = Diğer çizimler, raster görüntüler ve altlıklar gibi harici dosyalara referans ekler. + .xref-attach-invalid-input = Geçersiz giriş. + .xref-insertion-point-x = Ekleme noktası X + .xref-insertion-point-y = Ekleme noktası Y + .xref-insertion-point-z = Ekleme noktası Z + .xref-layer-property-overrides = Katman özelliği geçersiz kılmaları .point-clouds = Nokta Bulutları @@ -3821,6 +3844,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK Nesneleri seçin: .block-specify-insertion-base-point = BLOCK Ekleme taban noktasını belirtin: + .wblock-specify-insertion-base-point = WBLOCK Ekleme taban noktasını belirleyin: model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 13f0a278f..6c0cca5d1 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2291,6 +2291,15 @@ common = .specify-on-screen = 在屏幕上指定 .to-layout = 匹配到布局 .us-survey-feet = 美国测量英尺 + .entire-drawing = 整个图形 + .delete-from-drawing = 从图形中删除 + .destination = 目标 + .file-name-and-path = 文件名和路径: + .insert-units-label = 插入单位: + .wblock-err-no-block-name = 请选择或输入块名称。 + .wblock-err-block-not-found = 图形中不存在块“__ocs_fmt_0__”。 + .wblock-err-no-path = 请指定文件名和路径。 + .wblock-help-info = WBLOCK 将对象、块或整个图形写入新的图形文件。 errors = .save-warning = 保存警告 .unable-to-save-drawing = 无法保存图形 @@ -3515,15 +3524,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3555,6 +3557,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 点云 .attsync-block-name-to-sync = ATTSYNC 要同步的块名 : .attsync-no-block-named-arg = ATTSYNC:没有块名“__ocs_fmt_0__”。 @@ -3844,6 +3867,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK 选择对象: .block-specify-insertion-base-point = BLOCK 指定插入基点: + .wblock-specify-insertion-base-point = WBLOCK 指定插入基点: model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 13016e63d..8d40035cc 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2303,6 +2303,15 @@ common = .specify-on-screen = 在螢幕上指定 .to-layout = 與配置相符 .us-survey-feet = 美國測量英呎 + .entire-drawing = 整個圖面 + .delete-from-drawing = 從圖面中刪除 + .destination = 目標 + .file-name-and-path = 檔名和路徑: + .insert-units-label = 插入單位: + .wblock-err-no-block-name = 請選取或輸入圖塊名稱。 + .wblock-err-block-not-found = 圖面中不存在圖塊「__ocs_fmt_0__」。 + .wblock-err-no-path = 請指定檔名和路徑。 + .wblock-help-info = WBLOCK 將物件、圖塊或整個圖面寫入新的圖面檔案。 errors = .save-warning = 儲存警告 .unable-to-save-drawing = 無法儲存圖形 @@ -3527,15 +3536,8 @@ insert = .pdfui-this-file-does-not-contain-any-layers = This file does not contain any layers. .pdfui-search-for-layer = Search for layer .pdfui-reference-name = Reference name: - .pdfui-show-details = Show Details - .pdfui-hide-details = Hide Details .pdfui-page-size = Page size: - .pdfui-saved-path = Saved path: - .pdfui-found-in = Found in: - .pdfui-angle = Angle: - .pdfui-insertion-point = Insertion point .pdfui-select-one-or-more-pages-from-the-pdf-file = Select one or more pages from the PDF file: - .pdfui-browse = Browse... .pdfui-import-pdf = Import PDF .pdfui-pdf-import-settings = PDF Import Settings .pdfui-underlay-layers = Underlay Layers @@ -3567,6 +3569,27 @@ insert = Display in Monochrome .select-pdf-file = Select PDF File + .xref-attach-dialog-title = Attach External Reference + .xref-attach-browse = Browse... + .xref-attach-reference-type = Reference Type + .xref-attach-uniform-scale = Uniform Scale + .xref-attach-insertion-point = Insertion point + .xref-attach-angle = Angle: + .xref-attach-block-unit = Block Unit + .xref-attach-unit = Unit: + .xref-attach-factor = Factor: + .xref-attach-found-in = Found in: + .xref-attach-saved-path = Saved path: + .xref-attach-show-details = Show Details + .xref-attach-hide-details = Hide Details + .xref-attach-select-file = Select Reference File + .xref-attach-all-files = All Reference Files + .xref-attach-help = Inserts references to external files such as other drawings, raster images, and underlays. + .xref-attach-invalid-input = Invalid input. + .xref-insertion-point-x = Insertion point X + .xref-insertion-point-y = Insertion point Y + .xref-insertion-point-z = Insertion point Z + .xref-layer-property-overrides = Layer property overrides .point-clouds = 點雲 .attsync-block-name-to-sync = ATTSYNC 要同步的圖塊名 : .attsync-no-block-named-arg = ATTSYNC:沒有圖塊名“__ocs_fmt_0__”。 @@ -3856,6 +3879,7 @@ insert = .xref = XREF: __ocs_fmt_0__ .block-select-objects = BLOCK 選取物件: .block-specify-insertion-base-point = BLOCK 指定插入基準點: + .wblock-specify-insertion-base-point = WBLOCK 指定插入基準點: model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/alias.rs b/src/app/alias.rs index dc0a3a169..0f47f5b1e 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -35,6 +35,11 @@ const DEFAULT_ALIASES_PGP: &str = include_str!("../../assets/ocad.pgp"); /// config base can't be resolved (headless, no `HOME`). #[cfg(not(target_arch = "wasm32"))] fn alias_file_path() -> Option { + // Tests read the shipped defaults: parallel tests seeding and migrating one + // shared file race, and a half-written table fails unrelated alias tests. + if cfg!(test) { + return None; + } Some(crate::config::config_dir()?.join("ocad.pgp")) } @@ -74,8 +79,8 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// defaults add aliases or change a default target, so existing profiles can be /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped /// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 -/// introduced HB (HATCHTOBACK). -const DEFAULT_ALIASES_VERSION: u32 = 3; +/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES). +const DEFAULT_ALIASES_VERSION: u32 = 4; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -93,6 +98,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { ("REA", "REGENALL"), ], 3 => &[("HB", "HATCHTOBACK")], + 4 => &[("ER", "EXTERNALREFERENCES")], _ => &[], } } diff --git a/src/app/automation.rs b/src/app/automation.rs index 56bbef317..f0df43b37 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -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 { 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 = BTreeMap::new(); + let mut by_layer: BTreeMap = 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 { + 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 { + 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::() 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 = 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 = 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 = document + .layers + .iter() + .map(|layer| layer.name.to_ascii_uppercase()) + .collect(); + let block_names: HashSet = 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 = 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 = 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 { + 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(); diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8f0a41b7c..bd818e65a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -711,8 +711,12 @@ impl OpenCADStudio { let toks: Vec = tokens.iter().map(|s| s.to_string()).collect(); return self.finish_active_command(&toks, finish); } + // Bare XREF opens the External References palette, so probing the + // verb alone would open it (and refresh every entry) before the + // argument form `XREF Reload A` runs. if tokens[0].eq_ignore_ascii_case("BACKGROUND") || tokens[0].eq_ignore_ascii_case("COLORSCHEME") + || tokens[0].eq_ignore_ascii_case("XREF") { return self.dispatch_command(cmd); } @@ -3127,60 +3131,17 @@ impl OpenCADStudio { self.sync_dyn_fields(); self.refresh_area_preview(i); } - CmdResult::CommitAndExit(mut entity) => { - // For XATTACH: ensure the xref block definition exists before - // committing the INSERT entity that references it. - // Extract path early to avoid borrow conflicts. - let xattach_path: Option = { - let tab = &self.tabs[i]; - if let Some(cmd) = tab.active_cmd.as_ref() { - if cmd.name() == "XATTACH" { - cmd.xattach_path() - } else { - None - } - } else { - None - } - }; - if let Some(path) = xattach_path { - // XREF-Task6: the host drawing path lives in the tab - // (`current_path`), not in `Scene`, so the self-attach - // guard runs here via the pure `is_self_attach` helper. - let host_file = self.tabs[i].current_path.clone(); - let host_base = host_file - .as_deref() - .and_then(|p| p.parent()) - .map(|p| p.to_path_buf()); - if let Some(host) = host_file.as_deref() { - if crate::modules::insert::xattach::is_self_attach( - host, - &path, - host_base.as_deref(), - ) { - self.command_line.push_error(crate::t!("XATTACH: cannot attach the host drawing into itself.").as_ref()); - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - return Task::none(); - } - } - let prepared_name = crate::modules::insert::xattach::prepare_xref_block( - &mut self.tabs[i].scene, - &path, - host_base.as_deref(), - ); - // `prepare_xref_block` may suffix a colliding stem. The - // command was created before that collision was known, so - // retarget its pending INSERT to the actual new block. - if let acadrust::EntityType::Insert(insert) = &mut entity { - insert.block_name = prepared_name; - } - // Resolving the xref merged its layer / linetype tables - // into the document — mirror them into the Layers panel - // and ribbon dropdowns now, not on the next reopen (#407). - self.refresh_layer_panel(); + CmdResult::CommitAndExit(entity) => { + // XATTACH: the definition and its INSERT are created together + // in one undo step. + let xattach_request = self.tabs[i] + .active_cmd + .as_ref() + .filter(|cmd| cmd.name() == "XATTACH") + .and_then(|cmd| cmd.xattach_request()); + if let Some(request) = xattach_request { + self.commit_xref_attach(i, request, entity); + return Task::none(); } let insert_block_name = match &entity { acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), @@ -9301,7 +9262,7 @@ impl OpenCADStudio { out } - fn restore_pre_cmd_tangent(&mut self) { + pub(super) fn restore_pre_cmd_tangent(&mut self) { if let Some(was_on) = self.pre_cmd_tangent.take() { if !was_on { self.snapper.enabled.remove(&crate::snap::SnapType::Tangent); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index ee8bcab08..bbe978234 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -460,6 +460,41 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::BlockDefinition); } + cmd if cmd.starts_with("WBLOCK_POINT_PICKED ") => { + let parts: Vec = cmd["WBLOCK_POINT_PICKED ".len()..] + .split_whitespace() + .filter_map(|s| s.parse::().ok()) + .collect(); + if parts.len() == 3 { + if let Some(state) = self.wblock.as_mut() { + state.base_point_x = format!("{:.4}", parts[0]); + state.base_point_y = format!("{:.4}", parts[1]); + state.base_point_z = format!("{:.4}", parts[2]); + } + } + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::WriteBlock); + } + + "WBLOCK_POINT_CANCELLED" => { + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::WriteBlock); + } + + "WBLOCK_OBJECTS_GATHERED" => { + let handles: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + if let Some(state) = self.wblock.as_mut() { + state.selected_handles = handles; + } + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::WriteBlock); + } + "INSERT" => { let blocks = self.tabs[i].scene.custom_block_names(); if blocks.is_empty() { @@ -718,9 +753,56 @@ impl OpenCADStudio { // Launch the file picker; XAttachPickResult will start the command. return Some(Task::done(Message::XAttachPick)); } - cmd if cmd == "WBLOCK" || cmd == "WB" || cmd.starts_with("WBLOCK ") => { - let arg = cmd.splitn(2, ' ').nth(1).unwrap_or("").trim(); - if arg.is_empty() { + "WBLOCK" | "WB" => { + let handles: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + let existing_blocks = self.tabs[i].scene.custom_block_names(); + let default_unit = self.tabs[i].scene.document.header.insertion_units; + let default_folder = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let default_path = if default_folder.is_empty() { + "new_block.dwg".to_string() + } else { + format!("{}/new_block.dwg", default_folder.replace('\\', "/")) + }; + self.wblock = Some( + crate::ui::window::wblock::WblockState::new( + existing_blocks, + handles, + default_unit, + default_path, + ), + ); + self.active_modal = Some(crate::app::ModalKind::WriteBlock); + } + + cmd if cmd == "-WBLOCK" + || cmd == "-WB" + || cmd.starts_with("-WBLOCK ") + || cmd.starts_with("-WB ") + || cmd.starts_with("WBLOCK ") + || cmd.starts_with("WB ") => + { + let rest = if let Some(r) = cmd.strip_prefix("-WBLOCK") { + r.trim() + } else if let Some(r) = cmd.strip_prefix("-WB") { + r.trim() + } else if let Some(r) = cmd.strip_prefix("WBLOCK") { + r.trim() + } else if let Some(r) = cmd.strip_prefix("WB") { + r.trim() + } else { + "" + }; + if rest.is_empty() { // No argument: use selected entities (*) if any, else ask. let sel: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); if sel.is_empty() { @@ -731,7 +813,7 @@ impl OpenCADStudio { return Some(Task::done(Message::WblockSave("*".to_string()))); } } else { - return Some(Task::done(Message::WblockSave(arg.to_string()))); + return Some(Task::done(Message::WblockSave(rest.to_string()))); } } @@ -1417,11 +1499,16 @@ impl OpenCADStudio { if after.is_empty() { return Some(Task::done(Message::XAttachPick)); } - let cmd = crate::modules::insert::xattach::XAttachCommand::with_path( - after.to_string(), + self.start_xref_attach( + i, + crate::modules::insert::xattach::XrefAttachRequest { + path: after.trim_matches('"').to_string(), + overlay: false, + path_type: crate::io::xref_model::Pathtype::Full, + }, + crate::modules::insert::xattach::XrefPlacement::on_screen(), + "-XREF", ); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { self.command_line.push_error(crate::tf!( "XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach", @@ -1648,11 +1735,11 @@ mod tests { #[test] fn xref_dash_alias_lists_like_xref() { - // `-XREF` is accepted as an alias for industry muscle memory; bare - // `XREF` keeps the legacy list output for compatibility. + // `-XREF` asks on the command line: `?` then Enter prints the + // reference table; `XREF ?` keeps the legacy list output for scripts. let mut app = fresh_app(); let out = run_capture(&mut app, "-XREF ?"); - assert!(out.contains("No external references") || out.contains("External references")); + assert!(out.contains("Total Xref(s): 0"), "got: {out:?}"); } #[test] @@ -2065,9 +2152,15 @@ mod tests { #[test] fn xref_attach_with_file_starts_placement() { // F10: `XREF Attach ` threads the file into the XATTACH - // placement command (same flow as the picker result). + // placement command (same flow as the picker result). A missing file + // is rejected up front, so the reference has to exist. + let dir = std::env::temp_dir().join(format!("ocs_xref_attach_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("plan.dwg"); + std::fs::write(&file, b"fake").unwrap(); let mut app = fresh_app(); - let out = run_capture(&mut app, "XREF Attach C:/refs/plan.dwg"); + let out = run_capture(&mut app, &format!("XREF Attach {}", file.to_string_lossy())); + std::fs::remove_dir_all(&dir).ok(); assert!( !out.contains("ships with reference operations"), "dead-end text must be gone, got: {out:?}" diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a7ee27ed0..ce97b48b5 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -5,6 +5,7 @@ use iced::Task; use std::path::PathBuf; mod blocks; +mod xref_attach; mod dim; pub(crate) mod display; mod draw; @@ -249,6 +250,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_layers(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_xref_attach(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_blocks(cmd, i) { return Some(t); } @@ -663,6 +667,7 @@ inventory::submit!(crate::command::CommandRegistration { "3DORBIT", "3O", "ABOUT", + "ATTACH", "ATTDISP", "ATTEXT", "BACKGROUND", diff --git a/src/app/commands/xref_attach.rs b/src/app/commands/xref_attach.rs new file mode 100644 index 000000000..e80e59773 --- /dev/null +++ b/src/app/commands/xref_attach.rs @@ -0,0 +1,479 @@ +//! Attaching drawing references: ATTACH / XATTACH (file picker, then the +//! Attach External Reference dialog), the -XREF command line, the `?` list, +//! and the commit that creates the definition and its INSERT as one undo step. + +use super::*; +use crate::io::xref_model::Pathtype; +use crate::modules::insert::xattach::{ + existing_reference, path_to_block_name, prepare_xref_definition, stored_path, + XAttachCommand, XrefAttachRequest, XrefPlacement, +}; +use crate::modules::insert::xref_cmd::display_path; +use crate::ui::window::xref_attach::{XrefAttachMsg, XrefAttachState}; + +impl OpenCADStudio { + /// Commands owned here; `None` defers to the other families. + pub(super) fn dispatch_xref_attach(&mut self, cmd: &str, i: usize) -> Option> { + let upper = cmd.to_ascii_uppercase(); + let (verb, rest) = match cmd.split_once(char::is_whitespace) { + Some((verb, rest)) => (verb.to_ascii_uppercase(), rest.trim()), + None => (upper.clone(), ""), + }; + match (verb.as_str(), rest.is_empty()) { + ("ATTACH", true) => Some(Task::done(Message::AttachPick)), + ("XATTACH", true) => Some(Task::done(Message::XAttachPick)), + // A file named on the command line skips the picker. + // Handled at once, so the placement it starts belongs to this + // command line (automation included). + ("ATTACH", false) => Some(self.update(Message::AttachPickResult(Ok( + std::path::PathBuf::from(rest.trim_matches('"')), + )))), + ("XATTACH", false) => { + self.open_xref_attach_dialog(std::path::PathBuf::from(rest.trim_matches('"'))); + Some(Task::none()) + } + // XREF opens the External References palette; -XREF asks on the + // command line. Both keep their argument forms for scripts. + ("XREF", true) | ("XREFSHOW", true) => { + if !self.show_external_references { + return self.dispatch_blocks("EXTERNALREFERENCES", i); + } + self.refresh_xref_manager(); + Some(self.finish_dispatch(cmd)) + } + ("-XREF", true) => { + let saved: Vec<(String, String)> = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .map(|br| (br.name.clone(), br.xref_path.clone())) + .collect(); + let command = crate::modules::insert::xref_cmd::XrefCommand::new(saved); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + ("XREFLIST", _) => { + self.list_xrefs(i, if rest.is_empty() { "*" } else { rest }); + Some(self.finish_dispatch(cmd)) + } + ("XREFATTACHFILE", false) | ("XREFOVERLAYFILE", false) => { + let request = XrefAttachRequest { + path: rest.trim_matches('"').to_string(), + overlay: verb == "XREFOVERLAYFILE", + // The reference default (REFPATHTYPE 1): relative once the + // drawing is saved, the full path before that. + path_type: Pathtype::Relative, + }; + self.start_xref_attach(i, request, XrefPlacement::on_screen(), "-XREF"); + Some(Task::none()) + } + _ => None, + } + } + + /// `-XREF ?` — the reference table as the reference application prints it. + fn list_xrefs(&mut self, i: usize, pattern: &str) { + let rows: Vec<(String, &'static str, String)> = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .filter(|br| { + pattern == "*" || crate::io::xref_model::wildcard_match(&br.name, pattern) + }) + .map(|br| { + let kind = if br.flags.is_xref_overlay { "Overlay" } else { "Attach" }; + (br.name.clone(), kind, display_path(&br.xref_path)) + }) + .collect(); + let out = &mut self.command_line; + out.push_output(""); + out.push_output(&format!( + "{:<34}{:<13}{}", + "Xref name", + "Xref Type", + "Path" + )); + out.push_output(&format!("{:<34}{:<13}{}", "----------------------", "---------", "----------")); + out.push_output(""); + for (name, kind, path) in &rows { + out.push_output(&format!("{:<34}{:<13}{}", format!("\"{name}\""), *kind, path)); + } + out.push_output(""); + out.push_output(&format!("Total Xref(s): {}", rows.len())); + } + + /// Checks the file, reports it the way the reference does, then asks + /// for whatever is left on-screen (or commits straight away). + pub(in crate::app) fn start_xref_attach( + &mut self, + i: usize, + request: XrefAttachRequest, + placement: XrefPlacement, + label: &'static str, + ) { + let name = path_to_block_name(&request.path); + let full = crate::modules::insert::xattach::resolve_xref_store_path(&request.path, None); + let host = self.tabs[i].current_path.clone(); + if let Some(host) = host.as_deref() { + if crate::modules::insert::xattach::is_self_attach(host, &full, host.parent()) { + self.command_line.push_error( + "Error: Possible circular reference to current drawing. *Invalid*", + ); + return; + } + } + let existing = existing_reference(&self.tabs[i].scene, &name); + if let Some(existing) = existing.as_ref() { + self.command_line + .push_output(&format!("Xref \"{}\" has already been defined.", existing)); + self.command_line + .push_output("Using existing definition."); + } else { + let verb = if request.overlay { + format!("Overlay Xref \"{}\": {}", name, display_path(&full)) + } else { + format!("Attach Xref \"{}\": {}", name, display_path(&full)) + }; + self.command_line.push_output(&verb); + if !std::path::Path::new(&full).is_file() { + let file = std::path::Path::new(&full.replace('\\', "/")) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or(full.clone()); + self.command_line + .push_output(&format!("\"{}\" cannot be found.", file)); + self.command_line.push_error("*Invalid*"); + return; + } + self.command_line + .push_output(&format!("\"{}\" loaded.", name)); + } + let command = XAttachCommand::new(request.clone(), placement, label); + if let Some(entity) = command.immediate() { + self.commit_xref_attach(i, request, entity); + return; + } + let (wires, source_units) = self.xref_preview(i, existing.as_deref(), &full); + let host_units = self.tabs[i].scene.document.header.insertion_units; + let unit = crate::app::properties::insert_unit_scale(host_units, source_units).unwrap_or(1.0); + let command = command.with_preview(wires, unit); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + /// The reference's geometry for the placement ghost, in its own units: + /// an existing definition's content, or the file's model space. + fn xref_preview( + &self, + i: usize, + existing: Option<&str>, + path: &str, + ) -> (Vec, i16) { + // ponytail: a huge reference skips the ghost; cap by entity count. + #[cfg(not(target_arch = "wasm32"))] + const MAX_PREVIEW_ENTITIES: usize = 20_000; + let scene = &self.tabs[i].scene; + if let Some(name) = existing { + let units = scene + .document + .block_records + .get(name) + .map(|br| br.units) + .unwrap_or(0); + return (scene.block_preview_wires(name), units); + } + #[cfg(not(target_arch = "wasm32"))] + if let Ok(source) = crate::io::load_file(std::path::Path::new(path)) { + let owner = source.header.model_space_block_handle; + let entities: Vec = source + .entities() + .filter(|e| e.common().owner_handle == owner) + .filter(|e| { + !matches!( + e, + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) + }) + .take(MAX_PREVIEW_ENTITIES + 1) + .cloned() + .collect(); + if entities.len() <= MAX_PREVIEW_ENTITIES { + return ( + scene.wires_for_entities(&entities), + source.header.insertion_units, + ); + } + } + let _ = path; + (Vec::new(), 0) + } + + /// Creates (or reuses) the definition and commits its INSERT — one undo + /// step for both, so undo leaves no definition behind. + pub(in crate::app) fn commit_xref_attach( + &mut self, + i: usize, + request: XrefAttachRequest, + mut entity: acadrust::EntityType, + ) { + let pending = self.begin_undo(i, "XATTACH", 1, false); + let host = self.tabs[i].current_path.clone(); + let name = prepare_xref_definition(&mut self.tabs[i].scene, &request, host.as_deref()); + if host.is_none() && request.path_type == Pathtype::Relative { + self.tabs[i].xref_relative_on_save.insert(name.clone()); + } + if let acadrust::EntityType::Insert(insert) = &mut entity { + insert.block_name = name; + } + // Resolving merged the reference's layers and linetypes — mirror + // them into the Layers panel and ribbon dropdowns now (#407). + self.refresh_layer_panel(); + let _ = self.commit_entity_handle(entity); + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.refresh_xref_manager(); + } + + /// Opens the Attach External Reference dialog for `path`. + pub(in crate::app) fn open_xref_attach_dialog(&mut self, path: std::path::PathBuf) { + let i = self.active_tab; + let existing: Vec<(String, String)> = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .map(|br| (br.name.clone(), br.xref_path.clone())) + .collect(); + let mut state = XrefAttachState::new(existing); + self.load_xref_attach_file(&mut state, &path.to_string_lossy()); + self.xref_attach = Some(state); + self.active_modal = Some(crate::app::ModalKind::XrefAttach); + } + + /// Fills the dialog from the chosen file: name, preview, block unit and + /// the paths shown under Details. + fn load_xref_attach_file(&self, state: &mut XrefAttachState, path: &str) { + #[cfg(not(target_arch = "wasm32"))] + let i = self.active_tab; + state.path = path.to_string(); + state.name = path_to_block_name(path); + state.found_in = display_path(path); + #[cfg(not(target_arch = "wasm32"))] + { + state.preview = crate::io::thumbnail::read_handle(std::path::Path::new(path)); + let host_units = self.tabs[i].scene.document.header.insertion_units; + let source_units = crate::io::load_file(std::path::Path::new(path)) + .map(|doc| doc.header.insertion_units) + .unwrap_or(0); + state.unit_label = crate::t!(crate::app::properties::insunits_name(source_units)) + .into_owned(); + let factor = + crate::app::properties::insert_unit_scale(host_units, source_units).unwrap_or(1.0); + state.unit_factor = crate::app::properties::format_unit_factor(factor); + } + self.refresh_xref_attach_saved_path(state); + } + + fn refresh_xref_attach_saved_path(&self, state: &mut XrefAttachState) { + let host = self.tabs[self.active_tab].current_path.clone(); + let request = XrefAttachRequest { + path: state.path.clone(), + overlay: state.overlay, + path_type: state.path_type.0, + }; + state.saved_path = display_path(&stored_path(&request, host.as_deref())); + } + + /// Messages of the ATTACH picker and the dialog. + pub(in crate::app) fn update_xref_attach(&mut self, message: Message) -> Task { + match message { + Message::AttachPick => Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select Reference File").as_ref()) + .add_filter( + crate::t!("All Reference Files").as_ref(), + &[ + "dwg", "dxf", "pdf", "png", "jpg", "jpeg", "bmp", "tif", "tiff", + ], + ) + .add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"]) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + .add_filter( + crate::t!("Images").as_ref(), + &["png", "jpg", "jpeg", "bmp", "tif", "tiff"], + ) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + Message::AttachPickResult, + ), + Message::AttachPickResult(Ok(path)) => { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "pdf" => match std::fs::read(&path) { + Ok(bytes) => self.update(Message::PdfAttachPickResult(Ok(( + path, + std::sync::Arc::new(bytes), + )))), + Err(e) => self.update(Message::PdfAttachPickResult(Err(e.to_string()))), + }, + "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => { + let result = image::image_dimensions(&path) + .map(|(w, h)| (path.clone(), w, h)) + .map_err(|e| e.to_string()); + self.update(Message::ImagePickResult(result)) + } + _ => { + self.open_xref_attach_dialog(path); + Task::none() + } + } + } + Message::AttachPickResult(Err(e)) => { + if e != "Cancelled" { + self.command_line + .push_error(format!("ATTACH: {e}").as_ref()); + } + Task::none() + } + Message::XrefAttachBrowseResult(Ok(path)) => { + if let Some(mut state) = self.xref_attach.take() { + self.load_xref_attach_file(&mut state, &path.to_string_lossy()); + self.xref_attach = Some(state); + } + Task::none() + } + Message::XrefAttachBrowseResult(Err(_)) => Task::none(), + Message::XrefAttach(edit) => self.edit_xref_attach(edit), + _ => Task::none(), + } + } + + fn edit_xref_attach(&mut self, edit: XrefAttachMsg) -> Task { + let Some(mut state) = self.xref_attach.take() else { + return Task::none(); + }; + let mut task = Task::none(); + match edit { + XrefAttachMsg::Name(name) => { + if let Some((_, saved)) = state + .existing + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(&name)) + .cloned() + { + let host = self.tabs[self.active_tab].current_path.clone(); + let base = host.as_deref().and_then(|h| h.parent()); + let full = crate::modules::insert::xattach::resolve_xref_store_path(&saved, base); + self.load_xref_attach_file(&mut state, &full); + } + } + XrefAttachMsg::Browse => { + task = Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select Reference File").as_ref()) + .add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"]) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + Message::XrefAttachBrowseResult, + ); + } + XrefAttachMsg::Overlay(overlay) => state.overlay = overlay, + XrefAttachMsg::ScaleOnScreen(on) => state.scale_on_screen = on, + XrefAttachMsg::Scale(axis, value) => { + if axis == 0 && state.uniform { + state.scale = [value.clone(), value.clone(), value]; + } else if let Some(slot) = state.scale.get_mut(axis) { + *slot = value; + } + } + XrefAttachMsg::Uniform(on) => { + state.uniform = on; + if on { + let x = state.scale[0].clone(); + state.scale = [x.clone(), x.clone(), x]; + } + } + XrefAttachMsg::InsertOnScreen(on) => state.insert_on_screen = on, + XrefAttachMsg::Insert(axis, value) => { + if let Some(slot) = state.insert.get_mut(axis) { + *slot = value; + } + } + XrefAttachMsg::PathType(choice) => { + state.path_type = choice; + self.refresh_xref_attach_saved_path(&mut state); + } + XrefAttachMsg::RotationOnScreen(on) => state.rotation_on_screen = on, + XrefAttachMsg::Rotation(value) => state.rotation = value, + XrefAttachMsg::Details(on) => state.details = on, + XrefAttachMsg::Help => { + self.command_line.push_info( + crate::t!("Inserts references to external files such as other drawings, raster images, and underlays.") + .as_ref(), + ); + } + XrefAttachMsg::Apply => { + let placement = XrefPlacement { + insert: (!state.insert_on_screen) + .then(|| state.insert_point()) + .flatten(), + scale: (!state.scale_on_screen).then(|| state.scale_values()).flatten(), + rotation: (!state.rotation_on_screen) + .then(|| crate::entities::common::parse_typed_angle(&state.rotation)) + .flatten() + .map(f64::to_degrees), + }; + let invalid = (!state.insert_on_screen && placement.insert.is_none()) + || (!state.scale_on_screen + && placement + .scale + .is_none_or(|s| s.iter().any(|v| *v == 0.0))) + || (!state.rotation_on_screen && placement.rotation.is_none()); + if invalid || state.path.is_empty() { + self.command_line + .push_error(crate::t!("Invalid input.").as_ref()); + self.xref_attach = Some(state); + return Task::none(); + } + let request = XrefAttachRequest { + path: state.path.clone(), + overlay: state.overlay, + path_type: state.path_type.0, + }; + self.active_modal = None; + let i = self.active_tab; + self.start_xref_attach(i, request, placement, "XATTACH"); + return Task::none(); + } + } + self.xref_attach = Some(state); + task + } +} diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index d6bdecaad..105bca131 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -52,6 +52,7 @@ pub(super) const NAMES: &[&str] = &[ "pdf_page_select", "ribbon_tab", "ribbon_dropdown", + "dialog_ok", "close_document", "toggle_properties", "toggle_layers", @@ -705,6 +706,14 @@ impl OpenCADStudio { _ => return Err(failure("no_dialog", "No PDF dialog is open")), }) } + // The open dialog's OK button. + "dialog_ok" => match self.active_modal { + Some(crate::app::ModalKind::XrefAttach) => Message::XrefAttach( + crate::ui::window::xref_attach::XrefAttachMsg::Apply, + ), + Some(crate::app::ModalKind::BlockDefinition) => Message::BlockDefApply, + _ => return Err(failure("no_dialog", "No dialog with an OK button is open")), + }, "close_document" => Message::TabClose(self.active_tab), "toggle_properties" => Message::ToggleProperties, "toggle_layers" => Message::ToggleLayers, diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 32a3b6894..56107916a 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -426,8 +426,10 @@ impl OpenCADStudio { pub(super) fn control_state(&self) -> Value { let tab = &self.tabs[self.active_tab]; let command = tab.active_cmd.as_deref().map(active_command_metadata); + let missing_fonts = crate::io::font_repo::missing_shx_fonts(&tab.scene.document); json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"), "mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled, + "read_only":self.read_only,"missing_fonts":missing_fonts, "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, "hand_seed":format!("{:X}",tab.scene.document.header.handle_seed.max(tab.scene.document.next_handle())), "plugins":plugin_ids(), @@ -442,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"] }) } @@ -467,6 +469,7 @@ impl OpenCADStudio { | "history" | "xdata_get" | "get_selection" + | "audit" ); if !query && !self.control.enabled { return ( @@ -972,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 { @@ -981,7 +1012,7 @@ impl OpenCADStudio { self.queue_native_save( i, path, - acadrust::DxfVersion::AC1032, + version, super::SavePurpose::SaveAs, super::SaveContinuation::None, true, @@ -989,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 diff --git a/src/app/document.rs b/src/app/document.rs index be6004a79..aed22517d 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -231,6 +231,9 @@ pub(super) struct DocumentTab { /// Session set of unloaded reference keys (Task 8b). Owns the set that /// `collect_entries` takes as `unloaded`, so CLI and palette agree. pub(super) xref_unloaded: crate::io::xref_model::UnloadSet, + /// References attached with a relative path while the drawing had no + /// file yet: stored full until the first save, then made relative. + pub(super) xref_relative_on_save: rustc_hash::FxHashSet, /// Load-time mtimes per reference key (Task 8b). Written on every /// palette refresh; `Stale` is detectable from the second refresh on. pub(super) xref_stat_cache: crate::io::xref_model::RefStatCache, @@ -650,6 +653,7 @@ impl DocumentTab { active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, xref_unloaded: crate::io::xref_model::UnloadSet::default(), + xref_relative_on_save: rustc_hash::FxHashSet::default(), xref_stat_cache: crate::io::xref_model::RefStatCache::default(), xref_missing: 0, is_start: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index fb29bb761..56ad8e6e5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -500,6 +500,10 @@ pub(super) struct OpenCADStudio { pdf_import_file: Option, /// The dialog Options was opened from; it comes back when Options closes. options_parent: Option, + /// Working copy of the Attach External Reference dialog; None while closed. + xref_attach: Option, + /// Working copy of the Write Block (WBLOCK) dialog; `None` while it is closed. + wblock: Option, /// Working copy of the structured feature-control-frame editor. geometric_tolerance: Option, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = @@ -1265,6 +1269,15 @@ pub(super) struct OpenCADStudio { /// Missing SHX fonts of the last opened drawing, offered for download /// from the community repository (see `crate::io::font_repo`). missing_fonts: Option>, + /// Drawing that produced `missing_fonts`; the active tab may change while + /// a download is in flight. + missing_fonts_path: Option, + /// Prevent duplicate download tasks and keep modal contents visible while + /// the background request is running. + missing_fonts_downloading: bool, + /// Fonts skipped or unavailable during this process lifetime. Reopening a + /// drawing must not repeatedly nag for the same unresolved file. + suppressed_missing_fonts: rustc_hash::FxHashSet, /// Drawings handed to us by other launches while `opening` was busy. /// `opening` is a single slot that a second `OpenPathPicked` would /// overwrite, and `on_file_opened` drops any result arriving once it is @@ -1882,6 +1895,8 @@ pub enum ModalKind { UnderlayLayers, PdfImportSettings, PdfImportFile, + XrefAttach, + WriteBlock, GeometricTolerance, DraftingSettings, AutoConstrainSettings, @@ -2879,6 +2894,24 @@ pub enum Message { BlockDefConfirmRedefine(bool), BlockDefDismissError, BlockDefHelp, + /// Write Block (WBLOCK) dialog messages + WblockSourceMode(crate::ui::window::wblock::WblockSourceMode), + WblockBlockName(String), + WblockBlockSelect(String), + WblockPickPoint, + WblockBaseX(String), + WblockBaseY(String), + WblockBaseZ(String), + WblockSelectObjects, + WblockQuickSelect, + WblockObjectMode(crate::ui::window::wblock::WblockObjectMode), + WblockFilePath(String), + WblockBrowsePath, + WblockBrowsePathResult(Option), + WblockUnit(i16), + WblockApply, + WblockDismissError, + WblockHelp, /// One structured feature-control-frame field changed. ToleranceDialogField(crate::ui::window::geometric_tolerance::Field), /// One structured feature-control-frame option changed. @@ -3798,6 +3831,14 @@ pub enum Message { XAttachPick, /// Result of the XATTACH file picker. XAttachPickResult(Result), + /// ATTACH: pick a drawing, image or PDF to reference. + AttachPick, + /// Result of the ATTACH file picker. + AttachPickResult(Result), + /// An edit in the Attach External Reference dialog. + XrefAttach(crate::ui::window::xref_attach::XrefAttachMsg), + /// Result of the dialog's Browse picker. + XrefAttachBrowseResult(Result), // ── WBLOCK ──────────────────────────────────────────────────────────── /// Trigger the WBLOCK save dialog for `block_name` (or `*` = selection). WblockSave(String), @@ -3965,6 +4006,8 @@ impl OpenCADStudio { pdf_import_settings: None, pdf_import_file: None, options_parent: None, + xref_attach: None, + wblock: None, geometric_tolerance: None, pick_drag_rect: false, perf_hud: false, @@ -4202,6 +4245,9 @@ impl OpenCADStudio { open_job_serial: 0, recovery_report: None, missing_fonts: None, + missing_fonts_path: None, + missing_fonts_downloading: false, + suppressed_missing_fonts: rustc_hash::FxHashSet::default(), pending_opens: std::collections::VecDeque::new(), active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 66ff82bcc..e4d293e22 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2710,6 +2710,13 @@ impl crate::command::CadCommand for PluginInteractiveAdapter { .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel) } + fn on_preview_wires(&mut self, pt: glam::DVec3) -> Vec { + let raw_wires = crate::plugin::guard("InteractiveCommand::on_cursor_move", || { + self.inner.on_cursor_move([pt.x as f64, pt.y as f64, pt.z as f64]) + }) + .unwrap_or_default(); + convert_preview_wires(raw_wires) + } } /// Bridges an out-of-process plugin's interactive command to the host's @@ -2777,7 +2784,12 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { .interactive_event(self.command_id, InteractiveEvent::Point([pt.x, pt.y, pt.z])) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); - if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) { + if matches!( + result, + crate::command::CmdResult::CommitAndExit(_) + | crate::command::CmdResult::CommitEntitiesAndExit(_) + | crate::command::CmdResult::Cancel + ) { self.is_done = true; } self.refresh(); @@ -2790,7 +2802,12 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { .interactive_event(self.command_id, InteractiveEvent::Enter) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); - if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) { + if matches!( + result, + crate::command::CmdResult::CommitAndExit(_) + | crate::command::CmdResult::CommitEntitiesAndExit(_) + | crate::command::CmdResult::Cancel + ) { self.is_done = true; } self.refresh(); @@ -2816,12 +2833,101 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { ) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); - if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) { + if matches!( + result, + crate::command::CmdResult::CommitAndExit(_) + | crate::command::CmdResult::CommitEntitiesAndExit(_) + | crate::command::CmdResult::Cancel + ) { self.is_done = true; } self.refresh(); result } + fn on_preview_wires(&mut self, pt: glam::DVec3) -> Vec { + let raw_wires = self + .process + .on_cursor_move(self.command_id, [pt.x as f64, pt.y as f64, pt.z as f64]) + .unwrap_or_default(); + convert_preview_wires(raw_wires) + } +} + +fn convert_preview_wires( + raw_wires: Vec, +) -> Vec { + use ocs_plugin_api::host::PreviewWire; + use crate::scene::model::wire_model::{TangentGeom, WireModel}; + + raw_wires + .into_iter() + .filter_map(|w| match w { + PreviewWire::Polyline { points, color } => { + if points.len() < 2 { + return None; + } + let c = color.unwrap_or(WireModel::CYAN); + Some(WireModel::solid_f64("rubber_band".into(), points, c, false)) + } + PreviewWire::Circle { center, radius, color } => { + if radius <= 0.0 || !radius.is_finite() { + return None; + } + let c = color.unwrap_or(WireModel::CYAN); + // Two opposite diameter points ensure OCS's viewport frame-cache + // (render_signature) detects cursor motion and triggers 60-120 FPS + // redraws without requiring polar snap or artificial guide lines. + let points = vec![ + [center[0] - radius, center[1], center[2]], + [center[0] + radius, center[1], center[2]], + ]; + let mut wire = WireModel::solid_f64("rubber_band".into(), points, c, false); + wire.tangent_geoms.push(TangentGeom::PlanarCircle { + center, + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius, + }); + Some(wire) + } + PreviewWire::Arc { + center, + radius, + start_angle_rad, + end_angle_rad, + color, + } => { + if radius <= 0.0 || !radius.is_finite() { + return None; + } + let c = color.unwrap_or(WireModel::CYAN); + // Start and end points along the circumference ensure OCS's viewport + // frame-cache (render_signature) detects cursor motion and triggers + // 60-120 FPS redraws without requiring polar snap or artificial guide lines. + let p_start = [ + center[0] + radius * start_angle_rad.cos(), + center[1] + radius * start_angle_rad.sin(), + center[2], + ]; + let p_end = [ + center[0] + radius * end_angle_rad.cos(), + center[1] + radius * end_angle_rad.sin(), + center[2], + ]; + let points = vec![p_start, p_end]; + let mut wire = WireModel::solid_f64("rubber_band".into(), points, c, false); + wire.tangent_geoms.push(TangentGeom::Arc { + center, + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius, + start_angle: start_angle_rad, + end_angle: end_angle_rad, + }); + Some(wire) + } + }) + .collect() } fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::command::CmdResult { @@ -2830,7 +2936,9 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm match step { CommandStep::NeedPoint => CmdResult::NeedPoint, CommandStep::Commit(e) => CmdResult::CommitEntity(e), + CommandStep::CommitMany(es) => CmdResult::CommitEntities(es), CommandStep::CommitAndEnd(e) => CmdResult::CommitAndExit(e), + CommandStep::CommitManyAndEnd(es) => CmdResult::CommitEntitiesAndExit(es), CommandStep::Done | CommandStep::Cancel => CmdResult::Cancel, } } @@ -9226,6 +9334,140 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) ); } + /// A plugin command that previews a rubber-band line to the cursor. + struct PreviewLine { + start: [f64; 3], + color: Option<[f32; 4]>, + } + impl ocs_plugin_api::host::InteractiveCommand for PreviewLine { + fn prompt(&self) -> String { + "Pick endpoint".to_string() + } + fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep { + ocs_plugin_api::host::CommandStep::Done + } + fn on_cursor_move(&mut self, pt: [f64; 3]) -> Vec { + vec![ocs_plugin_api::host::PreviewWire::line(self.start, pt, self.color)] + } + } + + /// A plugin command that previews an analytical circle. + struct PreviewCircle { + center: [f64; 3], + color: Option<[f32; 4]>, + } + impl ocs_plugin_api::host::InteractiveCommand for PreviewCircle { + fn prompt(&self) -> String { + "Pick radius".to_string() + } + fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep { + ocs_plugin_api::host::CommandStep::Done + } + fn on_cursor_move(&mut self, pt: [f64; 3]) -> Vec { + let dx = pt[0] - self.center[0]; + let dy = pt[1] - self.center[1]; + let r = (dx * dx + dy * dy).sqrt(); + vec![ocs_plugin_api::host::PreviewWire::circle(self.center, r, self.color)] + } + } + + /// A plugin command that previews an analytical arc. + struct PreviewArc { + center: [f64; 3], + color: Option<[f32; 4]>, + } + impl ocs_plugin_api::host::InteractiveCommand for PreviewArc { + fn prompt(&self) -> String { + "Pick endpoint".to_string() + } + fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep { + ocs_plugin_api::host::CommandStep::Done + } + fn on_cursor_move(&mut self, pt: [f64; 3]) -> Vec { + let dx = pt[0] - self.center[0]; + let dy = pt[1] - self.center[1]; + let r = (dx * dx + dy * dy).sqrt(); + vec![ocs_plugin_api::host::PreviewWire::arc( + self.center, + r, + 0.0, + std::f64::consts::PI, + self.color, + )] + } + } + + #[test] + fn plugin_cursor_move_preview_wires() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + + // 1. Polyline default color (None -> CYAN) + { + let mut host = HostSession::new(&mut app, 0); + host.start_interactive(Box::new(PreviewLine { start: [0.0, 0.0, 0.0], color: None })); + } + assert!(app.tabs[0].active_cmd.is_some()); + let cmd = app.tabs[0].active_cmd.as_mut().unwrap(); + let wires = cmd.on_preview_wires(glam::DVec3::new(10.0, 20.0, 0.0)); + assert_eq!(wires.len(), 1); + assert_eq!(wires[0].name, "rubber_band"); + assert_eq!(wires[0].color, crate::scene::model::wire_model::WireModel::CYAN); + + // 2. Polyline custom color ([1.0, 0.0, 0.0, 1.0]) + { + let mut host = HostSession::new(&mut app, 0); + host.start_interactive(Box::new(PreviewLine { start: [0.0, 0.0, 0.0], color: Some([1.0, 0.0, 0.0, 1.0]) })); + } + let cmd2 = app.tabs[0].active_cmd.as_mut().unwrap(); + let wires2 = cmd2.on_preview_wires(glam::DVec3::new(10.0, 20.0, 0.0)); + assert_eq!(wires2.len(), 1); + assert_eq!(wires2[0].color, [1.0, 0.0, 0.0, 1.0]); + + // 3. Analytical Circle with custom color + { + let mut host = HostSession::new(&mut app, 0); + host.start_interactive(Box::new(PreviewCircle { + center: [5.0, 5.0, 0.0], + color: Some([0.0, 1.0, 0.0, 1.0]), + })); + } + let cmd3 = app.tabs[0].active_cmd.as_mut().unwrap(); + let circle_wires = cmd3.on_preview_wires(glam::DVec3::new(15.0, 5.0, 0.0)); + assert_eq!(circle_wires.len(), 1); + assert_eq!(circle_wires[0].points.len(), 2); + assert_eq!(circle_wires[0].color, [0.0, 1.0, 0.0, 1.0]); + assert_eq!(circle_wires[0].tangent_geoms.len(), 1); + assert!(matches!( + circle_wires[0].tangent_geoms[0], + crate::scene::model::wire_model::TangentGeom::PlanarCircle { radius, .. } if (radius - 10.0).abs() < 1e-6 + )); + let instances = crate::scene::pipeline::circle_gpu::extract_circle_instances(&circle_wires[0], 0.0); + assert!(instances.is_some()); + assert_eq!(instances.unwrap().len(), 1); + + // 4. Analytical Arc + { + let mut host = HostSession::new(&mut app, 0); + host.start_interactive(Box::new(PreviewArc { + center: [0.0, 0.0, 0.0], + color: None, + })); + } + let cmd4 = app.tabs[0].active_cmd.as_mut().unwrap(); + let arc_wires = cmd4.on_preview_wires(glam::DVec3::new(10.0, 0.0, 0.0)); + assert_eq!(arc_wires.len(), 1); + assert_eq!(arc_wires[0].points.len(), 2); + assert_eq!(arc_wires[0].tangent_geoms.len(), 1); + assert!(matches!( + arc_wires[0].tangent_geoms[0], + crate::scene::model::wire_model::TangentGeom::Arc { radius, .. } if (radius - 10.0).abs() < 1e-6 + )); + let arc_instances = crate::scene::pipeline::circle_gpu::extract_circle_instances(&arc_wires[0], 0.0); + assert!(arc_instances.is_some()); + assert_eq!(arc_instances.unwrap().len(), 1); + } + /// A plugin command that picks an existing object, then marks it. struct PickThenMark; impl ocs_plugin_api::host::InteractiveCommand for PickThenMark { diff --git a/src/app/properties.rs b/src/app/properties.rs index bfef367ec..ce453cadc 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1478,6 +1478,25 @@ impl OpenCADStudio { }, ); } + if let Some(br) = doc + .block_records + .get(&ins.block_name) + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + { + let base_dir = self.tabs[i] + .current_path + .as_deref() + .and_then(|p| p.parent()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let overrides = crate::io::xref::layer_overrides( + doc, + &br.name, + &br.xref_path, + &base_dir, + ); + xref_rows(&mut sections, ins, &br.xref_path, factor, overrides); + } } // Underlay: name, page, path and size from the definition. acadrust::EntityType::Underlay(ul) => { @@ -3713,6 +3732,69 @@ fn merge_prop_value( /// Set the first property row matching `field` (across all sections) to a /// read-only `value`. No-op when the field is absent. Used to fill the /// doc-dependent placeholder rows the entity builders leave empty. +/// An external reference's rows as the reference application lists them: +/// Insertion point X/Y/Z, Scale X/Y/Z without the unit conversion the INSERT +/// carries (no uniform-scale or Annotative rows), Saved Path after the name +/// and Layer property overrides last. +fn xref_rows( + sections: &mut [crate::scene::model::object::PropSection], + ins: &acadrust::entities::Insert, + saved_path: &str, + factor: f64, + overrides: bool, +) { + use crate::entities::common::{edit_prop, ro_prop}; + let factor = if factor.abs() > 0.0 { factor } else { 1.0 }; + for section in sections.iter_mut() { + for prop in section.props.iter_mut() { + let label = match prop.field { + "ins_x" => "Insertion point X", + "ins_y" => "Insertion point Y", + "ins_z" => "Insertion point Z", + _ => continue, + }; + prop.label = t!(label).into_owned(); + } + let scale_at = section.props.iter().position(|p| { + matches!(p.field, "x_scale" | "y_scale" | "z_scale" | "u_scale" | "ins_uniform") + }); + section.props.retain(|p| { + !matches!( + p.field, + "x_scale" | "y_scale" | "z_scale" | "u_scale" | "ins_uniform" | "annotative" + ) + }); + if let Some(at) = scale_at { + let at = at.min(section.props.len()); + section.props.splice( + at..at, + [ + edit_prop(t!("Scale X").as_ref(), "xref_x_scale", ins.x_scale() / factor), + edit_prop(t!("Scale Y").as_ref(), "xref_y_scale", ins.y_scale() / factor), + edit_prop(t!("Scale Z").as_ref(), "xref_z_scale", ins.z_scale() / factor), + ], + ); + } + if let Some(at) = section.props.iter().position(|p| p.field == "block") { + section.props.insert( + at + 1, + ro_prop( + t!("Saved Path").as_ref(), + "xref_saved_path", + crate::modules::insert::xref_cmd::display_path(saved_path), + ), + ); + } + if section.props.iter().any(|p| p.field == "unit_factor") { + section.props.push(ro_prop( + t!("Layer property overrides").as_ref(), + "xref_layer_overrides", + if overrides { t!("Yes") } else { t!("No") }.into_owned(), + )); + } + } +} + fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &str, value: String) { for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == field) { @@ -4103,7 +4185,7 @@ fn dim_lineweight_label(dimlwd: i16) -> String { } /// Human-readable INSUNITS name (DXF group 70 unit codes). -fn insunits_name(code: i16) -> &'static str { +pub(in crate::app) fn insunits_name(code: i16) -> &'static str { match code { 1 => "Inches", 2 => "Feet", @@ -4135,7 +4217,7 @@ fn insunits_name(code: i16) -> &'static str { } /// Unit-conversion scale for a new INSERT. -fn insert_unit_scale(host_units: i16, src_units: i16) -> Option { +pub(in crate::app) fn insert_unit_scale(host_units: i16, src_units: i16) -> Option { let host_mm = insunits_to_mm(host_units)?; let src_mm = insunits_to_mm(src_units)?; let ratio = src_mm / host_mm; @@ -4145,7 +4227,7 @@ fn insert_unit_scale(host_units: i16, src_units: i16) -> Option { Some(ratio) } -fn format_unit_factor(factor: f64) -> String { +pub(in crate::app) fn format_unit_factor(factor: f64) -> String { let magnitude = factor.abs(); if magnitude > 0.0 && !(1.0e-4..1.0e7).contains(&magnitude) { format!("{factor:.4e}") diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 68381b955..8fa8c4f6d 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1163,6 +1163,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.reset_modal_geometry(); if self.block_definition.is_some() { self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + } else if self.wblock.is_some() { + self.active_modal = Some(crate::app::ModalKind::WriteBlock); } return Task::none(); } @@ -3547,6 +3549,46 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + } else if let Some(axis) = match field { + "xref_x_scale" => Some(0), + "xref_y_scale" => Some(1), + "xref_z_scale" => Some(2), + _ => None, + } { + // An xref's Scale rows show the scale without the + // unit conversion its INSERT carries; write it back + // with that conversion. + let value = val + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v != 0.0); + if let Some(value) = value { + let host = self.tabs[i].scene.document.header.insertion_units; + for &handle in &handles { + let factor = match self.tabs[i].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Insert(ins)) => self.tabs[i] + .scene + .document + .block_records + .get(&ins.block_name) + .and_then(|br| { + crate::app::properties::insert_unit_scale(host, br.units) + }) + .unwrap_or(1.0), + _ => continue, + }; + if let Some(acadrust::EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + match axis { + 0 => ins.set_x_scale(value * factor), + 1 => ins.set_y_scale(value * factor), + _ => ins.set_z_scale(value * factor), + } + } + } + } } else if field == "block" { // Name row on a block reference: an existing name // re-points the selected inserts; a new one renames diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 02058be7d..bca1aa378 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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; @@ -1911,12 +1926,22 @@ impl OpenCADStudio { // fetch them from the community repository before the user // studies garbled substitute text (unless recovery already // owns the modal slot). - let missing = crate::io::font_repo::missing_shx_fonts( + let mut missing = crate::io::font_repo::missing_shx_fonts( &self.tabs[i].scene.document, ); - if !missing.is_empty() && self.check_missing_fonts { + missing.retain(|name| { + !self + .suppressed_missing_fonts + .contains(&crate::io::font_repo::font_key(name)) + }); + // Read-only/MCP evaluation sessions must remain non-blocking: + // report missing fonts through control state, but never offer + // a network/download mutation from a read-only launch. + if !missing.is_empty() && self.check_missing_fonts && !self.read_only { self.font_source_input = self.font_source_url.clone(); self.missing_fonts = Some(missing); + self.missing_fonts_path = self.tabs[i].current_path.clone(); + self.missing_fonts_downloading = false; self.active_modal = Some(crate::app::ModalKind::MissingFonts); } } @@ -2139,7 +2164,8 @@ impl OpenCADStudio { } let mut recent_task = Task::none(); - let saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) { + let document = self.tabs[i].scene.document_for_save(); + let saved = match crate::io::save_to_bytes(&document, &ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); let cache_name = std::path::Path::new(&filename) @@ -2337,6 +2363,15 @@ impl OpenCADStudio { }); let clone_started = iced::time::Instant::now(); let mut snapshot = self.tabs[i].scene.document_for_save(); + // First save: references attached with a relative path type while + // the drawing had no file become relative to it. + if self.tabs[i].current_path.is_none() && purpose != crate::app::SavePurpose::Autosave { + crate::io::xref::make_relative( + &mut snapshot, + &self.tabs[i].xref_relative_on_save, + &path, + ); + } // Save-As across folders: rebase relative reference paths onto the // new base dir inside the snapshot only (live strings are untouched). if purpose == crate::app::SavePurpose::SaveAs { @@ -2639,6 +2674,14 @@ impl OpenCADStudio { } if outcome.set_current_path { let old_path = self.tabs[i].current_path.clone(); + if old_path.is_none() { + let pending = std::mem::take(&mut self.tabs[i].xref_relative_on_save); + crate::io::xref::make_relative( + &mut self.tabs[i].scene.document, + &pending, + &outcome.path, + ); + } self.tabs[i].current_path = Some(outcome.path.clone()); self.tabs[i].scene.document.version = outcome.version; if outcome.purpose == crate::app::SavePurpose::SaveAs { @@ -5617,6 +5660,9 @@ mod plot_paper_tests { app.automation_op(r#"{"op":"new"}"#); let _ = app.update(Message::LayoutSwitch("Layout1".into())); let i = app.active_tab; + // Millimetre drawing units, so a ratio plots as written; the metre + // case has its own test. + app.tabs[i].scene.document.header.insertion_units = 4; let mut ps = app.tabs[i] .scene .plot_settings_for("Layout1") diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6835fe512..bd1cf204f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -219,6 +219,8 @@ impl OpenCADStudio { Some(UnderlayLayers) => self.underlay_layers = None, Some(PdfImportSettings) => self.pdf_import_settings = None, Some(PdfImportFile) => self.pdf_import_file = None, + Some(XrefAttach) => self.xref_attach = None, + Some(WriteBlock) => self.wblock = None, Some(Hyperlink) => { self.hyperlink_editor_handles.clear(); self.hyperlink_editor_url.clear(); @@ -356,6 +358,14 @@ impl OpenCADStudio { } } } + if self.active_modal == Some(super::ModalKind::WriteBlock) { + if let Some(ref mut state) = self.wblock { + if state.error_message.is_some() { + state.error_message = None; + return Task::none(); + } + } + } return self.update(Message::CloseModal); } if self.active_modal == Some(super::ModalKind::BlockDefinition) { @@ -372,6 +382,13 @@ impl OpenCADStudio { } } } + if self.active_modal == Some(super::ModalKind::WriteBlock) { + if matches!(msg, Message::CommandFinalize) + || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")) + { + return self.update(Message::WblockApply); + } + } if is_modal_blocked_key_msg(&msg) { return Task::none(); } @@ -1191,13 +1208,20 @@ impl OpenCADStudio { Task::none() } Message::MissingFontsDownload => { + if self.missing_fonts_downloading { + return Task::none(); + } // Remember the source across sessions — and across drawings. let source = self.font_source_input.trim().to_string(); if self.font_source_url != source { self.font_source_url = source.clone(); self.save_config(); } - let fonts = self.missing_fonts.take().unwrap_or_default(); + let fonts = self.missing_fonts.clone().unwrap_or_default(); + if fonts.is_empty() { + return Task::none(); + } + self.missing_fonts_downloading = true; Task::perform( async move { let source = crate::io::font_repo::FontSource::from_url(&source); @@ -1207,31 +1231,66 @@ impl OpenCADStudio { ) } Message::MissingFontsDismiss => { - self.missing_fonts = None; + if self.missing_fonts_downloading { + return Task::none(); + } + for name in self.missing_fonts.take().unwrap_or_default() { + self.suppressed_missing_fonts + .insert(crate::io::font_repo::font_key(&name)); + } + self.missing_fonts_path = None; self.close_active_modal(); Task::none() } Message::MissingFontsResult(result) => { - self.missing_fonts = None; - self.close_active_modal(); + self.missing_fonts_downloading = false; match result { Ok(pairs) if pairs.is_empty() => { + let unavailable = self.missing_fonts.take().unwrap_or_default(); + for name in &unavailable { + self.suppressed_missing_fonts + .insert(crate::io::font_repo::font_key(name)); + } + self.missing_fonts_path = None; + self.close_active_modal(); self.command_line.push_error(crate::t!( - "None of the missing fonts are in the community repository yet. Contribute them at github.com/huaninstratech/OpenCADStudio/tree/main/fonts." + "None of the requested fonts are available from the selected source. Substitute fonts remain active; this notice will not repeat during this session." ).as_ref()); } Ok(pairs) => { + let requested = self.missing_fonts.take().unwrap_or_default(); + let downloaded: rustc_hash::FxHashSet = pairs + .iter() + .map(|(name, _)| crate::io::font_repo::font_key(name)) + .collect(); + let unavailable: Vec = requested + .into_iter() + .filter(|name| { + !downloaded.contains(&crate::io::font_repo::font_key(name)) + }) + .collect(); + for name in &unavailable { + self.suppressed_missing_fonts + .insert(crate::io::font_repo::font_key(name)); + } for (name, path) in &pairs { self.command_line.push_output(crate::tf!( "FONT Downloaded {name} → {path}", path = path.display() ).as_ref()); } + if !unavailable.is_empty() { + self.command_line.push_info(crate::tf!( + "FONT Not available from the selected source: {fonts}. Substitute fonts remain active.", + fonts = unavailable.join(", ") + ).as_ref()); + } // The downloaded files change glyph resolution for the // whole drawing — reload it through the standard open // pipeline so every wire is rebuilt with the real fonts. - let i = self.active_tab; - if let Some(path) = self.tabs[i].current_path.clone() { + let path = self.missing_fonts_path.take(); + self.close_active_modal(); + if let Some(path) = path { return Task::done(Message::OpenExternal(path)); } self.command_line.push_info(crate::t!( @@ -1239,6 +1298,8 @@ impl OpenCADStudio { ).as_ref()); } Err(e) => { + // Keep the prompt open: the user can correct a custom + // source or retry a transient network failure. self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref()); } } @@ -1332,15 +1393,13 @@ impl OpenCADStudio { ), Message::XAttachPickResult(Ok(path)) => { - use crate::command::CadCommand; - use crate::modules::insert::xattach::XAttachCommand; - let path_str = path.to_string_lossy().into_owned(); - let cmd = XAttachCommand::with_path(path_str); - let i = self.active_tab; - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.open_xref_attach_dialog(path); Task::none() } + message @ (Message::AttachPick + | Message::AttachPickResult(_) + | Message::XrefAttach(_) + | Message::XrefAttachBrowseResult(_)) => self.update_xref_attach(message), Message::XAttachPickResult(Err(e)) => { if e != "Cancelled" { @@ -4935,6 +4994,294 @@ impl OpenCADStudio { } self.commit_block_definition(false) } + Message::WblockSourceMode(mode) => { + if let Some(state) = self.wblock.as_mut() { + state.source_mode = mode; + } + Task::none() + } + Message::WblockBlockName(name) => { + if let Some(state) = self.wblock.as_mut() { + state.block_name = name; + } + Task::none() + } + Message::WblockBlockSelect(name) => { + if let Some(state) = self.wblock.as_mut() { + state.block_name = name.clone(); + let trimmed = name.trim(); + if !trimmed.is_empty() { + let current_path = std::path::Path::new(&state.file_path); + let file_stem = current_path.file_stem().and_then(|s| s.to_str()); + let is_default_or_block = file_stem == Some("new_block") + || state.existing_blocks.iter().any(|b| Some(b.as_str()) == file_stem); + if is_default_or_block { + let new_file_name = format!("{}.dwg", trimmed); + if let Some(parent) = current_path.parent() { + if !parent.as_os_str().is_empty() { + state.file_path = parent.join(new_file_name).to_string_lossy().to_string(); + } else { + state.file_path = new_file_name; + } + } else { + state.file_path = new_file_name; + } + } + } + } + Task::none() + } + Message::WblockPickPoint => { + self.active_modal = None; + let cmd = crate::modules::insert::wblock::WblockPickBasePointCommand; + self.command_line.push_info(&cmd.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); + Task::none() + } + Message::WblockBaseX(val) => { + if let Some(state) = self.wblock.as_mut() { + state.base_point_x = val; + } + Task::none() + } + Message::WblockBaseY(val) => { + if let Some(state) = self.wblock.as_mut() { + state.base_point_y = val; + } + Task::none() + } + Message::WblockBaseZ(val) => { + if let Some(state) = self.wblock.as_mut() { + state.base_point_z = val; + } + Task::none() + } + Message::WblockSelectObjects => { + self.active_modal = None; + use crate::modules::draw::select::SelectObjectsCommand; + let cmd = SelectObjectsCommand::plain("WBLOCK", "WBLOCK_OBJECTS_GATHERED"); + self.command_line.push_info(&cmd.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); + Task::none() + } + Message::WblockQuickSelect => { + self.active_modal = None; + self.on_qselect_open() + } + Message::WblockObjectMode(mode) => { + if let Some(state) = self.wblock.as_mut() { + state.object_mode = mode; + } + Task::none() + } + Message::WblockFilePath(path) => { + if let Some(state) = self.wblock.as_mut() { + state.file_path = path; + } + Task::none() + } + Message::WblockBrowsePath => { + let default_name = if let Some(state) = self.wblock.as_ref() { + let p = std::path::Path::new(&state.file_path); + p.file_name() + .and_then(|f| f.to_str()) + .unwrap_or("new_block.dwg") + .to_string() + } else { + "new_block.dwg".to_string() + }; + Task::perform( + async move { + let path = crate::sys::file_dialog() + .set_title(crate::t!("Save Block As").as_ref()) + .set_file_name(&default_name) + .add_filter(crate::t!("DWG Files").as_ref(), &["dwg"]) + .add_filter(crate::t!("DXF Files").as_ref(), &["dxf"]) + .save_file() + .await + .map(|h| crate::sys::handle_path(&h)); + path + }, + |path| Message::WblockBrowsePathResult(path), + ) + } + Message::WblockBrowsePathResult(opt_path) => { + if let Some(path) = opt_path { + if let Some(state) = self.wblock.as_mut() { + state.file_path = path.to_string_lossy().to_string(); + } + } + Task::none() + } + Message::WblockUnit(unit) => { + if let Some(state) = self.wblock.as_mut() { + state.unit = unit; + } + Task::none() + } + Message::WblockDismissError => { + if let Some(state) = self.wblock.as_mut() { + state.error_message = None; + } + Task::none() + } + Message::WblockHelp => { + self.command_line.push_info( + crate::t!("WBLOCK writes objects, a block, or the entire drawing to a new drawing file.").as_ref(), + ); + Task::none() + } + Message::WblockApply => { + let Some(state) = self.wblock.as_mut() else { + return Task::none(); + }; + use crate::ui::window::wblock::{WblockObjectMode, WblockSourceMode}; + match state.source_mode { + WblockSourceMode::Block => { + let name = state.block_name.trim(); + if name.is_empty() { + state.error_message = + Some(crate::t!("Please select or enter a block name.").into_owned()); + return Task::none(); + } + let i = self.active_tab; + if self.tabs[i].scene.document.block_records.get(name).is_none() { + state.error_message = Some( + crate::tf!("Block \"{}\" does not exist in drawing.", name) + .into_owned(), + ); + return Task::none(); + } + } + WblockSourceMode::Objects => { + if state.selected_handles.is_empty() { + state.error_message = Some( + crate::t!("No objects selected. You must select objects to define a block.") + .into_owned(), + ); + return Task::none(); + } + } + WblockSourceMode::EntireDrawing => {} + } + + let path_str = state.file_path.trim().to_string(); + if path_str.is_empty() { + state.error_message = + Some(crate::t!("Please specify a file name and path.").into_owned()); + return Task::none(); + } + + let mut path = std::path::PathBuf::from(path_str); + if path.extension().is_none() { + path.set_extension("dwg"); + } + + let i = self.active_tab; + if let Some(current_path) = self.tabs[i].current_path.as_ref() { + if current_path == &path { + state.error_message = Some( + crate::t!("Cannot write to the current drawing file.").into_owned(), + ); + return Task::none(); + } + } + + let state = self.wblock.take().unwrap(); + self.active_modal = None; + let document = self.tabs[i].scene.document_for_save(); + let source_mode = state.source_mode; + let block_name = state.block_name.trim().to_string(); + let handles = state.selected_handles.clone(); + let base_point = state.parse_base_point(); + let unit = state.unit; + let object_mode = state.object_mode; + + if source_mode == WblockSourceMode::Objects { + match object_mode { + WblockObjectMode::Retain => {} + WblockObjectMode::Delete => { + self.push_undo_snapshot(i, "WBLOCK"); + self.tabs[i].scene.erase_entities(&handles); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.refresh_properties(); + } + WblockObjectMode::Convert => { + let block_name_for_conv = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("WBLOCK") + .to_string(); + self.push_undo_snapshot(i, "WBLOCK"); + let ucs = self.tabs[i].ucs_xform(); + let world_to_block = ucs.to_ucs_transform_at(base_point); + let block_to_world = ucs.to_wcs_transform_at(base_point); + let options = crate::scene::CreateBlockOptions { + name: block_name_for_conv, + handles: handles.clone(), + base_point, + world_to_block, + block_to_world, + mode: crate::ui::window::block_definition::BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit, + description: String::new(), + hyperlink_url: String::new(), + hyperlink_desc: String::new(), + redefine: true, + }; + let _ = self.tabs[i].scene.create_block_with_options(options); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.refresh_properties(); + } + } + } + + let worker_path = path.clone(); + let display_name = match source_mode { + WblockSourceMode::Block => block_name.clone(), + WblockSourceMode::EntireDrawing => "*".to_string(), + WblockSourceMode::Objects => "*".to_string(), + }; + let worker_display = display_name.clone(); + + file::background_task( + move || { + let out_doc = match source_mode { + WblockSourceMode::Block => { + let mut d = crate::modules::insert::wblock::extract_block_to_doc( + &document, + &block_name, + ) + .map_err(|e| e.to_string())?; + d.header.insertion_units = unit; + d + } + WblockSourceMode::EntireDrawing => { + let mut d = document.clone(); + d.header.insertion_units = unit; + d + } + WblockSourceMode::Objects => { + crate::modules::insert::wblock::extract_entities_to_doc_with_base( + &document, + &handles, + base_point, + unit, + ) + .map_err(|e| e.to_string())? + } + }; + crate::io::save(&out_doc, &worker_path).map_err(|e| e.to_string()) + }, + move |result| Message::WblockWriteFinished(worker_display, path, result), + ) + } Message::ToleranceDialogField(field) => { if let Some(state) = self.geometric_tolerance.as_mut() { state.apply_field(field); @@ -5889,6 +6236,8 @@ impl OpenCADStudio { self.reset_modal_geometry(); if self.block_definition.is_some() { self.active_modal = Some(super::ModalKind::BlockDefinition); + } else if self.wblock.is_some() { + self.active_modal = Some(super::ModalKind::WriteBlock); } Task::none() } @@ -6095,6 +6444,15 @@ impl OpenCADStudio { .collect(); block_def.error_message = None; self.active_modal = Some(super::ModalKind::BlockDefinition); + } else if let Some(ref mut wblock) = self.wblock { + wblock.selected_handles = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + wblock.error_message = None; + self.active_modal = Some(super::ModalKind::WriteBlock); } Task::none() } @@ -8071,6 +8429,12 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } + // The title-bar X, Escape, and MCP `close_modal` action mean + // the same thing as Skip for this prompt. Record that choice + // so reopening the drawing does not immediately nag again. + if self.active_modal == Some(super::ModalKind::MissingFonts) { + return self.update(Message::MissingFontsDismiss); + } // The Options window's × and Esc behave like its Close button. if self.active_modal == Some(super::ModalKind::Options) { return self.update(Message::OptionsClose); diff --git a/src/app/update/page_setup_import.rs b/src/app/update/page_setup_import.rs index 943b5eb5b..a794b53a7 100644 --- a/src/app/update/page_setup_import.rs +++ b/src/app/update/page_setup_import.rs @@ -260,8 +260,7 @@ mod tests { app.tabs[i].scene.page_setup_save("Site", ps.clone()); ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into(); app.tabs[i].scene.page_setup_save("Overview", ps); - let p = source.to_string_lossy().replace('\\', "/"); - let save = format!(r#"{{"op":"save","path":"{p}"}}"#); + let save = serde_json::json!({"op":"save","path":source}).to_string(); let reply = app.automation_op(&save); assert_eq!(reply["ok"], true, "{reply}"); } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index a0560e2c5..1ad9aa192 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -28,6 +28,8 @@ impl OpenCADStudio { Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(), Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(), Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), + Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(), + Some(K::WriteBlock) => crate::t!("Write Block").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), @@ -535,6 +537,19 @@ impl OpenCADStudio { crate::ui::window::pdf_dialogs::view_import_file(state, flow) }) } + super::super::ModalKind::XrefAttach => { + let state = self.xref_attach.as_ref()?; + let height = if state.details { 520 } else { 450 }; + sized_flow(ex, 740, height, |flow| { + crate::ui::window::xref_attach::view_window(state, flow) + }) + } + super::super::ModalKind::WriteBlock => { + let state = self.wblock.as_ref()?; + sized_flow(ex, 440, 395, |flow| { + crate::ui::window::wblock::view_window(state, flow) + }) + } super::super::ModalKind::GeometricTolerance => { let state = self.geometric_tolerance.as_ref()?; sized_flow(ex, 670, 530, |flow| { @@ -1704,6 +1719,7 @@ impl OpenCADStudio { crate::ui::window::missing_fonts::view_window( fonts, &font_source, + self.missing_fonts_downloading, flow, ) }) diff --git a/src/cli.rs b/src/cli.rs index f5dc8f41f..099650df0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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>, + /// 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, + /// 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)] diff --git a/src/command.rs b/src/command.rs index 74e927d71..276d74313 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2851,9 +2851,9 @@ pub trait CadCommand: Send { false } - /// If this command is XATTACH, returns the file path to attach. - /// Default: None. - fn xattach_path(&self) -> Option { + /// If this command is XATTACH, what it attaches and how the reference is + /// stored. Default: None. + fn xattach_request(&self) -> Option { None } diff --git a/src/config.rs b/src/config.rs index e8f907441..6d1e60962 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,13 @@ use std::path::PathBuf; /// file name onto it and `create_dir_all` its parent before writing. #[cfg(not(target_arch = "wasm32"))] pub fn config_dir() -> Option { + // Tests must never read or write the person's real settings: a test run + // once raced dozens of tests through an alias-table migration and left the + // user's alias file holding a single line. Every store routes through here, + // so one per-process scratch folder isolates them all. + if cfg!(test) { + return Some(std::env::temp_dir().join(format!("ocs-test-config-{}", std::process::id()))); + } let base: PathBuf = if cfg!(target_os = "windows") { std::env::var_os("APPDATA").map(PathBuf::from)? } else if cfg!(target_os = "macos") { diff --git a/src/io/font_repo.rs b/src/io/font_repo.rs index 5b53e8ec7..66f330fcb 100644 --- a/src/io/font_repo.rs +++ b/src/io/font_repo.rs @@ -42,6 +42,15 @@ pub enum FontSource { Custom(String), } +/// Stable key for comparing bare font names without path or case differences. +pub fn font_key(name: &str) -> String { + name.rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim() + .to_ascii_lowercase() +} + impl FontSource { /// Empty / whitespace input selects the community repository. pub fn from_url(url: &str) -> Self { @@ -265,6 +274,12 @@ mod tests { assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx"); } + #[test] + fn font_keys_are_bare_trimmed_and_case_insensitive() { + assert_eq!(font_key(r#" C:\Fonts\ROMANS.SHX "#), "romans.shx"); + assert_eq!(font_key("folder/simplex.shx"), "simplex.shx"); + } + #[test] fn rejects_a_non_listing_response() { assert!(parse_contents("{\"message\":\"Not Found\"}").is_err()); diff --git a/src/io/mod.rs b/src/io/mod.rs index fd284c277..696aecdac 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -1361,6 +1361,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 { + 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 diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index ad68fea52..29f6578da 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -1073,7 +1073,18 @@ fn gdi_raster_print( } /// Open a file with the OS default application (used for print preview). -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), test))] +pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { + // Unit tests exercise the complete preview pipeline, but must not launch + // the user's PDF application or leave generated previews in the temp + // directory. Production viewers need the file after this call returns; + // the test path has no external consumer, so it can be removed now. + std::fs::remove_file(path) + .map_err(|error| format!("Could not consume test preview: {error}")) +} + +/// Open a file with the OS default application (used for print preview). +#[cfg(all(not(target_arch = "wasm32"), not(test)))] pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { let p = path.to_string_lossy().to_string(); #[cfg(target_os = "windows")] @@ -1099,6 +1110,19 @@ pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("Could not open preview: {e}")) } +#[cfg(all(test, not(target_arch = "wasm32")))] +mod preview_isolation_tests { + #[test] + fn a_test_preview_is_consumed_without_leaving_a_temp_file() { + let path = super::temp_pdf_path("preview_test"); + std::fs::write(&path, b"test preview").unwrap(); + + super::open_in_viewer(&path).unwrap(); + + assert!(!path.exists(), "test preview was not cleaned up: {}", path.display()); + } +} + /// Ask the registered PDF application to print `path` on `printer` through /// its `printto` verb, once per copy. The printer name is quoted: some /// `printto` command templates do not quote their `%2`. diff --git a/src/io/xref.rs b/src/io/xref.rs index 2c2e55551..cc7e849f9 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -324,6 +324,82 @@ pub fn register_underlay_sources(doc: &CadDocument, base_dir: &Path) { } } +/// Layer properties an override can change, as compared for the +/// "Layer property overrides" row. +#[derive(Clone, PartialEq)] +struct LayerLook { + color: acadrust::types::Color, + line_type: String, + line_weight: acadrust::types::LineWeight, + off: bool, + frozen: bool, + locked: bool, + plottable: bool, +} + +impl LayerLook { + fn of(layer: &acadrust::tables::Layer) -> Self { + Self { + color: layer.color.clone(), + line_type: layer.line_type.to_uppercase(), + line_weight: layer.line_weight, + off: layer.flags.off, + frozen: layer.flags.frozen, + locked: layer.flags.locked, + plottable: layer.is_plottable, + } + } +} + +/// The source file's layers by upper-case name, read once per file version. +fn source_layers(path: &Path) -> Option> { + type Cache = std::sync::Mutex)>>; + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let cache = CACHE.get_or_init(Default::default); + if let Some((stamp, layers)) = cache.lock().ok()?.get(path) { + if *stamp == modified { + return Some(layers.clone()); + } + } + let doc = super::load_file(path).ok()?; + let layers: HashMap = doc + .layers + .iter() + .map(|layer| (layer.name.to_uppercase(), LayerLook::of(layer))) + .collect(); + cache + .lock() + .ok()? + .insert(path.to_path_buf(), (modified, layers.clone())); + Some(layers) +} + +/// Whether any of reference `name`'s layers was changed in the host — the +/// Properties "Layer property overrides" row. Compares the host's dependent +/// layers with the source file's own. +pub fn layer_overrides(doc: &CadDocument, name: &str, raw_path: &str, base_dir: &Path) -> bool { + let Some(source) = resolve_path(raw_path, base_dir).and_then(|p| source_layers(&p)) else { + return false; + }; + let prefix = format!("{}|", name.to_uppercase()); + doc.layers.iter().any(|layer| { + let upper = layer.name.to_uppercase(); + let Some(own) = upper.strip_prefix(&prefix) else { + return false; + }; + let Some(original) = source.get(own) else { + return false; + }; + let mut here = LayerLook::of(layer); + // Dependent linetypes carry the reference prefix. + if let Some(stripped) = here.line_type.strip_prefix(&prefix) { + here.line_type = stripped.to_string(); + } + here != *original + }) +} + /// Try to build an absolute path from a raw xref path string. /// Handles absolute paths, relative paths, and Windows-style separators. fn resolve_path(raw: &str, base_dir: &Path) -> Option { @@ -361,18 +437,44 @@ fn resolve_path(raw: &str, base_dir: &Path) -> Option { None } -/// Make sure `doc` has BLOCK + ENDBLK entities for `block_name`. -/// These are required so renderers can find the block content. -fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) { - let has_block = doc - .entities() - .any(|e| matches!(e, EntityType::Block(b) if b.name == block_name)); - if has_block { +/// Give a block record its BLOCK / ENDBLK markers, linked through the +/// record's marker handles. The DWG writer emits the markers the record +/// points at; a record left pointing at a null handle is written with +/// handle 0, which makes the whole drawing unreadable elsewhere. +pub(crate) fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) { + let Some((owner, begin, end, path)) = doc.block_records.get(block_name).map(|br| { + ( + br.handle, + br.block_entity_handle, + br.block_end_handle, + br.xref_path.clone(), + ) + }) else { return; + }; + if begin.is_null() || !matches!(doc.get_entity(begin), Some(EntityType::Block(_))) { + let handle = doc.allocate_handle(); + let mut block = Block::new(block_name, Vector3::zero()); + if !path.is_empty() { + block = block.with_xref_path(&path); + } + block.common.handle = handle; + block.common.owner_handle = owner; + let _ = doc.add_entity(EntityType::Block(block)); + if let Some(br) = doc.block_records.get_mut(block_name) { + br.block_entity_handle = handle; + } + } + if end.is_null() || !matches!(doc.get_entity(end), Some(EntityType::BlockEnd(_))) { + let handle = doc.allocate_handle(); + let mut block_end = BlockEnd::new(); + block_end.common.handle = handle; + block_end.common.owner_handle = owner; + let _ = doc.add_entity(EntityType::BlockEnd(block_end)); + if let Some(br) = doc.block_records.get_mut(block_name) { + br.block_end_handle = handle; + } } - let b = Block::new(block_name, Vector3::zero()); - let _ = doc.add_entity(EntityType::Block(b)); - let _ = doc.add_entity(EntityType::BlockEnd(BlockEnd::new())); } /// Shared symbol-remap core for XREF load-merge and BIND (SPIKE3 REFACTOR @@ -490,6 +592,7 @@ struct XrefSymbolMaps { layers: HashMap, linetypes: HashMap, text_styles: HashMap, + text_style_handles: HashMap, dim_styles: HashMap, blocks: HashMap, br_handles: HashMap, @@ -512,16 +615,55 @@ fn import_xref_symbols( ) -> XrefSymbolMaps { let mut maps = XrefSymbolMaps::default(); + // A layer's plot style and material point at objects of the file it came + // from; carried over verbatim they point at whatever the host keeps under + // those handles. Take the host's defaults (its layer 0) instead. + // ponytail: every imported layer gets the default plot style and material; + // map them by name if a reference ever carries non-default ones. + let (host_plotstyle, host_material) = doc + .layers + .get("0") + .map(|layer| (layer.plotstyle_handle, layer.material)) + .unwrap_or((Handle::NULL, Handle::NULL)); + for layer in xref_doc.layers.iter() { let old = layer.name.clone(); let new = naming.name_for(SymbolKind::Layer, &old); let mut cloned = layer.clone(); cloned.name = new.clone(); + cloned.plotstyle_handle = host_plotstyle; + cloned.material = host_material; cloned.set_handle(doc.allocate_handle()); doc.layers.add_or_replace(cloned); maps.layers.insert(old.to_uppercase(), new); } + for ts in xref_doc.text_styles.iter() { + // A shape-file style has no name of its own; the host's style for the + // same shape file serves the imported linetypes. + if ts.is_shape_file { + let same_file = doc + .text_styles + .iter() + .find(|host| host.is_shape_file && host.font_file.eq_ignore_ascii_case(&ts.font_file)) + .map(|host| host.handle); + if let Some(host_handle) = same_file { + maps.text_style_handles.insert(ts.handle, host_handle); + continue; + } + } + let old = ts.name.clone(); + let new = naming.name_for(SymbolKind::TextStyle, &old); + let mut cloned = ts.clone(); + cloned.name = new.clone(); + let new_handle = doc.allocate_handle(); + cloned.set_handle(new_handle); + cloned.xref_dependent = false; + doc.text_styles.add_or_replace(cloned); + maps.text_style_handles.insert(ts.handle, new_handle); + maps.text_styles.insert(old.to_uppercase(), new); + } + for lt in xref_doc.line_types.iter() { let old = lt.name.clone(); if is_sentinel_linetype(&old) { @@ -530,22 +672,20 @@ fn import_xref_symbols( let new = naming.name_for(SymbolKind::Linetype, &old); let mut cloned = lt.clone(); cloned.name = new.clone(); + // Shape and text segments name their style by handle: point them at + // the imported copy, not at the source file's record. + for element in &mut cloned.elements { + if let Some(complex) = element.complex.as_mut() { + if let Some(new_style) = maps.text_style_handles.get(&complex.style_handle) { + complex.style_handle = *new_style; + } + } + } cloned.set_handle(doc.allocate_handle()); doc.line_types.add_or_replace(cloned); maps.linetypes.insert(old.to_uppercase(), new); } - for ts in xref_doc.text_styles.iter() { - let old = ts.name.clone(); - let new = naming.name_for(SymbolKind::TextStyle, &old); - let mut cloned = ts.clone(); - cloned.name = new.clone(); - cloned.set_handle(doc.allocate_handle()); - cloned.xref_dependent = false; - doc.text_styles.add_or_replace(cloned); - maps.text_styles.insert(old.to_uppercase(), new); - } - for ds in xref_doc.dim_styles.iter() { let old = ds.name.clone(); let new = naming.name_for(SymbolKind::DimStyle, &old); @@ -817,6 +957,21 @@ fn merge_xref_into_block( prefix: prefix.to_string(), }; let maps = import_xref_symbols(doc, &xref_doc, &mut naming); + // Dependent layers and linetypes carry the xref flag and their owning + // reference, the way the file stores them; without it the host writes + // plain records with a "|" in the name, which other readers reject. + for name in maps.layers.values() { + if let Some(layer) = doc.layers.get_mut(name) { + layer.flags.xref_dependent = true; + layer.xref_block_record_handle = br_handle; + } + } + for name in maps.linetypes.values() { + if let Some(linetype) = doc.line_types.get_mut(name) { + linetype.xref_dependent = true; + linetype.xref_block_record_handle = br_handle; + } + } // ── Entities (shared helper) ──────────────────────────────────────── // The unremapped-handle count is ignored on the load path (BIND reports @@ -1730,6 +1885,64 @@ fn same_file(a: &Path, b: &Path) -> bool { } } +/// Make the named references' paths relative to `host` (their drawing's +/// first save); a reference that cannot be relative keeps its full path. +pub fn make_relative( + doc: &mut CadDocument, + names: &rustc_hash::FxHashSet, + host: &Path, +) { + for name in names { + let Some(key) = doc.block_records.get(name).map(|br| br.handle.value()) else { + continue; + }; + let _ = apply_pathtype(doc, key, crate::io::xref_model::Pathtype::Relative, host); + } +} + +/// Drop from a save copy what resolving each xref merged into the host but +/// the file never stores: the reference's own geometry, its nested block +/// definitions and its text and dimension styles. Dependent layers and +/// linetypes stay (flagged), so layer overrides kept under VISRETAIN persist. +pub fn strip_resolved_xref_content(doc: &mut CadDocument) { + let xrefs: Vec<(String, Handle)> = doc + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .map(|br| (br.name.clone(), br.handle)) + .collect(); + for (name, handle) in xrefs { + let (begin, end) = doc + .block_records + .get(&name) + .map(|br| (br.block_entity_handle, br.block_end_handle)) + .unwrap_or((Handle::NULL, Handle::NULL)); + let owned: Vec = doc + .entities() + .filter(|e| e.common().owner_handle == handle) + .map(|e| e.common().handle) + .filter(|h| *h != begin && *h != end) + .collect(); + for h in owned { + doc.remove_entity(h); + } + if let Some(br) = doc.block_records.get_mut(&name) { + br.entity_handles.clear(); + } + remove_dependent_symbols(doc, &name, false); + } +} + +/// Text styles a kept linetype draws its shapes or text with. +fn linetype_styles(doc: &CadDocument) -> HashSet { + doc.line_types + .iter() + .flat_map(|linetype| linetype.elements.iter()) + .filter_map(|element| element.complex.as_ref().map(|complex| complex.style_handle)) + .filter(|handle| !handle.is_null()) + .collect() +} + /// Remove one xref's `name|*` dependent symbols (case-insensitive): layers, /// linetypes, text styles, dim styles, and nested block records — plus every /// entity and draw-order table owned by those dependent blocks. @@ -1738,6 +1951,10 @@ fn same_file(a: &Path, b: &Path) -> bool { /// (which drops the previous merge before re-merging under `$N$` names). The /// xref BlockRecord itself, its INSERTs, and non-prefixed symbols stay. fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { + remove_dependent_symbols(doc, name, true); +} + +fn remove_dependent_symbols(doc: &mut CadDocument, name: &str, layers_too: bool) { let prefix = format!("{}|", name.to_uppercase()); let is_dep = |n: &str| n.to_uppercase().starts_with(&prefix); // Dependent blocks first — their handles route the owned-entity cleanup. @@ -1774,7 +1991,7 @@ fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { let layers: Vec = doc .layers .names() - .filter(|n| is_dep(n)) + .filter(|n| layers_too && is_dep(n)) .map(|s| s.to_string()) .collect(); for n in layers { @@ -1783,21 +2000,38 @@ fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { let lts: Vec = doc .line_types .names() - .filter(|n| is_dep(n)) + .filter(|n| layers_too && is_dep(n)) .map(|s| s.to_string()) .collect(); for n in lts { doc.line_types.remove(&n); } + // Styles a kept dependent linetype still draws with stay, flagged. + let used = if layers_too { + HashSet::default() + } else { + linetype_styles(doc) + }; let styles: Vec = doc .text_styles - .names() - .filter(|n| is_dep(n)) - .map(|s| s.to_string()) + .iter() + .filter(|style| is_dep(&style.name) && !used.contains(&style.handle)) + .map(|style| style.name.clone()) .collect(); for n in styles { doc.text_styles.remove(&n); } + let owner = doc + .block_records + .get(name) + .map(|br| br.handle) + .unwrap_or(Handle::NULL); + for style in doc.text_styles.iter_mut() { + if is_dep(&style.name) { + style.xref_dependent = true; + style.xref_block_record_handle = owner; + } + } let dims: Vec = doc .dim_styles .names() diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index 063f40d25..b25952d2a 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -247,6 +247,11 @@ pub fn to_pathtype_result( if parts.is_empty() { return Ok(file_name_only(path)); } + // A path at or below the host folder starts "./" ("./plan.dwg", + // "./refs/plan.dwg"), as the reference writes it; one above starts "../". + if parts[0] != ".." { + parts.insert(0, ".".to_string()); + } Ok(parts.join("/")) } } @@ -846,7 +851,7 @@ mod tests { let full = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Full); assert_eq!(full, "C:/Drawings/refs/plan.dwg"); let rel = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Relative); - assert_eq!(rel, "refs/plan.dwg"); + assert_eq!(rel, "./refs/plan.dwg"); let none = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::None); assert_eq!(none, "plan.dwg"); } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 184578d18..eafd05c23 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3,6 +3,17 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> { match source { + "Entire drawing" => Some(("common", "entire-drawing")), + "Delete from drawing" => Some(("common", "delete-from-drawing")), + "Destination" => Some(("common", "destination")), + "File name and path:" => Some(("common", "file-name-and-path")), + "Insert units:" => Some(("common", "insert-units-label")), + "Please select or enter a block name." => Some(("common", "wblock-err-no-block-name")), + "Block \"{}\" does not exist in drawing." => Some(("common", "wblock-err-block-not-found")), + "Please specify a file name and path." => Some(("common", "wblock-err-no-path")), + "Cannot write to the current drawing file." => Some(("common", "wblock-err-current-drawing")), + "WBLOCK writes objects, a block, or the entire drawing to a new drawing file." => Some(("common", "wblock-help-info")), + "WBLOCK Specify insertion base point:" => Some(("insert", "wblock-specify-insertion-base-point")), "Auto Constrain" => Some(("common", "auto-constrain")), "Delete Constraints" => Some(("common", "delete-constraints")), "Dimensional" => Some(("common", "dimensional")), @@ -265,6 +276,27 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Added annotation scale {arg}." => Some(("view", "added-annotation-scale-arg")), "Added from GitHub" => Some(("plugins", "added-from-github")), "Adjust" => Some(("insert", "adjust")), + "Attach External Reference" => Some(("insert", "xref-attach-dialog-title")), + "Browse..." => Some(("insert", "xref-attach-browse")), + "Reference Type" => Some(("insert", "xref-attach-reference-type")), + "Uniform Scale" => Some(("insert", "xref-attach-uniform-scale")), + "Insertion point" => Some(("insert", "xref-attach-insertion-point")), + "Angle:" => Some(("insert", "xref-attach-angle")), + "Block Unit" => Some(("insert", "xref-attach-block-unit")), + "Unit:" => Some(("insert", "xref-attach-unit")), + "Factor:" => Some(("insert", "xref-attach-factor")), + "Found in:" => Some(("insert", "xref-attach-found-in")), + "Saved path:" => Some(("insert", "xref-attach-saved-path")), + "Show Details" => Some(("insert", "xref-attach-show-details")), + "Hide Details" => Some(("insert", "xref-attach-hide-details")), + "Select Reference File" => Some(("insert", "xref-attach-select-file")), + "All Reference Files" => Some(("insert", "xref-attach-all-files")), + "Inserts references to external files such as other drawings, raster images, and underlays." => Some(("insert", "xref-attach-help")), + "Invalid input." => Some(("insert", "xref-attach-invalid-input")), + "Insertion point X" => Some(("insert", "xref-insertion-point-x")), + "Insertion point Y" => Some(("insert", "xref-insertion-point-y")), + "Insertion point Z" => Some(("insert", "xref-insertion-point-z")), + "Layer property overrides" => Some(("insert", "xref-layer-property-overrides")), "Adjust Colors for Background" => Some(("properties", "adjust-colors-for-background")), "Advanced" => Some(("properties", "advanced")), "Alias" => Some(("command", "alias-title-case")), @@ -2935,15 +2967,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Underlay Layers" => Some(("insert", "pdfui-underlay-layers")), "PDF Import Settings" => Some(("insert", "pdfui-pdf-import-settings")), "Import PDF" => Some(("insert", "pdfui-import-pdf")), - "Browse..." => Some(("insert", "pdfui-browse")), "Select one or more pages from the PDF file:" => Some(("insert", "pdfui-select-one-or-more-pages-from-the-pdf-file")), - "Insertion point" => Some(("insert", "pdfui-insertion-point")), - "Angle:" => Some(("insert", "pdfui-angle")), - "Found in:" => Some(("insert", "pdfui-found-in")), - "Saved path:" => Some(("insert", "pdfui-saved-path")), "Page size:" => Some(("insert", "pdfui-page-size")), - "Hide Details" => Some(("insert", "pdfui-hide-details")), - "Show Details" => Some(("insert", "pdfui-show-details")), "Reference name:" => Some(("insert", "pdfui-reference-name")), "Search for layer" => Some(("insert", "pdfui-search-for-layer")), "This file does not contain any layers." => Some(("insert", "pdfui-this-file-does-not-contain-any-layers")), diff --git a/src/main.rs b/src/main.rs index bfccf418b..b3564ab82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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); } diff --git a/src/mcp.rs b/src/mcp.rs index 00fe5b6b1..ff40822cc 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -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"], diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 2514e8bb5..c7490c6ae 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -25,6 +25,7 @@ mod underlay_layers; pub(crate) mod wblock; mod xadjust; pub(crate) mod xattach; +pub(crate) mod xref_cmd; mod xclip; use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem}; diff --git a/src/modules/insert/wblock.rs b/src/modules/insert/wblock.rs index 6177a1fcc..abbaef5e9 100644 --- a/src/modules/insert/wblock.rs +++ b/src/modules/insert/wblock.rs @@ -157,3 +157,109 @@ pub fn normalize_to_origin(out: &mut CadDocument) { crate::scene::view::dispatch::apply_transform(entity, &shift); } } + +/// One-shot point picker for the Write Block dialog's "Pick point" button. +pub struct WblockPickBasePointCommand; + +impl crate::command::CadCommand for WblockPickBasePointCommand { + fn name(&self) -> &'static str { + "WBLOCK" + } + + fn prompt(&self) -> String { + crate::t!("WBLOCK Specify insertion base point:").into_owned() + } + + fn on_point(&mut self, pt: glam::DVec3) -> crate::command::CmdResult { + crate::command::CmdResult::Dispatch(format!("WBLOCK_POINT_PICKED {} {} {}", pt.x, pt.y, pt.z)) + } + + fn on_enter(&mut self) -> crate::command::CmdResult { + crate::command::CmdResult::Dispatch("WBLOCK_POINT_CANCELLED".to_string()) + } + + fn on_escape(&mut self) -> crate::command::CmdResult { + crate::command::CmdResult::Dispatch("WBLOCK_POINT_CANCELLED".to_string()) + } +} + +/// Build a standalone `CadDocument` from an explicit list of entity handles, +/// applying `base_point` translation and `unit` insertion units. +pub fn extract_entities_to_doc_with_base( + src: &CadDocument, + handles: &[acadrust::Handle], + base_point: glam::DVec3, + unit: i16, +) -> Result { + let mut out = CadDocument::new(); + out.header.insertion_units = unit; + extract_entities_into(src, handles, &mut out)?; + if base_point != glam::DVec3::ZERO { + let shift = crate::command::EntityTransform::Affine( + acadrust::types::Transform::from_translation(acadrust::types::Vector3::new( + -base_point.x, + -base_point.y, + -base_point.z, + )), + ); + for entity in out.entities_mut() { + crate::scene::view::dispatch::apply_transform(entity, &shift); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn test_extract_entities_with_base_point_and_units() { + let mut doc = CadDocument::new(); + let mut line = Line::new(); + line.start = Vector3::new(10.0, 20.0, 0.0); + line.end = Vector3::new(30.0, 40.0, 0.0); + let h = doc.add_entity(EntityType::Line(line)).unwrap(); + + let out = extract_entities_to_doc_with_base( + &doc, + &[h], + glam::DVec3::new(10.0, 20.0, 0.0), + 4, // Millimeters + ) + .expect("extract should succeed"); + + assert_eq!(out.header.insertion_units, 4); + assert_eq!(out.entities().count(), 1); + let e = out.entities().next().unwrap(); + if let EntityType::Line(l) = e { + assert!((l.start.x - 0.0).abs() < 1e-6); + assert!((l.start.y - 0.0).abs() < 1e-6); + assert!((l.end.x - 20.0).abs() < 1e-6); + assert!((l.end.y - 20.0).abs() < 1e-6); + } else { + panic!("Expected Line entity"); + } + } + + #[test] + fn test_extract_block_to_doc() { + let mut doc = CadDocument::new(); + let mut line = Line::new(); + line.start = Vector3::new(1.0, 2.0, 0.0); + line.end = Vector3::new(3.0, 4.0, 0.0); + let h = doc.add_entity(EntityType::Line(line)).unwrap(); + + let mut block_record = acadrust::tables::BlockRecord::new("MY_BLOCK".to_string()); + block_record.entity_handles.push(h); + let _ = doc.block_records.add(block_record); + + let out = extract_block_to_doc(&doc, "MY_BLOCK").expect("block extract should succeed"); + assert_eq!(out.entities().count(), 1); + + assert!(extract_block_to_doc(&doc, "NON_EXISTENT").is_err()); + } +} + diff --git a/src/modules/insert/xattach.rs b/src/modules/insert/xattach.rs index b7be7c47a..c9d2f7967 100644 --- a/src/modules/insert/xattach.rs +++ b/src/modules/insert/xattach.rs @@ -1,23 +1,25 @@ -// XATTACH command — attach an external DWG/DXF file as an XREF block -// and insert it at a picked point. +// XATTACH placement — attach an external DWG/DXF file as an XREF block and +// insert it. // -// Workflow: -// Step 1 (point pick): user clicks the insertion point. -// Step 2 (text input): scale factor (default 1.0). -// Step 3 (text input): rotation angle (default 0.0). -// Result: BlockRecord + Block entities are created with is_xref=true, -// then an INSERT entity is committed with the chosen scale/rotation. +// The reference options (type, path type, fixed or on-screen scale, +// insertion point and rotation) come from the Attach External Reference +// dialog or from -XREF Attach / Overlay. Values left "on-screen" are asked +// here with the reference prompts: +// Specify insertion point or [Scale/X/Y/Z/Rotate/PScale/PX/PY/PZ/PRotate]: +// Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>: +// Enter Y scale factor : +// Specify rotation angle <0>: +// The definition is created when the INSERT is committed (see the command +// driver), so one undo removes both. -use acadrust::entities::{Block, BlockEnd, Insert}; +use acadrust::entities::Insert; use acadrust::tables::block_record::{BlockFlags, BlockRecord}; use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; -use crate::t; - -use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; -use crate::io::xref_model::normalize_lexical; +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane}; +use crate::io::xref_model::{normalize_lexical, Pathtype}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::scene::Scene; @@ -31,45 +33,259 @@ pub fn tool() -> ToolDef { } } -/// Multi-step placement state: insertion point, then scale, then rotation. -/// Mirrors the INSERT command's scale/rotation value handling (single value -/// typed after the point pick, bare Enter keeps the default). -enum XAttachStep { - Point, - Scale { point: DVec3 }, - Rotate { point: DVec3, sx: f64, sy: f64, sz: f64 }, +/// What to attach and how the reference is stored. +#[derive(Debug, Clone, PartialEq)] +pub struct XrefAttachRequest { + /// The drawing file as chosen (absolute). + pub path: String, + pub overlay: bool, + pub path_type: Pathtype, } -pub struct XAttachCommand { - path: String, - block_name: String, - plane: WorkingPlane, - step: XAttachStep, +/// Placement values fixed before the command starts; `None` = on-screen. +#[derive(Debug, Clone, Copy, Default)] +pub struct XrefPlacement { + pub insert: Option, + pub scale: Option<[f64; 3]>, + /// Degrees. + pub rotation: Option, } -impl XAttachCommand { - /// Create an XATTACH command with a path already filled in (from file-picker). - pub fn with_path(path: String) -> Self { - let block_name = path_to_block_name(&path); - Self { - path, - block_name, - plane: WorkingPlane::default(), - step: XAttachStep::Point, +impl XrefPlacement { + /// Everything on-screen, as -XREF asks it. + pub fn on_screen() -> Self { + Self::default() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Preset { + Scale, + X, + Y, + Z, + Rotate, + PScale, + PX, + PY, + PZ, + PRotate, +} + +impl Preset { + fn from_keyword(text: &str) -> Option { + Some(match text { + "S" | "SCALE" => Preset::Scale, + "X" => Preset::X, + "Y" => Preset::Y, + "Z" => Preset::Z, + "R" | "ROTATE" => Preset::Rotate, + "PS" | "PSCALE" => Preset::PScale, + "PX" => Preset::PX, + "PY" => Preset::PY, + "PZ" => Preset::PZ, + "PR" | "PROTATE" => Preset::PRotate, + _ => return None, + }) + } + + fn prompt(self) -> &'static str { + match self { + Preset::Scale => "Specify scale factor for XYZ axes:", + Preset::X => "Specify X scale factor:", + Preset::Y => "Specify Y scale factor:", + Preset::Z => "Specify Z Scale factor:", + Preset::Rotate => "Specify rotation angle:", + Preset::PScale => "Specify preview scale factor for XYZ axes:", + Preset::PX => "Specify preview X scale factor:", + Preset::PY => "Specify preview Y scale factor:", + Preset::PZ => "Specify preview Z scale factor:", + Preset::PRotate => "Specify preview rotation angle:", } } - fn commit_insert(&self, point: DVec3, sx: f64, sy: f64, sz: f64, rotation: f64) -> EntityType { + fn is_angle(self) -> bool { + matches!(self, Preset::Rotate | Preset::PRotate) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum Step { + Insert, + Preset(Preset), + XScale, + Corner, + YScale, + XyzX, + XyzY, + XyzZ, + Rotation, +} + +pub struct XAttachCommand { + request: XrefAttachRequest, + block_name: String, + /// Command name shown before each prompt (ATTACH, XATTACH or -XREF). + label: &'static str, + plane: WorkingPlane, + step: Step, + point: Option, + scale: [f64; 3], + /// Scale fixed in the dialog or by the Scale/X/Y/Z options. + scale_fixed: bool, + /// Degrees. + rotation: f64, + rotation_fixed: bool, + /// Ghost of the reference's model space in its own coordinates. + preview: Vec, + /// Drawing units per reference unit; the INSERT's scale carries it. + unit: f64, + /// PScale / PX / PY / PZ / PRotate: preview-only values. + preview_scale: [f64; 3], + preview_rotation: f64, +} + +const NONZERO: &str = "Value must be nonzero."; + +impl XAttachCommand { + pub fn new(request: XrefAttachRequest, placement: XrefPlacement, label: &'static str) -> Self { + let block_name = path_to_block_name(&request.path); + let mut command = Self { + request, + block_name, + label, + plane: WorkingPlane::default(), + step: Step::Insert, + point: placement.insert, + scale: placement.scale.unwrap_or([1.0; 3]), + scale_fixed: placement.scale.is_some(), + rotation: placement.rotation.unwrap_or(0.0), + rotation_fixed: placement.rotation.is_some(), + preview: Vec::new(), + unit: 1.0, + preview_scale: [1.0; 3], + preview_rotation: 0.0, + }; + if command.point.is_some() { + command.step = command.next_step().unwrap_or(Step::Rotation); + } + command + } + + /// Attach with every value on-screen. + #[cfg(test)] + pub fn with_path(path: String) -> Self { + Self::new( + XrefAttachRequest { + path, + overlay: false, + path_type: Pathtype::Full, + }, + XrefPlacement::on_screen(), + "XATTACH", + ) + } + + /// Show `wires` (the reference's model space) while placing it. + pub fn with_preview(mut self, wires: Vec, unit: f64) -> Self { + self.preview = wires; + self.unit = if unit.is_finite() && unit != 0.0 { unit } else { 1.0 }; + self + } + + /// Placement shown for the cursor at `cursor` (local): insertion point, + /// scale and rotation (degrees) as far as they are known at this step. + fn preview_placement(&self, cursor: DVec3) -> (DVec3, [f64; 3], f64) { + let rotation = if self.rotation_fixed { + self.rotation + } else { + self.preview_rotation + }; + let base = self.point.unwrap_or(cursor); + match self.step { + Step::Insert | Step::Preset(_) => { + let scale = if self.scale_fixed { self.scale } else { self.preview_scale }; + (cursor, scale, rotation) + } + Step::XScale | Step::Corner | Step::XyzX => { + let (dx, dy) = (cursor.x - base.x, cursor.y - base.y); + let scale = if dx.abs() > 1e-12 && dy.abs() > 1e-12 { + [dx, dy, dx.abs()] + } else { + [1.0; 3] + }; + (base, scale, rotation) + } + Step::Rotation => { + let angle = (cursor.y - base.y).atan2(cursor.x - base.x).to_degrees(); + (base, self.scale, angle) + } + _ => (base, self.scale, rotation), + } + } + + /// The finished INSERT when nothing is left to ask (all values fixed in + /// the dialog). + pub fn immediate(&self) -> Option { + (self.point.is_some() && self.next_step().is_none()).then(|| self.commit_insert()) + } + + /// The step after the insertion point, or `None` when it can commit. + fn next_step(&self) -> Option { + if !self.scale_fixed { + Some(Step::XScale) + } else if !self.rotation_fixed { + Some(Step::Rotation) + } else { + None + } + } + + fn advance(&mut self) -> CmdResult { + match self.next_step() { + Some(step) => { + self.step = step; + CmdResult::NeedPoint + } + None => CmdResult::CommitAndExit(self.commit_insert()), + } + } + + /// Scale settled at this step: the rotation (if asked) comes next. + fn scale_done(&mut self) -> CmdResult { + self.scale_fixed = true; + self.advance() + } + + fn commit_insert(&self) -> EntityType { + let point = self.point.unwrap_or(DVec3::ZERO); let mut ins = Insert::new( self.block_name.clone(), Vector3::new(point.x, point.y, point.z), ); - ins.set_x_scale(sx); - ins.set_y_scale(sy); - ins.set_z_scale(sz); - ins.rotation = rotation; + ins.set_x_scale(self.scale[0]); + ins.set_y_scale(self.scale[1]); + ins.set_z_scale(self.scale[2]); + ins.rotation = self.rotation.to_radians(); self.plane.place_entity(EntityType::Insert(ins)) } + + /// Corner pick at the X scale prompt: X and Y from the rectangle, + /// Z follows X. + fn corner(&mut self, pt: DVec3) -> CmdResult { + let base = self.point.unwrap_or(DVec3::ZERO); + let corner = self.plane.to_local(pt); + let (sx, sy) = (corner.x - base.x, corner.y - base.y); + if sx.abs() < 1e-12 || sy.abs() < 1e-12 { + return CmdResult::ReportError(NONZERO.to_string()); + } + self.scale = [sx, sy, sx.abs()]; + self.scale_done() + } +} + +fn parse_number(text: &str) -> Option { + text.trim().replace(',', ".").parse::().ok().filter(|v| v.is_finite()) } impl CadCommand for XAttachCommand { @@ -82,121 +298,243 @@ impl CadCommand for XAttachCommand { } fn prompt(&self) -> String { - match &self.step { - XAttachStep::Point => t!( - "XATTACH Specify insertion point for \"%{name}\":", - name = self.block_name - ) - .into_owned(), - XAttachStep::Scale { .. } => { - t!("XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner):").into_owned() + let text = match self.step { + Step::Insert => { + "Specify insertion point or [Scale/X/Y/Z/Rotate/PScale/PX/PY/PZ/PRotate]:" } - XAttachStep::Rotate { .. } => { - t!("XATTACH Specify rotation angle <0>:").into_owned() + Step::Preset(preset) => preset.prompt(), + Step::XScale => { + "Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>:" } + Step::Corner => "Specify opposite corner:", + Step::YScale => "Enter Y scale factor :", + Step::XyzX => "Specify X scale factor or [Corner] <1>:", + Step::XyzY => "Enter Y scale factor :", + Step::XyzZ => "Specify Z scale factor or :", + Step::Rotation => "Specify rotation angle <0>:", + }; + format!("{} {}", self.label, text) + } + + fn options(&self) -> Vec { + match self.step { + Step::Insert => vec![ + CmdOption::new("Scale", "S"), + CmdOption::new("X", "X"), + CmdOption::new("Y", "Y"), + CmdOption::new("Z", "Z"), + CmdOption::new("Rotate", "R"), + CmdOption::new("PScale", "PS"), + CmdOption::new("PX", "PX"), + CmdOption::new("PY", "PY"), + CmdOption::new("PZ", "PZ"), + CmdOption::new("PRotate", "PR"), + ], + Step::XScale => vec![CmdOption::new("Corner", "C"), CmdOption::new("XYZ", "XYZ")], + Step::XyzX => vec![CmdOption::new("Corner", "C")], + _ => Vec::new(), } } fn input_kind(&self) -> InputKind { match self.step { - XAttachStep::Point => InputKind::Point, - XAttachStep::Scale { .. } | XAttachStep::Rotate { .. } => InputKind::SingleToken, + Step::Insert | Step::Corner => InputKind::Point, + _ => InputKind::SingleToken, } } + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::Insert + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - XAttachStep::Point => { - let point = self.plane.to_local(pt); - self.step = XAttachStep::Scale { point }; - CmdResult::NeedPoint + Step::Insert => { + self.point = Some(self.plane.to_local(pt)); + self.advance() } - XAttachStep::Scale { point } => { - // Corner: derive X/Y scale from the picked opposite corner. - let corner = self.plane.to_local(pt); - let sx = (corner.x - point.x).abs(); - let sy = (corner.y - point.y).abs(); - self.step = XAttachStep::Rotate { - point, - sx: if sx < 1e-9 { 1.0 } else { sx }, - sy: if sy < 1e-9 { 1.0 } else { sy }, - sz: 1.0, - }; - CmdResult::NeedPoint - } - XAttachStep::Rotate { point, sx, sy, sz } => { - // Second point defines the rotation direction from the base. - let base_world = self.plane.to_world(point); - let rotation = self.plane.angle(base_world, pt).unwrap_or(0.0); - CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, rotation)) + Step::XScale | Step::Corner | Step::XyzX => self.corner(pt), + Step::Rotation => { + let base = self.plane.to_world(self.point.unwrap_or(DVec3::ZERO)); + self.rotation = self.plane.angle(base, pt).unwrap_or(0.0).to_degrees(); + CmdResult::CommitAndExit(self.commit_insert()) } + _ => CmdResult::NeedPoint, } } fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim().to_uppercase(); match self.step { - XAttachStep::Point => None, - XAttachStep::Scale { point } => { - // Single value (uniform) or X/Y/Z triplet; invalid input is - // consumed and re-prompts (mirrors INSERT's scale handling). - if let Some((sx, sy, sz)) = parse_scale_spec(text) { - self.step = XAttachStep::Rotate { point, sx, sy, sz }; - } + Step::Insert => { + let preset = Preset::from_keyword(&token)?; + self.step = Step::Preset(preset); Some(CmdResult::NeedPoint) } - XAttachStep::Rotate { point, sx, sy, sz } => { - if let Some(rotation) = crate::entities::common::parse_typed_angle(text) { - Some(CmdResult::CommitAndExit( - self.commit_insert(point, sx, sy, sz, rotation), - )) + Step::Preset(preset) => { + let value = if preset.is_angle() { + crate::entities::common::parse_typed_angle(text).map(f64::to_degrees) } else { - Some(CmdResult::NeedPoint) + parse_number(text) + }; + let Some(value) = value else { + return Some(CmdResult::NeedPoint); + }; + if !preset.is_angle() && value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); } + match preset { + Preset::Scale => { + self.scale = [value; 3]; + self.scale_fixed = true; + } + Preset::X => { + self.scale[0] = value; + self.scale_fixed = true; + } + Preset::Y => { + self.scale[1] = value; + self.scale_fixed = true; + } + Preset::Z => { + self.scale[2] = value; + self.scale_fixed = true; + } + Preset::Rotate => { + self.rotation = value; + self.rotation_fixed = true; + } + // Preview-only values: the reference asks the real ones + // after the insertion point as usual. + Preset::PScale => self.preview_scale = [value; 3], + Preset::PX => self.preview_scale[0] = value, + Preset::PY => self.preview_scale[1] = value, + Preset::PZ => self.preview_scale[2] = value, + Preset::PRotate => self.preview_rotation = value, + } + self.step = Step::Insert; + Some(CmdResult::NeedPoint) } + Step::XScale | Step::XyzX if token == "C" || token == "CORNER" => { + self.step = Step::Corner; + Some(CmdResult::NeedPoint) + } + Step::XScale if token == "XYZ" => { + self.step = Step::XyzX; + Some(CmdResult::NeedPoint) + } + Step::XScale | Step::XyzX => { + let value = parse_number(text)?; + if value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); + } + self.scale = [value, value, value.abs()]; + self.step = if self.step == Step::XScale { + Step::YScale + } else { + Step::XyzY + }; + Some(CmdResult::NeedPoint) + } + Step::YScale | Step::XyzY => { + let value = parse_number(text)?; + if value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); + } + self.scale[1] = value; + if self.step == Step::XyzY { + self.step = Step::XyzZ; + return Some(CmdResult::NeedPoint); + } + Some(self.scale_done()) + } + Step::XyzZ => { + let value = parse_number(text)?; + if value == 0.0 { + return Some(CmdResult::ReportError(NONZERO.to_string())); + } + self.scale[2] = value; + Some(self.scale_done()) + } + Step::Rotation => { + let rotation = crate::entities::common::parse_typed_angle(text)?; + self.rotation = rotation.to_degrees(); + Some(CmdResult::CommitAndExit(self.commit_insert())) + } + Step::Corner => None, } } fn on_enter(&mut self) -> CmdResult { match self.step { - // Bare Enter at the insertion-point step cancels (unchanged). - XAttachStep::Point => CmdResult::Cancel, - // Bare Enter accepts the default scale / rotation. - XAttachStep::Scale { point } => { - self.step = XAttachStep::Rotate { - point, - sx: 1.0, - sy: 1.0, - sz: 1.0, + Step::Insert => { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + Step::Preset(_) => { + self.step = Step::Insert; + CmdResult::NeedPoint + } + Step::XScale | Step::XyzX => { + self.scale = [1.0; 3]; + self.step = if self.step == Step::XScale { + Step::YScale + } else { + Step::XyzY }; CmdResult::NeedPoint } - XAttachStep::Rotate { point, sx, sy, sz } => { - CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, 0.0)) + Step::YScale => { + self.scale[1] = self.scale[0]; + self.scale_done() + } + Step::XyzY => { + self.scale[1] = self.scale[0]; + self.step = Step::XyzZ; + CmdResult::NeedPoint + } + Step::XyzZ => { + self.scale[2] = self.scale[0]; + self.scale_done() + } + Step::Corner => CmdResult::NeedPoint, + Step::Rotation => { + self.rotation = 0.0; + CmdResult::CommitAndExit(self.commit_insert()) } } } - fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { - // XREF-Task6: PScale drag preview — no framework hook; revisit - vec![] + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if self.preview.is_empty() { + return Vec::new(); + } + let (origin, scale, degrees) = self.preview_placement(self.plane.to_local(pt)); + let (sin, cos) = degrees.to_radians().sin_cos(); + let unit = self.unit; + let plane = self.plane; + let place = |p: DVec3| { + let (x, y, z) = (p.x * scale[0] * unit, p.y * scale[1] * unit, p.z * scale[2] * unit); + plane.to_world(origin + DVec3::new(x * cos - y * sin, x * sin + y * cos, z)) + }; + self.preview.iter().map(|wire| wire.mapped(place)).collect() } - fn xattach_path(&self) -> Option { - Some(self.path.clone()) + fn xattach_request(&self) -> Option { + Some(self.request.clone()) } } -/// Derive a block name from the file path: take the file stem, uppercase it. +/// Reference name for a file: its name without the extension, case kept. pub fn path_to_block_name(path: &str) -> String { - let p = std::path::Path::new(path); - p.file_stem() - .map(|s| s.to_string_lossy().to_uppercase()) + let normalized = path.replace('\\', "/"); + std::path::Path::new(&normalized) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "XREF".to_string()) } -/// Collision-free block name: `stem` unless taken (case-insensitive — block -/// names are uppercase by convention), else `stem_1`, `stem_2`, ... -/// (`$0$` numbering belongs to Bind, not Attach.) +/// Collision-free block name: `stem` unless taken (case-insensitive), else +/// `stem_1`, `stem_2`, ... (`$0$` numbering belongs to Bind, not Attach.) pub fn unique_block_name(stem: &str, taken: &[impl AsRef]) -> String { let upper_taken: Vec = taken.iter().map(|t| t.as_ref().to_uppercase()).collect(); if !upper_taken.iter().any(|t| t == &stem.to_uppercase()) { @@ -215,10 +553,6 @@ pub fn unique_block_name(stem: &str, taken: &[impl AsRef]) -> String { } } -/// Attach-default-Full store path: a relative incoming path is joined onto -/// the host drawing's base dir (yielding an absolute path when the base dir -/// is absolute); absolute paths and missing base dirs pass through verbatim. -/// Separators are normalized to `/` for stable DWG storage. fn is_absolute_xref_path(path: &str) -> bool { let bytes = path.as_bytes(); std::path::Path::new(path).is_absolute() @@ -229,6 +563,9 @@ fn is_absolute_xref_path(path: &str) -> bool { && matches!(bytes[2], b'/' | b'\\')) } +/// Full store path: a relative incoming path is joined onto the host +/// drawing's base dir; absolute paths and missing base dirs pass through. +/// Separators are normalized to `/` for stable DWG storage. pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Path>) -> String { let p = std::path::Path::new(path); if is_absolute_xref_path(path) { @@ -240,14 +577,6 @@ pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Pat } } -// Host-path note (XREF-Task6): `Scene` exposes no host file path — it owns -// the document plus view state only (verified in scene/mod.rs: no file path -// field). The host drawing path lives one layer up, in `Tab::current_path` -// (app/document.rs). The self-attach guard therefore runs at the command -// driver layer, which owns both the tab path and the pending XATTACH path, -// via this pure, unit-tested helper. `prepare_xref_block` below stays -// side-effect-only and keeps its `-> String` return. - /// True when attaching `ref_path` would attach the host drawing into itself. /// Lexical comparison only (no filesystem touch — the target may be missing); /// a relative ref is resolved against `host_base_dir` (falling back to the @@ -271,46 +600,52 @@ pub fn is_self_attach( normalize_lexical(&candidate) == normalize_lexical(&host_file.to_string_lossy()) } -/// Parse a typed scale: a single uniform value, or an X/Y/Z triplet separated -/// by commas, semicolons or whitespace. Zero never scales (rejected, like -/// INSERT); unparseable input returns None and the caller re-prompts. -fn parse_scale_spec(text: &str) -> Option<(f64, f64, f64)> { - let norm = text.trim().replace([',', ';'], " "); - let parts: Vec<&str> = norm.split_whitespace().collect(); - match parts.as_slice() { - [single] => single - .parse::() - .ok() - .filter(|v| *v != 0.0) - .map(|v| (v, v, 1.0)), - [x, y, z] => { - let parsed: Option> = - [x, y, z].iter().map(|s| s.parse::().ok()).collect(); - match parsed { - Some(v) if v.iter().all(|n| *n != 0.0) => Some((v[0], v[1], v[2])), - _ => None, - } - } - _ => None, +/// The drawing's existing reference for `name`, if any (case-insensitive). +pub fn existing_reference(scene: &Scene, name: &str) -> Option { + scene + .document + .block_records + .iter() + .find(|br| { + (br.flags.is_xref || br.flags.is_xref_overlay) && br.name.eq_ignore_ascii_case(name) + }) + .map(|br| br.name.clone()) +} + +/// The path to store for `request`: full, relative to the saved host, or +/// the file name alone. A relative path needs a saved host; until then the +/// full path is kept, as the reference does. +pub fn stored_path(request: &XrefAttachRequest, host_file: Option<&std::path::Path>) -> String { + let full = resolve_xref_store_path(&request.path, None); + match request.path_type { + Pathtype::Full => full, + Pathtype::Relative => host_file + .and_then(|host| { + crate::io::xref_model::to_pathtype_result(&full, host, Pathtype::Relative).ok() + }) + .unwrap_or(full), + Pathtype::None => std::path::Path::new(&full.replace('\\', "/")) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(full), } } -/// Create the XREF BlockRecord + Block/EndBlock entities in the scene document -/// for a given file path. Returns the block name. +/// Create (or reuse) the reference definition for `request` and load its +/// content. Returns the block name the INSERT must use. /// -/// This must be called before committing the INSERT so that the block -/// definition exists when the renderer looks it up. -/// -/// Name collisions get a `_1`, `_2`... suffix via [`unique_block_name`]; the -/// stored path follows Attach-default-Full via [`resolve_xref_store_path`]. -/// Self-attach blocking is NOT done here — see the note on [`is_self_attach`]; -/// the driver checks it before calling. -pub fn prepare_xref_block( +/// A reference of the same name already in the drawing is reused ("Using +/// existing definition."). The self-attach guard runs before this, where the +/// host path is known. +pub fn prepare_xref_definition( scene: &mut Scene, - path: &str, - host_base_dir: Option<&std::path::Path>, + request: &XrefAttachRequest, + host_file: Option<&std::path::Path>, ) -> String { - let stem = path_to_block_name(path); + let stem = path_to_block_name(&request.path); + if let Some(existing) = existing_reference(scene, &stem) { + return existing; + } let taken: Vec = scene .document .block_records @@ -318,20 +653,13 @@ pub fn prepare_xref_block( .map(|b| b.name.clone()) .collect(); let block_name = unique_block_name(&stem, &taken); + let store_path = stored_path(request, host_file); - // If a BlockRecord already exists with this name, skip creation. - if scene.document.block_records.get(&block_name).is_some() { - return block_name; - } - - let store_path = resolve_xref_store_path(path, host_base_dir); - - // Create the BlockRecord. let mut br = BlockRecord::new(&block_name); br.handle = scene.document.allocate_handle(); br.flags = BlockFlags { - is_xref: true, - is_xref_overlay: false, + is_xref: !request.overlay, + is_xref_overlay: request.overlay, anonymous: false, has_attributes: false, is_external: false, @@ -339,33 +667,50 @@ pub fn prepare_xref_block( is_xref_unloaded: false, }; br.xref_path = store_path.clone(); + let key = br.handle; let _ = scene.document.block_records.add(br); - // Create BLOCK entity. - let b = Block::new(&block_name, Vector3::zero()).with_xref_path(&store_path); - let _ = scene.document.add_entity(EntityType::Block(b)); - let _ = scene - .document - .add_entity(EntityType::BlockEnd(BlockEnd::new())); + // BLOCK / ENDBLK markers linked through the record. + crate::io::xref::ensure_block_entities(&mut scene.document, &block_name); - // Resolve the XREF content immediately. - // Every reference in this host resolves relative to the host drawing, not - // relative to the newly-attached file. Using `store_path.parent()` here - // made an attach unexpectedly redirect existing relative xrefs. - if let Some(base_dir) = host_base_dir { - let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); - } else if let Some(base_dir) = std::path::Path::new(&store_path).parent() { - let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); + // Load this reference only. A stored relative path resolves against the + // host folder; a full path or bare file name against the chosen file's. + let file_dir = std::path::Path::new(&resolve_xref_store_path(&request.path, None)) + .parent() + .map(|p| p.to_path_buf()); + let base_dir = match request.path_type { + Pathtype::Relative => host_file.and_then(|h| h.parent()).map(|p| p.to_path_buf()), + _ => None, + } + .or(file_dir); + if let Some(base_dir) = base_dir { + let keys: rustc_hash::FxHashSet = [key].into_iter().collect(); + let _ = crate::io::xref::resolve_xrefs_for_keys(&mut scene.document, &base_dir, &keys); } block_name } +/// Full-path attach of `path` (kept for callers that only have a path). +#[cfg(test)] +pub fn prepare_xref_block( + scene: &mut Scene, + path: &str, + host_base_dir: Option<&std::path::Path>, +) -> String { + let request = XrefAttachRequest { + path: resolve_xref_store_path(path, host_base_dir), + overlay: false, + path_type: Pathtype::Full, + }; + prepare_xref_definition(scene, &request, None) +} + #[cfg(test)] mod tests { use super::{ - is_self_attach, parse_scale_spec, path_to_block_name, resolve_xref_store_path, - unique_block_name, XAttachCommand, + is_self_attach, path_to_block_name, resolve_xref_store_path, unique_block_name, + XAttachCommand, }; use crate::command::{CadCommand, CmdResult}; use acadrust::EntityType; @@ -385,8 +730,9 @@ mod tests { } #[test] - fn block_name_derives_from_stem() { - assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "PLAN"); + fn block_name_keeps_the_file_name_case() { + assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "plan"); + assert_eq!(path_to_block_name("C:\\refs\\Site Plan.dwg"), "Site Plan"); } #[test] @@ -407,96 +753,91 @@ mod tests { fn self_attach_guard_matches_normalized_paths() { let host = std::path::Path::new("C:/Drawings/host.dwg"); let base = std::path::Path::new("C:/Drawings"); - // Verbatim, drive-letter case, dot segments, and base-relative forms. assert!(is_self_attach(host, "C:/Drawings/host.dwg", Some(base))); assert!(is_self_attach(host, "c:/Drawings/host.dwg", Some(base))); assert!(is_self_attach(host, "C:/Drawings/./host.dwg", Some(base))); assert!(is_self_attach(host, "host.dwg", Some(base))); assert!(is_self_attach(host, "C:/Drawings/host.dwg", None)); - // Anything else attaches freely. assert!(!is_self_attach(host, "C:/Drawings/other.dwg", Some(base))); assert!(!is_self_attach(host, "other.dwg", Some(base))); assert!(!is_self_attach(host, "other.dwg", None)); } #[test] - fn scale_spec_parses_single_and_triplet() { - assert_eq!(parse_scale_spec("2"), Some((2.0, 2.0, 1.0))); - assert_eq!(parse_scale_spec("2,3,4"), Some((2.0, 3.0, 4.0))); - assert_eq!(parse_scale_spec("2 3 4"), Some((2.0, 3.0, 4.0))); - assert_eq!(parse_scale_spec("0"), None); - assert_eq!(parse_scale_spec("abc"), None); - assert_eq!(parse_scale_spec("1,2"), None); + fn enter_at_insertion_point_asks_again() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + assert!(matches!(cmd.on_enter(), CmdResult::ReportError(_))); + assert!(cmd.prompt().contains("Specify insertion point")); } - #[test] - fn enter_at_insertion_point_cancels() { - let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); - } - - #[test] - fn placement_flow_scale_then_rotate_commits() { - let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!( - cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), - CmdResult::NeedPoint - )); - assert!(cmd.prompt().contains("scale")); - assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint))); - assert!(cmd.prompt().contains("rotation")); - match cmd.on_enter() { - CmdResult::CommitAndExit(EntityType::Insert(ins)) => { - assert_eq!(ins.block_name, "PLAN"); - assert!((ins.x_scale() - 2.0).abs() < 1e-9); - assert!((ins.y_scale() - 2.0).abs() < 1e-9); - assert!(ins.rotation.abs() < 1e-9); - assert!((ins.insert_point.x - 10.0).abs() < 1e-9); - assert!((ins.insert_point.y - 20.0).abs() < 1e-9); - } + fn committed(result: Option) -> acadrust::entities::Insert { + match result { + Some(CmdResult::CommitAndExit(EntityType::Insert(ins))) => ins, _ => panic!("expected CommitAndExit(Insert)"), } } #[test] - fn placement_flow_triplet_and_typed_rotation() { + fn x_scale_then_default_y_and_rotation() { let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!( - cmd.on_point(DVec3::ZERO), - CmdResult::NeedPoint - )); - assert!(matches!( - cmd.on_text_input("2,3,4"), - Some(CmdResult::NeedPoint) - )); - match cmd.on_text_input("90") { - Some(CmdResult::CommitAndExit(EntityType::Insert(ins))) => { - assert!((ins.x_scale() - 2.0).abs() < 1e-9); - assert!((ins.y_scale() - 3.0).abs() < 1e-9); - assert!((ins.z_scale() - 4.0).abs() < 1e-9); - assert!((ins.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-6); - } - _ => panic!("expected CommitAndExit(Insert)"), - } + assert!(matches!(cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), CmdResult::NeedPoint)); + assert!(cmd.prompt().contains("Enter X scale factor")); + assert!(matches!(cmd.on_text_input("-2"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("Enter Y scale factor")); + assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint)); + assert!(cmd.prompt().contains("Specify rotation angle <0>")); + let ins = committed(Some(cmd.on_enter())); + assert_eq!(ins.block_name, "plan"); + assert!((ins.x_scale() + 2.0).abs() < 1e-9); + assert!((ins.y_scale() + 2.0).abs() < 1e-9); + // Z follows the size of X, not its sign. + assert!((ins.z_scale() - 2.0).abs() < 1e-9); + assert!((ins.insert_point.x - 10.0).abs() < 1e-9); } #[test] - fn placement_flow_corner_pick_sets_xy_scale() { + fn zero_scale_is_refused() { let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); - assert!(matches!( - cmd.on_point(DVec3::ZERO), - CmdResult::NeedPoint - )); - assert!(matches!( - cmd.on_point(DVec3::new(3.0, 4.0, 0.0)), - CmdResult::NeedPoint - )); - match cmd.on_enter() { - CmdResult::CommitAndExit(EntityType::Insert(ins)) => { - assert!((ins.x_scale() - 3.0).abs() < 1e-9); - assert!((ins.y_scale() - 4.0).abs() < 1e-9); - } - _ => panic!("expected CommitAndExit(Insert)"), - } + cmd.on_point(DVec3::ZERO); + assert!(matches!(cmd.on_text_input("0"), Some(CmdResult::ReportError(_)))); + assert!(cmd.prompt().contains("Enter X scale factor")); + } + + #[test] + fn xyz_option_takes_three_factors() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + cmd.on_point(DVec3::ZERO); + cmd.on_text_input("XYZ"); + cmd.on_text_input("2"); + cmd.on_text_input("3"); + cmd.on_text_input("4"); + let ins = committed(cmd.on_text_input("90")); + assert!((ins.x_scale() - 2.0).abs() < 1e-9); + assert!((ins.y_scale() - 3.0).abs() < 1e-9); + assert!((ins.z_scale() - 4.0).abs() < 1e-9); + assert!((ins.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + } + + #[test] + fn corner_pick_sets_x_and_y() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + cmd.on_point(DVec3::ZERO); + assert!(matches!(cmd.on_point(DVec3::new(5.0, 7.0, 0.0)), CmdResult::NeedPoint)); + let ins = committed(Some(cmd.on_enter())); + assert!((ins.x_scale() - 5.0).abs() < 1e-9); + assert!((ins.y_scale() - 7.0).abs() < 1e-9); + assert!((ins.z_scale() - 5.0).abs() < 1e-9); + } + + #[test] + fn scale_preset_skips_the_scale_prompts() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + cmd.on_text_input("S"); + assert!(cmd.prompt().contains("Specify scale factor for XYZ axes")); + cmd.on_text_input("2"); + cmd.on_point(DVec3::ZERO); + assert!(cmd.prompt().contains("Specify rotation angle <0>")); + let ins = committed(Some(cmd.on_enter())); + assert!((ins.z_scale() - 2.0).abs() < 1e-9); } } diff --git a/src/modules/insert/xref_cmd.rs b/src/modules/insert/xref_cmd.rs new file mode 100644 index 000000000..96a698c2a --- /dev/null +++ b/src/modules/insert/xref_cmd.rs @@ -0,0 +1,237 @@ +// -XREF — the command-line form of the reference manager. +// +// Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] : +// +// Each option collects its answers with the reference prompts and hands them +// to the existing reference operations through `CmdResult::Dispatch`. + +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Step { + Option, + List, + Names(&'static str), + File { overlay: bool }, + PathNames, + NewPath { name: String }, + PathTypeNames, + PathTypeKind { names: String }, + ShowNames, +} + +pub struct XrefCommand { + step: Step, + /// Saved paths by reference name, for the Path option's "Old path:" line. + saved_paths: Vec<(String, String)>, +} + +impl XrefCommand { + pub fn new(saved_paths: Vec<(String, String)>) -> Self { + Self { + step: Step::Option, + saved_paths, + } + } + + /// Whether any reference name matches `pattern` (`*` / `?` wildcards). + fn matches(&self, pattern: &str) -> bool { + self.saved_paths + .iter() + .any(|(name, _)| crate::io::xref_model::wildcard_match(name, pattern)) + } + + fn choose(&mut self, token: &str) -> CmdResult { + self.step = match token { + "?" => Step::List, + "B" | "BIND" => Step::Names("Bind"), + "D" | "DETACH" => Step::Names("Detach"), + "P" | "PATH" => Step::PathNames, + "T" | "PATHTYPE" => Step::PathTypeNames, + "U" | "UNLOAD" => Step::Names("Unload"), + "R" | "RELOAD" => Step::Names("Reload"), + "O" | "OVERLAY" => Step::File { overlay: true }, + "A" | "ATTACH" => Step::File { overlay: false }, + "S" | "SHOW" => Step::ShowNames, + _ => { + return CmdResult::ReportError("Invalid option keyword.".to_string()); + } + }; + CmdResult::NeedPoint + } +} + +impl CadCommand for XrefCommand { + fn name(&self) -> &'static str { + "-XREF" + } + + fn prompt(&self) -> String { + let text = match &self.step { + Step::Option => "Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] :", + Step::List => "Enter xref name(s) to list <*>:", + Step::Names("Bind") => "Enter xref name(s) to bind:", + Step::Names("Detach") => "Enter xref name(s) to detach:", + Step::Names("Unload") => "Enter xref name(s) to unload:", + Step::Names(_) => "Enter xref name(s) to reload:", + Step::File { overlay: false } => "Enter name of file to attach:", + Step::File { overlay: true } => "Enter name of file to overlay:", + Step::PathNames => "Edit xref name(s) to edit path:", + Step::NewPath { .. } => "Enter new path:", + Step::PathTypeNames => "Enter xref name(s) to edit path type:", + Step::PathTypeKind { .. } => "Enter new path type [Full/Relative/None]:", + Step::ShowNames => "Enter xref name(s) to show:", + }; + format!("-XREF {text}") + } + + fn options(&self) -> Vec { + match self.step { + Step::Option => vec![ + CmdOption::new("?", "?"), + CmdOption::new("Bind", "B"), + CmdOption::new("Detach", "D"), + CmdOption::new("Path", "P"), + CmdOption::new("pathType", "T"), + CmdOption::new("Unload", "U"), + CmdOption::new("Reload", "R"), + CmdOption::new("Overlay", "O"), + CmdOption::new("Attach", "A"), + CmdOption::new("Show", "S"), + ], + Step::PathTypeKind { .. } => vec![ + CmdOption::new("Full", "F"), + CmdOption::new("Relative", "R"), + CmdOption::new("None", "N"), + ], + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + // Paths and names can hold spaces. + Step::File { .. } | Step::NewPath { .. } => InputKind::FreeText, + _ => InputKind::SingleToken, + } + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + let value = text.trim().trim_matches('"').to_string(); + let token = value.to_uppercase(); + let names_step = matches!( + self.step, + Step::Names(_) | Step::PathTypeNames | Step::ShowNames + ); + if names_step && !self.matches(&value) { + return Some(CmdResult::Measurement( + "No matching xref names found.".to_string(), + )); + } + Some(match self.step.clone() { + Step::Option => self.choose(&token), + Step::List => CmdResult::Dispatch(format!("XREFLIST {value}")), + Step::Names(op) => CmdResult::Dispatch(format!("XREF {op} {value}")), + Step::File { overlay } => { + let verb = if overlay { "XREFOVERLAYFILE" } else { "XREFATTACHFILE" }; + CmdResult::Dispatch(format!("{verb} {value}")) + } + Step::PathNames => { + let Some((name, old)) = self + .saved_paths + .iter() + .find(|(name, _)| crate::io::xref_model::wildcard_match(name, &value)) + .cloned() + else { + return Some(CmdResult::Measurement( + "No matching xref names found.".to_string(), + )); + }; + self.step = Step::NewPath { name: name.clone() }; + CmdResult::ReportMeasurement(format!( + "{}\n{}", + format!("xref name: \"{}\"", name), + format!("Old path: {}", display_path(&old)) + )) + } + Step::NewPath { name } => CmdResult::Dispatch(format!("XREF Path {name} {value}")), + Step::PathTypeNames => { + self.step = Step::PathTypeKind { names: value }; + CmdResult::NeedPoint + } + Step::PathTypeKind { names } => { + let kind = match token.as_str() { + "F" | "FULL" => "Full", + "R" | "RELATIVE" => "Relative", + "N" | "NONE" => "None", + _ => { + return Some(CmdResult::ReportError( + "Invalid option keyword.".to_string(), + )) + } + }; + CmdResult::Dispatch(format!("XREF Pathtype {kind} {names}")) + } + Step::ShowNames => CmdResult::Dispatch("XREFSHOW".to_string()), + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step.clone() { + Step::Option => self.choose("A"), + Step::List => CmdResult::Dispatch("XREFLIST *".to_string()), + Step::NewPath { .. } => { + CmdResult::Measurement("Path unchanged.".to_string()) + } + Step::PathTypeKind { .. } => CmdResult::NeedPoint, + _ => CmdResult::Cancel, + } + } +} + +/// A stored path shown the way the platform writes it. +pub fn display_path(path: &str) -> String { + if cfg!(windows) { + path.replace('/', "\\") + } else { + path.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::XrefCommand; + use crate::command::{CadCommand, CmdResult}; + + #[test] + fn enter_chooses_attach() { + let mut cmd = XrefCommand::new(Vec::new()); + assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint)); + assert!(cmd.prompt().contains("Enter name of file to attach:")); + } + + #[test] + fn list_defaults_to_every_reference() { + let mut cmd = XrefCommand::new(Vec::new()); + cmd.on_text_input("?"); + assert!(cmd.prompt().contains("Enter xref name(s) to list <*>:")); + assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(ref s) if s == "XREFLIST *")); + } + + #[test] + fn overlay_file_is_dispatched() { + let mut cmd = XrefCommand::new(Vec::new()); + cmd.on_text_input("O"); + assert!(cmd.prompt().contains("Enter name of file to overlay:")); + assert!(matches!( + cmd.on_text_input("C:/refs/site plan.dwg"), + Some(CmdResult::Dispatch(ref s)) if s == "XREFOVERLAYFILE C:/refs/site plan.dwg" + )); + } +} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 1aefcb675..a4e28e789 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -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 { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 6343a6ae7..bf44295ce 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2884,11 +2884,11 @@ impl Scene { /// standard linetypes and a compatible page setup on every paper layout. pub fn populate_new_drawing_defaults(&mut self) { crate::io::linetypes::populate_document(&mut self.document); - // A new drawing is metric, as the reference's ISO template: millimetre + // A new drawing is metric, as the reference's new drawing: metre // insertion units, metric measurement, and the ISO-25 dimension style // current. Standard stays available for imperial work. self.document.header.measurement = 1; - self.document.header.insertion_units = 4; + self.document.header.insertion_units = 6; let iso = crate::scene::creation_style::ensure_iso_dim_style(&mut self.document); self.document.header.current_dimstyle_name = "ISO-25".to_string(); // The name alone leaves the header pointing at the previous style, and diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index d1fc50bb8..5007a8a06 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -633,6 +633,28 @@ impl WireModel { out } + /// Return a preview clone with every point mapped through `map` (an + /// INSERT-style placement: per-axis scale, rotation, translation). + /// Tangent geometry is dropped; a preview is not snapped to. + pub fn mapped(&self, map: impl Fn(glam::DVec3) -> glam::DVec3) -> Self { + let mut out = self.clone(); + out.name = format!("preview_{}", self.name); + out.color = Self::CYAN; + out.selected = false; + map_points(&mut out.points, &mut out.points_low, &map); + if let Some(marker) = &mut out.point_marker { + marker.origin = map(marker.origin); + } + if !out.text_verts.is_empty() { + out.text_verts = map_text_verts(&self.text_verts, |x, y, z| { + let p = map(glam::DVec3::new(x, y, z)); + (p.x, p.y, p.z) + }); + } + out.tangent_geoms.clear(); + out + } + /// Return a clone with every point rotated around `center` by `angle_rad`. pub fn rotated(&self, center: glam::Vec3, angle_rad: f32) -> Self { self.rotated_about_axis(center, glam::Vec3::Z, angle_rad) diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 75cea5769..5073640fb 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -2507,7 +2507,9 @@ impl super::Scene { } /// The document as written to a file: a dynamic dimension's screen-size - /// overrides are a display matter and stay out of the file. + /// overrides are a display matter and stay out of the file, and so does + /// what resolving an external reference merged in (its geometry, nested + /// blocks and styles), which the file never stores. pub(crate) fn document_for_save(&self) -> acadrust::CadDocument { use crate::entities::dim_override as ov; let mut document = self.document.clone(); @@ -2530,6 +2532,7 @@ impl super::Scene { } } } + crate::io::xref::strip_resolved_xref_content(&mut document); document } diff --git a/src/ui/style/form.rs b/src/ui/style/form.rs index 050fa0eb4..71edfe5ed 100644 --- a/src/ui/style/form.rs +++ b/src/ui/style/form.rs @@ -7,10 +7,44 @@ use crate::app::Message; use crate::ui::style::common::muted_style; -use iced::widget::{button, container, pick_list, row, text, text_input, Space}; +use iced::widget::{button, container, pick_list, radio, row, text, text_input, Space}; use iced::{Background, Border, Element, Length, Theme}; use std::borrow::Cow; +/// Standard dialog footer action button (OK, Cancel, Apply, Close, Help, etc.), +/// matching the Drafting Settings / Options appearance: 12px text, [6, 18] padding, +/// themed primary or secondary style. +pub fn dialog_button<'a>( + label: impl text::IntoFragment<'a>, + on_press: Message, + primary: bool, +) -> button::Button<'a, Message> { + button(text(label).size(12)) + .on_press(on_press) + .padding([6, 18]) + .style(if primary { + button::primary + } else { + button::secondary + }) +} + +/// Unified radio button helper ensuring consistent 16px size for exact pixel-grid concentric alignment. +pub fn form_radio<'a, T>( + label: impl Into, + value: T, + selected: Option, + on_click: impl Fn(T) -> Message + 'a, +) -> radio::Radio<'a, Message> +where + T: Copy + Eq + 'a, +{ + radio(label, value, selected, on_click) + .size(16) + .spacing(6) + .text_size(11) +} + /// Width of the label column in a `label : control` row. pub const LABEL_WIDTH: f32 = 92.0; diff --git a/src/ui/style/point_style.rs b/src/ui/style/point_style.rs index b6feb50dc..a5c817f44 100644 --- a/src/ui/style/point_style.rs +++ b/src/ui/style/point_style.rs @@ -168,7 +168,7 @@ pub fn view_window<'a>( Some(relative), Message::PointStyleSizeRelative, ) - .size(15) + .size(16) .text_size(13), radio( t!("Set Size in Absolute Units"), @@ -176,7 +176,7 @@ pub fn view_window<'a>( Some(relative), Message::PointStyleSizeRelative, ) - .size(15) + .size(16) .text_size(13), ] .spacing(6); diff --git a/src/ui/window/block_definition.rs b/src/ui/window/block_definition.rs index f1c296f11..64b71a9fc 100644 --- a/src/ui/window/block_definition.rs +++ b/src/ui/window/block_definition.rs @@ -5,7 +5,7 @@ use std::fmt; use acadrust::Handle; use iced::widget::{ - button, checkbox, column, combo_box, container, mouse_area, pick_list, radio, row, stack, text, + button, checkbox, column, combo_box, container, mouse_area, pick_list, row, stack, text, text_editor, text_input, Space, }; use iced::{Background, Border, Element, Fill, Length, Theme}; @@ -14,7 +14,7 @@ use crate::app::Message; use crate::modules::draw::units; use crate::t; use crate::ui::style::common::muted_style; -use crate::ui::style::form::{button_style, field_style}; +use crate::ui::style::form::{button_style, dialog_button, field_style, form_radio}; static ICON_PICK_POINT: &[u8] = include_bytes!("../../../assets/icons/blocks/pick_point.svg"); static ICON_SELECT_OBJECTS: &[u8] = @@ -353,33 +353,24 @@ pub fn view_window<'a>( let btns_row = row![sel_obj_btn, qsel_btn].spacing(5).align_y(iced::Center); let radios = column![ - radio( + form_radio( t!("Retain"), BlockObjectMode::Retain, Some(state.object_mode), Message::BlockDefObjectMode, - ) - .size(15) - .spacing(5) - .text_size(11), - radio( + ), + form_radio( t!("Convert to block"), BlockObjectMode::Convert, Some(state.object_mode), Message::BlockDefObjectMode, - ) - .size(15) - .spacing(5) - .text_size(11), - radio( + ), + form_radio( t!("Delete"), BlockObjectMode::Delete, Some(state.object_mode), Message::BlockDefObjectMode, - ) - .size(15) - .spacing(5) - .text_size(11), + ), ] .spacing(2); @@ -580,20 +571,11 @@ pub fn view_window<'a>( // ── Footer Bar ──────────────────────────────────────────────────────────── let actions = row![ Space::new().width(Fill), - button(text(t!("OK")).size(11)) - .on_press(Message::BlockDefApply) - .style(button_style(true)) - .padding([4, 16]), - button(text(t!("Cancel")).size(11)) - .on_press(Message::CloseModal) - .style(button_style(false)) - .padding([4, 12]), - button(text(t!("Help")).size(11)) - .on_press(Message::BlockDefHelp) - .style(button_style(false)) - .padding([4, 12]), + dialog_button(t!("OK"), Message::BlockDefApply, true), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button(t!("Help"), Message::BlockDefHelp, false), ] - .spacing(6) + .spacing(8) .align_y(iced::Center); // ── Error Banner ────────────────────────────────────────────────────────── @@ -664,15 +646,9 @@ pub fn view_window<'a>( .align_x(iced::alignment::Horizontal::Center), Space::new().height(16), row![ - button(text(t!("Yes")).size(12)) - .on_press(Message::BlockDefConfirmRedefine(true)) - .padding([6, 20]) - .style(button_style(false)), + dialog_button(t!("Yes"), Message::BlockDefConfirmRedefine(true), false), // Default / safe focus on "No" - button(text(t!("No")).size(12)) - .on_press(Message::BlockDefConfirmRedefine(false)) - .padding([6, 22]) - .style(button_style(true)), + dialog_button(t!("No"), Message::BlockDefConfirmRedefine(false), true), ] .spacing(12) .align_y(iced::Center), diff --git a/src/ui/window/missing_fonts.rs b/src/ui/window/missing_fonts.rs index cdc002d15..875dedc62 100644 --- a/src/ui/window/missing_fonts.rs +++ b/src/ui/window/missing_fonts.rs @@ -17,6 +17,7 @@ fn muted_style(theme: &Theme) -> text::Style { pub fn view_window<'a>( fonts: &'a [String], font_source: &'a str, + downloading: bool, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let content_width = if matches!(sizing.width, Length::Fill) { @@ -33,16 +34,24 @@ pub fn view_window<'a>( .iter() .map(|name| text(format!(" {name}")).size(12).into()) .collect(); + let download = button( + text(if downloading { + crate::t!("Downloading...") + } else { + crate::t!("Download available") + }) + .size(12), + ) + .on_press_maybe((!downloading).then_some(Message::MissingFontsDownload)) + .style(button::primary) + .padding([6, 14]); let actions = row![ Space::new().width(content_width), button(text(crate::t!("Skip")).size(12)) - .on_press(Message::MissingFontsDismiss) + .on_press_maybe((!downloading).then_some(Message::MissingFontsDismiss)) .style(button::secondary) .padding([6, 14]), - button(text(crate::t!("Download")).size(12)) - .on_press(Message::MissingFontsDownload) - .style(button::primary) - .padding([6, 14]), + download, ] .spacing(8); @@ -50,7 +59,7 @@ pub fn view_window<'a>( column![ text(crate::t!("Missing fonts")).size(20), text(crate::t!( - "This drawing uses fonts that are not installed on this machine. Download them from the OpenCADStudio community font repository?" + "This drawing uses fonts that are not installed on this machine. OpenCADStudio can download any redistributable fonts that exist in the selected repository; unavailable fonts will keep using a substitute." )) .size(11) .style(muted_style), diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 77d4608ef..20146b6cd 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -25,5 +25,7 @@ pub mod alias_editor; pub mod find_replace; pub mod named_parameters; pub mod pdf_dialogs; +pub mod xref_attach; pub mod xref_help; pub mod xref_manager; +pub mod wblock; diff --git a/src/ui/window/wblock.rs b/src/ui/window/wblock.rs new file mode 100644 index 000000000..81d7f07ba --- /dev/null +++ b/src/ui/window/wblock.rs @@ -0,0 +1,492 @@ +//! Write Block (WBLOCK) dialog window — write a block, selected entities, or entire drawing to an external DWG/DXF file. + +use std::fmt; + +use acadrust::Handle; +use iced::widget::{ + button, column, combo_box, container, pick_list, row, text, text_input, Space, +}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::modules::draw::units; +use crate::t; +use crate::ui::style::form::{button_style, dialog_button, field_style, form_radio}; + +static ICON_PICK_POINT: &[u8] = include_bytes!("../../../assets/icons/blocks/pick_point.svg"); +static ICON_SELECT_OBJECTS: &[u8] = + include_bytes!("../../../assets/icons/blocks/select_objects.svg"); +static ICON_QUICK_SELECT: &[u8] = include_bytes!("../../../assets/icons/blocks/quick_select.svg"); +static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg"); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WblockSourceMode { + Block, + EntireDrawing, + Objects, +} + +impl Default for WblockSourceMode { + fn default() -> Self { + WblockSourceMode::Objects + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WblockObjectMode { + Retain, + Convert, + Delete, +} + +impl Default for WblockObjectMode { + fn default() -> Self { + WblockObjectMode::Retain + } +} + +/// Dropdown entry for the Unit picker. +#[derive(Clone, PartialEq, Eq)] +pub struct UnitChoice { + pub code: i16, + pub label: &'static str, +} + +impl fmt::Display for UnitChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = crate::i18n::translate(self.label); + f.write_str(name.as_ref()) + } +} + +/// The Write Block dialog's state. +pub struct WblockState { + pub source_mode: WblockSourceMode, + pub block_name: String, + pub existing_blocks: Vec, + pub block_combo: combo_box::State, + + pub base_point_x: String, + pub base_point_y: String, + pub base_point_z: String, + + pub selected_handles: Vec, + pub object_mode: WblockObjectMode, + + pub file_path: String, + pub unit: i16, + + pub error_message: Option, +} + +impl Clone for WblockState { + fn clone(&self) -> Self { + Self { + source_mode: self.source_mode, + block_name: self.block_name.clone(), + existing_blocks: self.existing_blocks.clone(), + block_combo: self.block_combo.clone(), + base_point_x: self.base_point_x.clone(), + base_point_y: self.base_point_y.clone(), + base_point_z: self.base_point_z.clone(), + selected_handles: self.selected_handles.clone(), + object_mode: self.object_mode, + file_path: self.file_path.clone(), + unit: self.unit, + error_message: self.error_message.clone(), + } + } +} + +impl WblockState { + pub fn new( + existing_blocks: Vec, + pre_selected: Vec, + default_unit: i16, + default_path: String, + ) -> Self { + let default_block = existing_blocks.first().cloned().unwrap_or_default(); + Self { + source_mode: WblockSourceMode::Objects, + block_name: default_block, + block_combo: combo_box::State::new(existing_blocks.clone()), + existing_blocks, + base_point_x: "0.00".to_string(), + base_point_y: "0.00".to_string(), + base_point_z: "0.00".to_string(), + selected_handles: pre_selected, + object_mode: WblockObjectMode::Retain, + file_path: default_path, + unit: default_unit, + error_message: None, + } + } + + pub fn parse_base_point(&self) -> glam::DVec3 { + let x = self.base_point_x.trim().parse::().unwrap_or(0.0); + let y = self.base_point_y.trim().parse::().unwrap_or(0.0); + let z = self.base_point_z.trim().parse::().unwrap_or(0.0); + glam::DVec3::new(x, y, z) + } +} + +fn group<'a>( + title: impl Into, + body: impl Into>, + height: impl Into, +) -> Element<'a, Message> { + container( + column![ + text(title.into()) + .size(11) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)), + }), + body.into(), + ] + .spacing(4), + ) + .padding([5, 8]) + .width(Fill) + .height(height) + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 4.0.into(), + color: theme.palette().background.strong.color, + }, + ..Default::default() + }) + .into() +} + +pub fn view_window<'a>( + state: &'a WblockState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let is_objects = state.source_mode == WblockSourceMode::Objects; + let is_block = state.source_mode == WblockSourceMode::Block; + + // ── Source: Block Radio & Dropdown ───────────────────────────────────────── + let block_radio = form_radio( + t!("Block:"), + WblockSourceMode::Block, + Some(state.source_mode), + Message::WblockSourceMode, + ); + + let block_dropdown: Element<'a, Message> = if is_block { + combo_box( + &state.block_combo, + "", + if state.block_name.is_empty() { + None + } else { + Some(&state.block_name) + }, + |chosen: String| Message::WblockBlockSelect(chosen), + ) + .on_input(Message::WblockBlockName) + .size(11) + .padding([2, 5]) + .width(Fill) + .into() + } else { + crate::ui::read_only::field(&state.block_name, 11.0, Fill) + }; + + let block_row = row![block_radio, block_dropdown] + .spacing(8) + .width(Fill) + .align_y(iced::Center); + + let entire_drawing_radio = form_radio( + t!("Entire drawing"), + WblockSourceMode::EntireDrawing, + Some(state.source_mode), + Message::WblockSourceMode, + ); + + let objects_radio = form_radio( + t!("Objects"), + WblockSourceMode::Objects, + Some(state.source_mode), + Message::WblockSourceMode, + ); + + // ── Source: Base Point Sub-group ────────────────────────────────────────── + let pick_pt_icon = crate::ui::icons::semantic(ICON_PICK_POINT, 14.0); + let mut pick_pt_btn = button( + row![pick_pt_icon, text(t!("Pick point")).size(11)] + .spacing(6) + .align_y(iced::Center), + ) + .padding([3, 10]); + + if is_objects { + pick_pt_btn = pick_pt_btn + .on_press(Message::WblockPickPoint) + .style(button_style(false)); + } else { + pick_pt_btn = pick_pt_btn.style(button_style(false)); + } + + let coord_row = |axis: &'static str, val: &'a str, on_input: fn(String) -> Message| { + let input: Element<'a, Message> = if is_objects { + text_input("", val) + .on_input(on_input) + .on_submit(Message::WblockApply) + .style(field_style) + .size(11) + .padding([2, 5]) + .width(Fill) + .into() + } else { + crate::ui::read_only::field(val, 11.0, Fill) + }; + row![ + text(axis) + .size(11) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)), + }) + .width(Length::Fixed(16.0)), + input, + ] + .spacing(4) + .align_y(iced::Center) + }; + + let base_point_group = group( + t!("Base point").into_owned(), + column![ + pick_pt_btn, + Space::new().height(3), + coord_row("X:", &state.base_point_x, Message::WblockBaseX), + coord_row("Y:", &state.base_point_y, Message::WblockBaseY), + coord_row("Z:", &state.base_point_z, Message::WblockBaseZ), + ] + .spacing(3), + Length::Fixed(142.0), + ); + + // ── Source: Objects Sub-group ───────────────────────────────────────────── + let sel_obj_icon = crate::ui::icons::semantic(ICON_SELECT_OBJECTS, 14.0); + let mut sel_obj_btn = button( + row![sel_obj_icon, text(t!("Select objects")).size(11)] + .spacing(6) + .align_y(iced::Center), + ) + .padding([3, 8]); + + let qsel_icon = crate::ui::icons::semantic(ICON_QUICK_SELECT, 14.0); + let mut qsel_btn = button(qsel_icon).padding([3, 6]); + + if is_objects { + sel_obj_btn = sel_obj_btn + .on_press(Message::WblockSelectObjects) + .style(button_style(false)); + qsel_btn = qsel_btn + .on_press(Message::WblockQuickSelect) + .style(button_style(false)); + } else { + sel_obj_btn = sel_obj_btn.style(button_style(false)); + qsel_btn = qsel_btn.style(button_style(false)); + } + + let obj_btns_row = row![sel_obj_btn, qsel_btn] + .spacing(5) + .align_y(iced::Center); + + let object_radios = column![ + form_radio( + t!("Retain"), + WblockObjectMode::Retain, + if is_objects { Some(state.object_mode) } else { None }, + Message::WblockObjectMode, + ), + form_radio( + t!("Convert to block"), + WblockObjectMode::Convert, + if is_objects { Some(state.object_mode) } else { None }, + Message::WblockObjectMode, + ), + form_radio( + t!("Delete from drawing"), + WblockObjectMode::Delete, + if is_objects { Some(state.object_mode) } else { None }, + Message::WblockObjectMode, + ), + ] + .spacing(3); + + let count = state.selected_handles.len(); + let status_row: Element<'a, Message> = if !is_objects { + Space::new().height(14).into() + } else if count == 0 { + row![ + crate::ui::icons::semantic(ICON_WARN, 13.0), + text(t!("No objects selected")) + .size(10) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().warning.base.color), + }), + ] + .spacing(5) + .align_y(iced::Center) + .into() + } else { + row![text(crate::tf!("{count} objects selected")) + .size(10) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().primary.base.color), + }),] + .spacing(5) + .align_y(iced::Center) + .into() + }; + + let objects_subgroup = group( + t!("Objects").into_owned(), + column![obj_btns_row, Space::new().height(2), object_radios, Space::new().height(2), status_row,] + .spacing(2), + Length::Fixed(142.0), + ); + + let sub_panels = row![base_point_group, objects_subgroup] + .spacing(8) + .width(Fill); + + let source_group = group( + t!("Source").into_owned(), + column![ + block_row, + entire_drawing_radio, + objects_radio, + Space::new().height(4), + sub_panels, + ] + .spacing(4), + Length::Shrink, + ); + + // ── Destination Group ───────────────────────────────────────────────────── + let file_input = text_input("", &state.file_path) + .on_input(Message::WblockFilePath) + .on_submit(Message::WblockApply) + .style(field_style) + .size(11) + .padding([3, 6]) + .width(Fill); + + let browse_btn = button( + container(text("...").size(11)) + .width(Fill) + .align_x(iced::alignment::Horizontal::Center), + ) + .width(Length::Fixed(28.0)) + .on_press(Message::WblockBrowsePath) + .style(button::secondary) + .padding([3, 0]); + + let path_row = row![file_input, browse_btn].spacing(5).align_y(iced::Center); + + let unit_options: Vec = units::all() + .map(|(code, label)| UnitChoice { code, label }) + .collect(); + + let selected_unit = unit_options + .iter() + .find(|choice| choice.code == state.unit) + .cloned(); + + let unit_picker = pick_list(selected_unit, unit_options, |choice| choice.to_string()) + .on_select(|choice| Message::WblockUnit(choice.code)) + .text_size(11) + .padding([2, 5]) + .width(Fill); + + let units_row = row![ + text(t!("Insert units:")) + .size(11) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)), + }), + Space::new().width(8), + unit_picker, + Space::new().width(Length::Fixed(33.0)), + ] + .spacing(4) + .align_y(iced::Center); + + let destination_group = group( + t!("Destination").into_owned(), + column![ + text(t!("File name and path:")) + .size(11) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)), + }), + path_row, + Space::new().height(4), + units_row, + ] + .spacing(3), + Length::Shrink, + ); + + // ── Footer Bar ──────────────────────────────────────────────────────────── + let actions = row![ + Space::new().width(Fill), + dialog_button(t!("OK"), Message::WblockApply, true), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button(t!("Help"), Message::WblockHelp, false), + ] + .spacing(8) + .align_y(iced::Center); + + // ── Error Banner ────────────────────────────────────────────────────────── + let mut main_column = column![source_group, destination_group, actions,] + .spacing(8) + .padding([8, 10]) + .width(sizing.width); + + if let Some(err) = &state.error_message { + let err_banner = container( + row![ + crate::ui::icons::semantic(ICON_WARN, 16.0), + text(err.clone()) + .size(11) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text), + }), + Space::new().width(Fill), + button(text("×").size(12)) + .on_press(Message::WblockDismissError) + .style(button_style(false)) + .padding([1, 6]), + ] + .spacing(8) + .align_y(iced::Center), + ) + .padding([6, 10]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().danger.base.color.scale_alpha(0.18), + )), + border: Border { + width: 1.0, + radius: 4.0.into(), + color: theme.palette().danger.base.color, + }, + ..Default::default() + }); + + main_column = column![err_banner, main_column] + .spacing(6) + .width(sizing.width) + .padding(10); + } + + main_column.into() +} diff --git a/src/ui/window/xref_attach.rs b/src/ui/window/xref_attach.rs new file mode 100644 index 000000000..30251a6d7 --- /dev/null +++ b/src/ui/window/xref_attach.rs @@ -0,0 +1,414 @@ +//! Attach External Reference dialog — the options chosen after picking a +//! drawing with ATTACH / XATTACH: reference type, scale, insertion point, +//! path type and rotation, with the file's preview and block unit. + +use std::fmt; + +use iced::widget::{ + button, column, combo_box, container, image, pick_list, radio, row, text, text_input, Space, +}; +use iced::{Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::io::xref_model::Pathtype; +use crate::t; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; +use crate::ui::window::block_definition::{group, labeled_checkbox}; + +/// One edit in the dialog. +#[derive(Debug, Clone)] +pub enum XrefAttachMsg { + /// Name box: a reference already in the drawing, picked or typed. + Name(String), + Browse, + Overlay(bool), + ScaleOnScreen(bool), + Scale(usize, String), + Uniform(bool), + InsertOnScreen(bool), + Insert(usize, String), + PathType(PathTypeChoice), + RotationOnScreen(bool), + Rotation(String), + Details(bool), + Apply, + Help, +} + +/// Path type choice as the dialog lists it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PathTypeChoice(pub Pathtype); + +impl PathTypeChoice { + pub const ALL: [PathTypeChoice; 3] = [ + PathTypeChoice(Pathtype::Full), + PathTypeChoice(Pathtype::Relative), + PathTypeChoice(Pathtype::None), + ]; +} + +impl fmt::Display for PathTypeChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self.0 { + Pathtype::Full => t!("Full path"), + Pathtype::Relative => t!("Relative path"), + Pathtype::None => t!("No path"), + }; + f.write_str(label.as_ref()) + } +} + +/// The dialog's working copy. +#[derive(Clone)] +pub struct XrefAttachState { + /// Absolute path of the drawing to attach. + pub path: String, + /// Reference name (the file name without extension). + pub name: String, + /// References already in the drawing: name and saved path. + pub existing: Vec<(String, String)>, + pub name_combo: combo_box::State, + pub overlay: bool, + pub scale_on_screen: bool, + pub scale: [String; 3], + pub uniform: bool, + pub insert_on_screen: bool, + pub insert: [String; 3], + pub path_type: PathTypeChoice, + pub rotation_on_screen: bool, + pub rotation: String, + /// Block unit of the chosen drawing and its factor to the host's unit. + pub unit_label: String, + pub unit_factor: String, + pub found_in: String, + pub saved_path: String, + pub details: bool, + pub preview: Option, +} + +impl XrefAttachState { + pub fn new(existing: Vec<(String, String)>) -> Self { + let names = existing.iter().map(|(name, _)| name.clone()).collect(); + Self { + path: String::new(), + name: String::new(), + existing, + name_combo: combo_box::State::new(names), + overlay: false, + scale_on_screen: false, + scale: ["1.0000".into(), "1.0000".into(), "1.0000".into()], + uniform: false, + insert_on_screen: true, + insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()], + path_type: PathTypeChoice(Pathtype::Relative), + rotation_on_screen: false, + rotation: "0".into(), + unit_label: String::new(), + unit_factor: String::new(), + found_in: String::new(), + saved_path: String::new(), + details: false, + preview: None, + } + } + + /// Parsed X/Y/Z scale; a uniform scale takes X for all three. + pub fn scale_values(&self) -> Option<[f64; 3]> { + let parse = |s: &String| s.trim().parse::().ok(); + let x = parse(&self.scale[0])?; + if self.uniform { + return Some([x, x, x]); + } + Some([x, parse(&self.scale[1])?, parse(&self.scale[2])?]) + } + + pub fn insert_point(&self) -> Option { + let parse = |s: &String| s.trim().parse::().ok(); + Some(glam::DVec3::new( + parse(&self.insert[0])?, + parse(&self.insert[1])?, + parse(&self.insert[2])?, + )) + } +} + +fn msg(m: XrefAttachMsg) -> Message { + Message::XrefAttach(m) +} + +fn number_field<'a>( + label: &'a str, + value: &'a str, + enabled: bool, + on_input: impl Fn(String) -> Message + 'a, +) -> Element<'a, Message> { + let mut field = text_input("", value) + .size(11) + .padding([3, 6]) + .width(Fill) + .style(field_style); + if enabled { + field = field.on_input(on_input); + } + row![ + text(label).size(11).style(muted_style).width(Length::Fixed(16.0)), + field + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +fn read_only_field<'a>(label: String, value: &'a str, label_width: f32) -> Element<'a, Message> { + row![ + text(label) + .size(11) + .style(muted_style) + .width(Length::Fixed(label_width)), + text_input("", value) + .size(11) + .padding([3, 6]) + .width(Fill) + .style(field_style), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +pub fn view_window<'a>( + state: &'a XrefAttachState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + // ── Name ──────────────────────────────────────────────────────────── + let name_combo = combo_box( + &state.name_combo, + "", + if state.name.is_empty() { + None + } else { + Some(&state.name) + }, + |chosen: String| msg(XrefAttachMsg::Name(chosen)), + ) + .size(11) + .padding([3, 6]) + .width(Fill); + let name_section = column![ + text(t!("Name:")).size(11).style(muted_style), + row![ + name_combo, + button(text(t!("Browse...")).size(11)) + .on_press(msg(XrefAttachMsg::Browse)) + .style(button_style(false)) + .padding([4, 12]) + .width(Length::Fixed(96.0)), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(3); + + // ── Preview + reference type ──────────────────────────────────────── + let preview_body: Element<'a, Message> = match &state.preview { + Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(), + None => Space::new().width(Fill).height(Fill).into(), + }; + let preview = group( + t!("Preview").into_owned(), + container(preview_body) + .width(Fill) + .height(Length::Fixed(132.0)) + .style(|theme: &Theme| container::Style { + background: Some(iced::Background::Color( + theme.palette().background.base.color, + )), + border: iced::Border { + width: 1.0, + radius: 2.0.into(), + color: theme.palette().background.strong.color, + }, + ..Default::default() + }), + Length::Shrink, + ); + let reference_type = group( + t!("Reference Type").into_owned(), + column![ + radio( + t!("Attachment"), + false, + Some(state.overlay), + |v| msg(XrefAttachMsg::Overlay(v)) + ) + .size(14) + .text_size(11), + radio(t!("Overlay"), true, Some(state.overlay), |v| msg( + XrefAttachMsg::Overlay(v) + )) + .size(14) + .text_size(11), + ] + .spacing(6), + Length::Shrink, + ); + let left = column![preview, reference_type] + .spacing(6) + .width(Length::FillPortion(1)); + + // ── Scale + insertion point ───────────────────────────────────────── + let scale_enabled = !state.scale_on_screen; + let scale = group( + t!("Scale").into_owned(), + column![ + labeled_checkbox(t!("Specify On-screen"), state.scale_on_screen, |v| msg( + XrefAttachMsg::ScaleOnScreen(v) + )), + number_field("X:", &state.scale[0], scale_enabled, |v| msg( + XrefAttachMsg::Scale(0, v) + )), + number_field( + "Y:", + &state.scale[1], + scale_enabled && !state.uniform, + |v| msg(XrefAttachMsg::Scale(1, v)) + ), + number_field( + "Z:", + &state.scale[2], + scale_enabled && !state.uniform, + |v| msg(XrefAttachMsg::Scale(2, v)) + ), + labeled_checkbox(t!("Uniform Scale"), state.uniform, |v| msg( + XrefAttachMsg::Uniform(v) + )), + ] + .spacing(5), + Length::Shrink, + ); + let insert_enabled = !state.insert_on_screen; + let insertion = group( + t!("Insertion point").into_owned(), + column![ + labeled_checkbox(t!("Specify On-screen"), state.insert_on_screen, |v| msg( + XrefAttachMsg::InsertOnScreen(v) + )), + number_field("X:", &state.insert[0], insert_enabled, |v| msg( + XrefAttachMsg::Insert(0, v) + )), + number_field("Y:", &state.insert[1], insert_enabled, |v| msg( + XrefAttachMsg::Insert(1, v) + )), + number_field("Z:", &state.insert[2], insert_enabled, |v| msg( + XrefAttachMsg::Insert(2, v) + )), + ] + .spacing(5), + Length::Shrink, + ); + let middle = column![scale, insertion] + .spacing(6) + .width(Length::FillPortion(1)); + + // ── Path type, rotation, block unit ───────────────────────────────── + let path_type = group( + t!("Path type").into_owned(), + pick_list(Some(state.path_type), PathTypeChoice::ALL, |choice| choice.to_string()) + .on_select(|v| msg(XrefAttachMsg::PathType(v))) + .text_size(11) + .padding([3, 6]) + .width(Fill), + Length::Shrink, + ); + let mut angle = text_input("", &state.rotation) + .size(11) + .padding([3, 6]) + .width(Fill) + .style(field_style); + if !state.rotation_on_screen { + angle = angle.on_input(|v| msg(XrefAttachMsg::Rotation(v))); + } + let rotation = group( + t!("Rotation").into_owned(), + column![ + labeled_checkbox(t!("Specify On-screen"), state.rotation_on_screen, |v| msg( + XrefAttachMsg::RotationOnScreen(v) + )), + row![ + text(t!("Angle:")) + .size(11) + .style(muted_style) + .width(Length::Fixed(44.0)), + angle + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(5), + Length::Shrink, + ); + let block_unit = group( + t!("Block Unit").into_owned(), + column![ + read_only_field(t!("Unit:").into_owned(), &state.unit_label, 52.0), + read_only_field(t!("Factor:").into_owned(), &state.unit_factor, 52.0), + ] + .spacing(5), + Length::Shrink, + ); + let right = column![path_type, rotation, block_unit] + .spacing(6) + .width(Length::FillPortion(1)); + + let body = row![left, middle, right].spacing(6).width(Fill); + + // ── Details ───────────────────────────────────────────────────────── + let details: Element<'a, Message> = if state.details { + group( + t!("Location").into_owned(), + column![ + read_only_field(t!("Found in:").into_owned(), &state.found_in, 92.0), + read_only_field(t!("Saved path:").into_owned(), &state.saved_path, 92.0), + ] + .spacing(5), + Length::Shrink, + ) + } else { + Space::new().height(0).into() + }; + + // ── Footer ────────────────────────────────────────────────────────── + let details_label = if state.details { + t!("Hide Details") + } else { + t!("Show Details") + }; + let footer = row![ + button(text(details_label).size(11)) + .on_press(msg(XrefAttachMsg::Details(!state.details))) + .style(button_style(false)) + .padding([4, 12]), + Space::new().width(Fill), + button(text(t!("OK")).size(11)) + .on_press(msg(XrefAttachMsg::Apply)) + .style(button_style(true)) + .padding([4, 16]), + button(text(t!("Cancel")).size(11)) + .on_press(Message::CloseModal) + .style(button_style(false)) + .padding([4, 12]), + button(text(t!("Help")).size(11)) + .on_press(msg(XrefAttachMsg::Help)) + .style(button_style(false)) + .padding([4, 12]), + ] + .spacing(6) + .align_y(iced::Center); + + column![name_section, body, details, footer] + .spacing(6) + .padding([8, 10]) + .width(sizing.width) + .into() +}