diff --git a/README.md b/README.md index f65e23ab6..c4c255e0e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Français · Suomi · Deutsch · + Ελληνικά · Magyar · Italiano · 日本語 · @@ -122,9 +123,9 @@ The application is ad-hoc signed but is not currently notarized by Apple. ## Languages -Open CAD Studio can follow the system language or use any of these 20 interface languages: +Open CAD Studio can follow the system language or use any of these 21 interface languages: -> Arabic · Brazilian Portuguese · Bulgarian · Czech · Dutch · English · Finnish · French · German · Hindi · Hungarian · Italian · Japanese · Korean · Polish · Russian · Simplified Chinese · Spanish · Traditional Chinese · Turkish +> Arabic · Brazilian Portuguese · Bulgarian · Czech · Dutch · English · Finnish · French · German · Greek · Hindi · Hungarian · Italian · Japanese · Korean · Polish · Russian · Simplified Chinese · Spanish · Traditional Chinese · Turkish Change the language from the application settings. The browser version also uses the browser's preferred locale when **System** is selected. diff --git a/assets/icons/model/box.svg b/assets/icons/model/box.svg new file mode 100644 index 000000000..b5bdd3387 --- /dev/null +++ b/assets/icons/model/box.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/chamfer.svg b/assets/icons/model/chamfer.svg new file mode 100644 index 000000000..709ae1d0c --- /dev/null +++ b/assets/icons/model/chamfer.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/model/cone.svg b/assets/icons/model/cone.svg new file mode 100644 index 000000000..1de0d9973 --- /dev/null +++ b/assets/icons/model/cone.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/model/cylinder.svg b/assets/icons/model/cylinder.svg new file mode 100644 index 000000000..e67f2ddd3 --- /dev/null +++ b/assets/icons/model/cylinder.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/extrude.svg b/assets/icons/model/extrude.svg new file mode 100644 index 000000000..2d4313745 --- /dev/null +++ b/assets/icons/model/extrude.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/model/fillet.svg b/assets/icons/model/fillet.svg new file mode 100644 index 000000000..94745172c --- /dev/null +++ b/assets/icons/model/fillet.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/model/intersect.svg b/assets/icons/model/intersect.svg new file mode 100644 index 000000000..0647a86b2 --- /dev/null +++ b/assets/icons/model/intersect.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/model/loft.svg b/assets/icons/model/loft.svg new file mode 100644 index 000000000..983a96523 --- /dev/null +++ b/assets/icons/model/loft.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/model/polysolid.svg b/assets/icons/model/polysolid.svg new file mode 100644 index 000000000..2f56acdaf --- /dev/null +++ b/assets/icons/model/polysolid.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/model/presspull.svg b/assets/icons/model/presspull.svg new file mode 100644 index 000000000..ecc793f02 --- /dev/null +++ b/assets/icons/model/presspull.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/model/pyramid.svg b/assets/icons/model/pyramid.svg new file mode 100644 index 000000000..d2a5a1324 --- /dev/null +++ b/assets/icons/model/pyramid.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/revolve.svg b/assets/icons/model/revolve.svg new file mode 100644 index 000000000..047ebf48f --- /dev/null +++ b/assets/icons/model/revolve.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/sphere.svg b/assets/icons/model/sphere.svg new file mode 100644 index 000000000..3ff1a9687 --- /dev/null +++ b/assets/icons/model/sphere.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/model/subtract.svg b/assets/icons/model/subtract.svg new file mode 100644 index 000000000..f0babf9a0 --- /dev/null +++ b/assets/icons/model/subtract.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/model/sweep.svg b/assets/icons/model/sweep.svg new file mode 100644 index 000000000..c7e69eee0 --- /dev/null +++ b/assets/icons/model/sweep.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/torus.svg b/assets/icons/model/torus.svg new file mode 100644 index 000000000..75835fea7 --- /dev/null +++ b/assets/icons/model/torus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/model/union.svg b/assets/icons/model/union.svg new file mode 100644 index 000000000..34a79d089 --- /dev/null +++ b/assets/icons/model/union.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/model/wedge.svg b/assets/icons/model/wedge.svg new file mode 100644 index 000000000..d54f4b295 --- /dev/null +++ b/assets/icons/model/wedge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/automation/README.md b/docs/automation/README.md index e59e070f7..d812c6244 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -13,9 +13,9 @@ The client must start that command over standard input/output. If it asks for th The server provides four tools: - `ocs_sessions` finds running editor sessions and opens OpenCADStudio when none exists. -- `ocs_read` reads document state, commands, entities, layers, properties, measurements, history, events, and operation status. -- `ocs_execute` performs semantic commands and input against the real editor. -- `ocs_capture` returns a PNG of the current window for visual verification. +- `ocs_read` reads document state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events, and operation status. +- `ocs_execute` performs one operation or a sequential batch against the real editor. +- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: @@ -31,11 +31,69 @@ The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_rea } ``` +To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance. + +A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "request": { + "op": "run", + "request_id": "polyline-1", + "document_id": 1, + "revision": 12, + "cmd": "PLINE 0,0 10,0 10,10 C" + } +} +``` + +Use `batch` when the steps are already known. OCS supplies each step with the state produced by the previous one and stops on the first failure. `completed_steps` and `next_step` show exactly what committed: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "response_detail": "changed_entities", + "request": { + "op": "batch", + "request_id": "shape-1", + "steps": [ + {"op": "run", "cmd": "LINE 0,0 10,0"}, + {"op": "run", "cmd": "CIRCLE 5,5 2"} + ] + } +} +``` + +Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed. + +`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "op": "query", + "parameters": {"intersections": ["2A", "31"]} +} +``` + +For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step. + Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel. -The source-tree protocol smoke test uses only the Python standard library: +`ocs_capture` defaults to the drawing viewport and a longest edge of 1600 pixels. Set `scope` to `window` for the full interface or change `max_dimension` between 256 and 4096. + +Clients using MCP 2026-07-28 can advertise `io.modelcontextprotocol/tasks`. OCS then returns a standard task handle when an operation is still running and accepts `tasks/get`, `tasks/update`, and `tasks/cancel`. Other clients continue to receive the existing operation status and can read it with `ocs_read`. + +The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface: ```sh cargo build python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio ``` + +The repeatable live-editor evaluation draws three isolated entities in one batch, verifies exact intersections, nearest geometry and kernel measurements, removes the entities, and reports call count, elapsed time and wire bytes: + +```sh +python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio +``` diff --git a/docs/automation/mcp_eval.py b/docs/automation/mcp_eval.py new file mode 100644 index 000000000..956633c4f --- /dev/null +++ b/docs/automation/mcp_eval.py @@ -0,0 +1,182 @@ +"""Repeatable black-box speed and capability check for the native MCP server.""" + +import json +from pathlib import Path +import subprocess +import sys +import time + + +MODERN = "2026-07-28" +META = { + "io.modelcontextprotocol/protocolVersion": MODERN, + "io.modelcontextprotocol/clientInfo": {"name": "ocs-eval", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": { + "extensions": {"io.modelcontextprotocol/tasks": {}} + }, +} + + +class Client: + def __init__(self, server: Path) -> None: + self.process = subprocess.Popen( + [str(server), "--mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.serial = 0 + self.rpc_calls = 0 + self.tool_calls = 0 + self.tasks = 0 + self.request_bytes = 0 + self.response_bytes = 0 + + def rpc(self, method: str, params: dict) -> dict: + assert self.process.stdin and self.process.stdout + self.serial += 1 + payload = {"jsonrpc": "2.0", "id": self.serial, "method": method, "params": params} + wire = json.dumps(payload, separators=(",", ":")) + self.request_bytes += len(wire.encode()) + self.rpc_calls += 1 + self.process.stdin.write(wire + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + self.response_bytes += len(line.encode()) + response = json.loads(line) + if "error" in response: + raise RuntimeError(response["error"]) + return response["result"] + + def tool(self, name: str, arguments: dict) -> dict: + self.tool_calls += 1 + result = self.rpc("tools/call", {"name": name, "arguments": arguments, "_meta": META}) + if result.get("resultType") == "task": + self.tasks += 1 + task_id = result["taskId"] + poll_interval = result.get("pollIntervalMs", 250) + while True: + time.sleep(poll_interval / 1000) + task = self.rpc("tasks/get", {"taskId": task_id, "_meta": META}) + if task["status"] in {"failed", "cancelled"}: + raise RuntimeError(task) + if task["status"] == "completed": + result = task["result"] + break + structured = result.get("structuredContent") + if structured is None: + raise RuntimeError(result) + if structured.get("ok") is False: + raise RuntimeError(structured) + return structured + + def close(self) -> None: + assert self.process.stdin + self.process.stdin.close() + if self.process.wait(timeout=5) != 0: + raise RuntimeError(self.process.stderr.read() if self.process.stderr else "MCP exited") + + +def main() -> None: + server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve() + client = Client(server) + started = time.perf_counter() + handles: list[str] = [] + session: str | None = None + base = 0 + succeeded = False + try: + discovery = client.rpc("server/discover", {"_meta": META}) + assert "io.modelcontextprotocol/tasks" in discovery["capabilities"]["extensions"] + sessions = client.tool("ocs_sessions", {"launch_if_none": True})["result"] + session = sessions[0]["session_id"] + active = next( + document for document in sessions[0]["documents"] + if document["id"] == sessions[0]["document_id"] + ) + if active.get("start"): + client.tool( + "ocs_execute", + {"session_id": session, "request": {"op": "new", "request_id": "eval-new"}}, + ) + + base = 1_000_000 + int(time.time()) % 100_000 + draw = client.tool( + "ocs_execute", + { + "session_id": session, + "response_detail": "changed_entities", + "wait_seconds": 0, + "request": { + "op": "batch", + "request_id": f"eval-draw-{base}", + "steps": [ + {"op": "run", "cmd": f"LINE {base-5},0 {base+5},0"}, + {"op": "run", "cmd": f"LINE {base},-5 {base},5"}, + {"op": "run", "cmd": f"CIRCLE {base+20},0 2"}, + ], + }, + }, + ) + assert draw["completed_steps"] == 3, draw + assert client.tasks > 0, "wait_seconds=0 did not exercise MCP Tasks" + changed = draw["changed_entities"] + line_handles = [entity["handle"] for entity in changed if entity["type"] == "Line"] + circle_handles = [entity["handle"] for entity in changed if entity["type"] == "Circle"] + assert len(line_handles) == 2 and len(circle_handles) == 1, changed + handles = line_handles + circle_handles + + crossings = client.tool( + "ocs_read", + {"session_id": session, "op": "query", "parameters": {"intersections": line_handles}}, + ) + assert crossings["count"] == 1 and crossings["intersections"][0]["point"] == [base, 0.0] + + nearest = client.tool( + "ocs_read", + {"session_id": session, "op": "query", "parameters": {"near": [base + 20, 0], "handles": handles, "limit": 1}}, + ) + assert nearest["entities"][0]["handle"] == circle_handles[0] + + measured = client.tool( + "ocs_read", + {"session_id": session, "op": "measure", "parameters": {"handles": circle_handles}}, + ) + assert abs(measured["measurements"][0]["curve"]["area"] - 12.566370614359172) < 1e-9 + succeeded = True + finally: + if handles and session: + try: + client.tool( + "ocs_execute", + { + "session_id": session, + "request": { + "op": "batch", + "request_id": f"eval-clean-{base}", + "steps": [ + {"op": "select", "handles": handles}, + {"op": "run", "cmd": "ERASE"}, + ], + }, + }, + ) + except Exception: + pass + elapsed = (time.perf_counter() - started) * 1000 + metrics = { + "ok": succeeded, + "elapsed_ms": round(elapsed, 1), + "tool_calls": client.tool_calls, + "tasks": client.tasks, + "rpc_calls": client.rpc_calls, + "request_bytes": client.request_bytes, + "response_bytes": client.response_bytes, + } + client.close() + print(json.dumps(metrics, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index 47ff970c4..e28fb6867 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -44,12 +44,22 @@ def legacy(server: Path) -> None: "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}, }) assert initialized["result"]["serverInfo"]["name"] == "OpenCADStudio" - assert initialized["result"]["instructions"] + assert "state.command.accepts" in initialized["result"]["instructions"] process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") process.stdin.flush() tools = request(process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) - names = {tool["name"] for tool in tools["result"]["tools"]} + definitions = {tool["name"]: tool for tool in tools["result"]["tools"]} + names = set(definitions) assert names == TOOLS, names + execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] + assert len(execute_request["oneOf"]) == 15 + assert execute_request["properties"]["steps"]["maxItems"] == 64 + assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" + assert execute_request["properties"]["kind"]["enum"] == [ + "text", "token", "point", "entity", "structure", "selection", "enter" + ] + for name in {"ocs_sessions", "ocs_read", "ocs_execute"}: + assert definitions[name]["outputSchema"]["type"] == "object" assert "resultType" not in tools["result"] sessions = request(process, { "jsonrpc": "2.0", @@ -77,6 +87,7 @@ def modern(server: Path) -> None: assert discovered["result"]["resultType"] == "complete" assert discovered["result"]["ttlMs"] >= 0 assert discovered["result"]["cacheScope"] in {"public", "private"} + assert "io.modelcontextprotocol/tasks" in discovered["result"]["capabilities"]["extensions"] tools = request(process, { "jsonrpc": "2.0", @@ -88,6 +99,26 @@ def modern(server: Path) -> None: assert tools["result"]["resultType"] == "complete" assert tools["result"]["ttlMs"] >= 0 assert tools["result"]["cacheScope"] in {"public", "private"} + definitions = {tool["name"]: tool for tool in tools["result"]["tools"]} + assert definitions["ocs_execute"]["inputSchema"]["properties"]["response_detail"]["default"] == "compact" + assert definitions["ocs_capture"]["inputSchema"]["properties"]["scope"]["default"] == "viewport" + + missing_cmd = request(process, { + "jsonrpc": "2.0", + "id": "missing-cmd", + "method": "tools/call", + "params": { + "name": "ocs_execute", + "arguments": { + "session_id": "missing", + "request": {"op": "run", "request_id": "run-1"}, + }, + "_meta": meta, + }, + }) + error = missing_cmd["result"]["structuredContent"] + assert error["code"] == "invalid_arguments" + assert "LINE 0,0 10,10" in error["error"] invalid_mutation = request(process, { "jsonrpc": "2.0", diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index eeb10743b..80464a85a 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

شعار Open CAD Studio

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## اللغات -يمكن لـ Open CAD Studio اتباع لغة النظام أو استخدام إحدى لغات الواجهة العشرين التالية: +يمكن لـ Open CAD Studio اتباع لغة النظام أو استخدام إحدى لغات الواجهة التالية البالغ عددها 21 لغة: -> العربية · البرتغالية البرازيلية · البلغارية · التشيكية · الهولندية · الإنجليزية · الفنلندية · الفرنسية · الألمانية · الهندية · المجرية · الإيطالية · اليابانية · الكورية · البولندية · الروسية · الصينية المبسطة · الإسبانية · الصينية التقليدية · التركية +> العربية · البرتغالية البرازيلية · البلغارية · التشيكية · الهولندية · الإنجليزية · الفنلندية · الفرنسية · الألمانية · اليونانية · الهندية · المجرية · الإيطالية · اليابانية · الكورية · البولندية · الروسية · الصينية المبسطة · الإسبانية · الصينية التقليدية · التركية غيّر اللغة من إعدادات التطبيق. عند اختيار **النظام**، يستخدم إصدار المتصفح أيضاً الإعدادات المحلية المفضلة للمتصفح. diff --git a/docs/readme/README.bg.md b/docs/readme/README.bg.md index 975f642b0..ddc65ecb6 100644 --- a/docs/readme/README.bg.md +++ b/docs/readme/README.bg.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Лого на Open CAD Studio

@@ -93,9 +93,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## Езици -Open CAD Studio може да следва системния език или да използва един от следните 20 езика на интерфейса: +Open CAD Studio може да следва системния език или да използва един от следните 21 езика на интерфейса: -> Арабски · Бразилски португалски · Български · Чешки · Нидерландски · Английски · Фински · Френски · Немски · Хинди · Унгарски · Италиански · Японски · Корейски · Полски · Руски · Опростен китайски · Испански · Традиционен китайски · Турски +> Арабски · Бразилски португалски · Български · Чешки · Нидерландски · Английски · Фински · Френски · Немски · Гръцки · Хинди · Унгарски · Италиански · Японски · Корейски · Полски · Руски · Опростен китайски · Испански · Традиционен китайски · Турски Променете езика от настройките на приложението. Когато е избрано **Системен език**, браузърната версия също използва предпочитания локал на браузъра. diff --git a/docs/readme/README.cs.md b/docs/readme/README.cs.md index be39bd983..819c3c212 100644 --- a/docs/readme/README.cs.md +++ b/docs/readme/README.cs.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo Open CAD Studio

@@ -91,9 +91,9 @@ Aplikace je podepsána ad hoc, ale v současnosti není notářsky ověřena spo ## Jazyky -Open CAD Studio může používat jazyk systému nebo jeden z těchto 20 jazyků rozhraní: +Open CAD Studio může používat jazyk systému nebo jeden z těchto 21 jazyků rozhraní: -> Arabština · Brazilská portugalština · Bulharština · Čeština · Nizozemština · Angličtina · Finština · Francouzština · Němčina · Hindština · Maďarština · Italština · Japonština · Korejština · Polština · Ruština · Zjednodušená čínština · Španělština · Tradiční čínština · Turečtina +> Arabština · Brazilská portugalština · Bulharština · Čeština · Nizozemština · Angličtina · Finština · Francouzština · Němčina · Řečtina · Hindština · Maďarština · Italština · Japonština · Korejština · Polština · Ruština · Zjednodušená čínština · Španělština · Tradiční čínština · Turečtina Jazyk změníte v nastavení aplikace. Pokud je vybrána možnost **Systém**, webová verze používá také preferované národní prostředí prohlížeče. diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index 4c7d8a979..f6c1b1818 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open-CAD-Studio-Logo

@@ -91,9 +91,9 @@ Die Anwendung ist ad hoc signiert, wird derzeit aber nicht von Apple notarisiert ## Sprachen -Open CAD Studio kann der Systemsprache folgen oder eine dieser 20 Oberflächensprachen verwenden: +Open CAD Studio kann der Systemsprache folgen oder eine dieser 21 Oberflächensprachen verwenden: -> Arabisch · Brasilianisches Portugiesisch · Bulgarisch · Tschechisch · Niederländisch · Englisch · Finnisch · Französisch · Deutsch · Hindi · Ungarisch · Italienisch · Japanisch · Koreanisch · Polnisch · Russisch · Vereinfachtes Chinesisch · Spanisch · Traditionelles Chinesisch · Türkisch +> Arabisch · Brasilianisches Portugiesisch · Bulgarisch · Tschechisch · Niederländisch · Englisch · Finnisch · Französisch · Deutsch · Griechisch · Hindi · Ungarisch · Italienisch · Japanisch · Koreanisch · Polnisch · Russisch · Vereinfachtes Chinesisch · Spanisch · Traditionelles Chinesisch · Türkisch Die Sprache lässt sich in den Anwendungseinstellungen ändern. Die Browserversion verwendet bei Auswahl von **System** ebenfalls das bevorzugte Gebietsschema des Browsers. diff --git a/docs/readme/README.el.md b/docs/readme/README.el.md new file mode 100644 index 000000000..db0381d38 --- /dev/null +++ b/docs/readme/README.el.md @@ -0,0 +1,210 @@ +

+ English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية +

+ +

Λογότυπο του Open CAD Studio

+ +

Open CAD Studio

+ +

Εφαρμογή ανοιχτού κώδικα για δισδιάστατη σχεδίαση και τρισδιάστατη μοντελοποίηση, για υπολογιστές και τον ιστό, γραμμένη σε Rust.

+ +

+ Τελευταία έκδοση + Λήψεις εκδόσεων + Αστέρια στο GitHub + Άδεια GPL-3.0 +

+ +

+ Άνοιγμα της διαδικτυακής εφαρμογής · + Λήψη της εφαρμογής για υπολογιστές · + Συμμετοχή στη συζήτηση +

+ +

Χώρος εργασίας του Open CAD Studio

+ +## Επισκόπηση + +Το Open CAD Studio είναι μια εφαρμογή για τεχνική σχεδίαση, διάταξη φύλλων και μοντελοποίηση στερεών, η οποία λειτουργεί σε διαφορετικές πλατφόρμες. Διαβάζει και γράφει απευθείας σχέδια DWG και DXF, με κοινό πυρήνα επεξεργασίας στις εκδόσεις για υπολογιστές και προγράμματα περιήγησης. + +Το έργο βρίσκεται υπό ενεργή ανάπτυξη. Διατηρείτε αντίγραφα ασφαλείας των σημαντικών σχεδίων σας και αναφέρετε προβλήματα που μπορούν να αναπαραχθούν μέσω των [GitHub Issues](https://github.com/HakanSeven12/OpenCADStudio/issues). + +## Κύρια χαρακτηριστικά + +- **Άμεση επεξεργασία σχεδίων** — άνοιγμα, επεξεργασία, ανάκτηση και αποθήκευση αρχείων DWG και DXF χωρίς υπηρεσία μετατροπής. +- **Ακριβής δισδιάστατη σχεδίαση** — γραμμές, πολυγραμμές, καμπύλες, splines, διαγραμμίσεις, έλξεις αντικειμένων, παρακολούθηση, στρώσεις, μπλοκ και εξωτερικές αναφορές. +- **Εργαλεία τεκμηρίωσης** — κείμενο, διαστάσεις, γραμμές υπόδειξης, ανοχές, πίνακες, χώρος μοντέλου, χώρος χαρτιού, παράθυρα προβολής και στυλ εκτύπωσης. +- **Τρισδιάστατη μοντελοποίηση με γεωμετρικό πυρήνα** — βασικά στερεά, εξώθηση, περιστροφή, σάρωση, loft, πράξεις Boolean και ψηφίδωση οντοτήτων ACIS. +- **Απόδοση μέσω GPU** — επιταχυνόμενες δισδιάστατες και τρισδιάστατες προβολές μέσω του `wgpu`, με ορθογραφικές και προοπτικές κάμερες. +- **Επεκτάσιμες ροές εργασίας** — εγγενή πρόσθετα, δέσμες εντολών, μετατροπή χωρίς γραφικό περιβάλλον και API αυτοματισμού JSON με ένα αντικείμενο ανά γραμμή. + +

Τρισδιάστατο μοντέλο στο Open CAD Studio

+ +## Μορφές αρχείων και εργασίες + +| Μορφή ή εργασία | Υποστήριξη | +| --- | --- | +| DWG | Ανάγνωση και εγγραφή· αποθήκευση σε εκδόσεις από R14 έως 2018 | +| DXF | Ανάγνωση και εγγραφή· αποθήκευση σε εκδόσεις από R14 έως 2018 | +| BAK / SV$ | Άνοιγμα αντιγράφων ασφαλείας σχεδίων και αρχείων αυτόματης αποθήκευσης | +| OBJ | Εισαγωγή πολυγωνικών πλεγμάτων | +| LandXML | Εισαγωγή τοπογραφικών σημείων `CgPoint` | +| STL | Εξαγωγή δεδομένων τρισδιάστατου πλέγματος | +| STEP AP203 | Εξαγωγή δεδομένων τρισδιάστατου πλέγματος | +| PDF | Εκτύπωση διατάξεων και επιλεγμένης γεωμετρίας στην εφαρμογή για υπολογιστές | +| CSV | Εξαγωγή δεδομένων ιδιοτήτων οντοτήτων | +| CTB / STB | Φόρτωση και επεξεργασία πινάκων στυλ εκτύπωσης | + +## Υπολογιστής ή ιστός + +Χρησιμοποιήστε τη [διαδικτυακή εφαρμογή](https://www.opencadstudio.com) για άμεση πρόσβαση χωρίς εγκατάσταση. Τα σχέδια επιλέγονται μέσω του προγράμματος περιήγησης και αποθηκεύονται ως τοπικές λήψεις. + +Χρησιμοποιήστε την εφαρμογή για υπολογιστές για συσχετίσεις αρχείων, μικρογραφίες στη διαχείριση αρχείων, εκτύπωση μέσω του συστήματος, εξαγωγή PDF, εξωτερικά πρόσθετα, δέσμες εντολών και αυτοματισμό χωρίς γραφικό περιβάλλον. Διατίθενται εκδόσεις για Windows, Linux και macOS με Apple Silicon. + +## Εγκατάσταση + +Κατεβάστε όλα τα τρέχοντα πακέτα από την [τελευταία έκδοση](https://github.com/HakanSeven12/OpenCADStudio/releases/latest). + +### Windows + +Επιλέξτε ένα από τα υπογεγραμμένα πακέτα x86-64: + +- `OpenCADStudio-*-windows-x86_64-installer.msi` — το προτεινόμενο πρόγραμμα εγκατάστασης, με συντομεύσεις στο μενού Έναρξη, συσχετίσεις αρχείων DWG/DXF και μικρογραφίες σχεδίων. +- `OpenCADStudio-*-windows-x86_64-portable.exe` — αυτόνομη εφαρμογή που δεν απαιτεί εγκατάσταση. + +### Linux + +Κατεβάστε το AppImage για x86-64, κάντε το εκτελέσιμο και εκκινήστε το: + +```bash +chmod +x OpenCADStudio-*-linux-x86_64.AppImage +./OpenCADStudio-*-linux-x86_64.AppImage +``` + +### macOS + +Το διαθέσιμο πακέτο για macOS υποστηρίζει Apple Silicon: + +1. Κατεβάστε το `OpenCADStudio-*-macos-arm64.dmg`. +2. Ανοίξτε την εικόνα δίσκου και σύρετε το `OpenCADStudio.app` στον φάκελο **Εφαρμογές (Applications)**. +3. Αν το Gatekeeper εμποδίσει την πρώτη εκκίνηση, εγκρίνετε την εφαρμογή από τις **Ρυθμίσεις συστήματος → Απόρρητο και ασφάλεια (System Settings → Privacy & Security)**. + +Η εφαρμογή διαθέτει υπογραφή ad-hoc, αλλά δεν έχει ακόμη επικυρωθεί μέσω της διαδικασίας notarization της Apple. + +## Γλώσσες + +Το Open CAD Studio μπορεί να ακολουθεί τη γλώσσα του συστήματος ή να χρησιμοποιεί μία από τις ακόλουθες 21 γλώσσες περιβάλλοντος: + +> Αραβικά · Πορτογαλικά Βραζιλίας · Βουλγαρικά · Τσεχικά · Ολλανδικά · Αγγλικά · Φινλανδικά · Γαλλικά · Γερμανικά · Ελληνικά · Χίντι · Ουγγρικά · Ιταλικά · Ιαπωνικά · Κορεατικά · Πολωνικά · Ρωσικά · Απλοποιημένα κινεζικά · Ισπανικά · Παραδοσιακά κινεζικά · Τουρκικά + +Αλλάξτε τη γλώσσα από τις ρυθμίσεις της εφαρμογής. Όταν είναι επιλεγμένη η **Γλώσσα συστήματος**, η διαδικτυακή έκδοση χρησιμοποιεί επίσης την προτιμώμενη γλώσσα του προγράμματος περιήγησης. + +## Μεταγλώττιση από τον πηγαίο κώδικα + +### Εφαρμογή για υπολογιστές + +Απαιτήσεις: + +- Git +- Τρέχουσα σταθερή έκδοση της εργαλειοθήκης Rust +- Βιβλιοθήκες ανάπτυξης γραφικών και γραμματοσειρών για την πλατφόρμα σας + +Σε Ubuntu ή Debian, εγκαταστήστε τις εξαρτήσεις με: + +```bash +sudo apt update +sudo apt install libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev \ + libxrandr-dev libxkbcommon-dev libwayland-dev libfontconfig1-dev \ + libfreetype6-dev +``` + +Στη συνέχεια, μεταγλωττίστε την εφαρμογή: + +```bash +git clone https://github.com/HakanSeven12/OpenCADStudio.git +cd OpenCADStudio +cargo build --release --bin OpenCADStudio +``` + +Το εκτελέσιμο αρχείο δημιουργείται στη θέση `target/release/OpenCADStudio` (`OpenCADStudio.exe` στα Windows). + +### Διαδικτυακή εφαρμογή + +Εγκαταστήστε μία φορά τον στόχο WebAssembly και τα εργαλεία μεταγλώττισης: + +```bash +rustup target add wasm32-unknown-unknown +cargo install trunk wasm-bindgen-cli +``` + +Εκκινήστε τον διακομιστή ανάπτυξης: + +```bash +trunk serve +``` + +## Αυτοματισμός + +Το εκτελέσιμο για υπολογιστές υποστηρίζει μεμονωμένες μετατροπές, έναν μόνιμο διακομιστή χωρίς γραφικό περιβάλλον και ένα σημείο σύνδεσης MCP για εφαρμογές τεχνητής νοημοσύνης, ανεξάρτητα από τον πελάτη: + +```bash +OpenCADStudio --export input.dwg output.dxf +OpenCADStudio --serve +OpenCADStudio --serve --port 4242 +OpenCADStudio --mcp +``` + +Ο διακομιστής αυτοματισμού ανταλλάσσει ένα αντικείμενο JSON ανά γραμμή μέσω της τυπικής εισόδου/εξόδου ή μιας τοπικής υποδοχής TCP. Το αυτοτελές σημείο σύνδεσης MCP παρέχει πρόσβαση στον ενεργό επεξεργαστή της εφαρμογής μέσω των ίδιων εργαλείων σε κάθε συμβατό πελάτη. Για τη σύνδεση, ρυθμίστε τον πελάτη ώστε να εκκινεί το `OpenCADStudio --mcp`. Δείτε τον [οδηγό ελέγχου MCP](../automation/README.md). + +## Πρόσθετα + +Τα πρόσθετα για υπολογιστές εκτελούνται σε ξεχωριστές διεργασίες και επικοινωνούν με την κύρια εφαρμογή μέσω του API προσθέτων με καθορισμένη έκδοση. Η διαδικτυακή έκδοση δεν φορτώνει εγγενή πρόσθετα. + +- [Αρχιτεκτονική προσθέτων](../plugin-architecture.md) +- [Πρότυπο προσθέτου](../plugin-template/README.md) +- [Μητρώο προσθέτων](../../plugins/README.md) + +## Τεκμηρίωση του έργου + +- [API αυτοματισμού](../automation/README.md) +- [Αρχιτεκτονική προσθέτων](../plugin-architecture.md) +- [Στάδια ψηφίδωσης](../tessellation.md) +- [Πολιτική ασφάλειας](../../SECURITY.md) + +## Συνεισφορά + +Είναι ευπρόσδεκτες οι αναφορές σφαλμάτων, τα pull requests με συγκεκριμένο αντικείμενο, οι μεταφράσεις, οι βελτιώσεις στην τεκμηρίωση και οι συνεισφορές προσθέτων. + +- Αναζητήστε στα υπάρχοντα [issues](https://github.com/HakanSeven12/OpenCADStudio/issues) πριν υποβάλετε νέα αναφορά. +- Χρησιμοποιήστε τα [Discussions](https://github.com/HakanSeven12/OpenCADStudio/discussions) για ερωτήσεις και ιδέες. +- Αναφέρετε ιδιωτικά τυχόν ευπάθειες, ακολουθώντας την [πολιτική ασφάλειας](../../SECURITY.md). + +## Ανάπτυξη του έργου + +### Αστέρια + + + + + + Ιστορικό αστεριών του Open CAD Studio + + + +### Λήψεις εκδόσεων + + + + + + Ιστορικό λήψεων εκδόσεων του Open CAD Studio + + + +## Υποστήριξη του έργου + +Αν το Open CAD Studio σας βοηθά στην εργασία σας, υποστηρίξτε τη συνεχή ανάπτυξή του μέσω του [GitHub Sponsors](https://github.com/sponsors/HakanSeven12) ή του [Patreon](https://www.patreon.com/HakanSeven12). + +## Άδεια χρήσης + +Το Open CAD Studio διανέμεται υπό τη [Γενική Άδεια Δημόσιας Χρήσης GNU v3.0](../../LICENSE). diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 2d0e937bc..f210a2b72 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logotipo de Open CAD Studio

@@ -91,9 +91,9 @@ La aplicación tiene firma ad hoc, pero actualmente no está notarizada por Appl ## Idiomas -Open CAD Studio puede seguir el idioma del sistema o usar cualquiera de estos 20 idiomas de interfaz: +Open CAD Studio puede seguir el idioma del sistema o usar cualquiera de estos 21 idiomas de interfaz: -> Árabe · Portugués de Brasil · Búlgaro · Checo · Neerlandés · Inglés · Finés · Francés · Alemán · Hindi · Húngaro · Italiano · Japonés · Coreano · Polaco · Ruso · Chino simplificado · Español · Chino tradicional · Turco +> Árabe · Portugués de Brasil · Búlgaro · Checo · Neerlandés · Inglés · Finés · Francés · Alemán · Griego · Hindi · Húngaro · Italiano · Japonés · Coreano · Polaco · Ruso · Chino simplificado · Español · Chino tradicional · Turco Cambia el idioma en los ajustes de la aplicación. La versión web también usa la configuración regional preferida del navegador cuando se selecciona **Sistema**. diff --git a/docs/readme/README.fi.md b/docs/readme/README.fi.md index b4badeb71..bab3fb45a 100644 --- a/docs/readme/README.fi.md +++ b/docs/readme/README.fi.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studion logo

@@ -91,9 +91,9 @@ Sovellus on allekirjoitettu ad hoc -allekirjoituksella, mutta Apple ei ole täll ## Kielet -Open CAD Studio voi seurata järjestelmän kieltä tai käyttää jotakin näistä 20 käyttöliittymäkielestä: +Open CAD Studio voi seurata järjestelmän kieltä tai käyttää jotakin näistä 21 käyttöliittymäkielestä: -> Arabia · Brasilianportugali · Bulgaria · Tšekki · Hollanti · Englanti · Suomi · Ranska · Saksa · Hindi · Unkari · Italia · Japani · Korea · Puola · Venäjä · Yksinkertaistettu kiina · Espanja · Perinteinen kiina · Turkki +> Arabia · Brasilianportugali · Bulgaria · Tšekki · Hollanti · Englanti · Suomi · Ranska · Saksa · Kreikka · Hindi · Unkari · Italia · Japani · Korea · Puola · Venäjä · Yksinkertaistettu kiina · Espanja · Perinteinen kiina · Turkki Vaihda kieli sovelluksen asetuksissa. Selainversio käyttää myös selaimen ensisijaista kielialuetta, kun **Järjestelmä** on valittu. diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 2371fec90..e0085596c 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo Open CAD Studio

@@ -91,9 +91,9 @@ L’application est signée de façon ad hoc, mais n’est actuellement pas nota ## Langues -Open CAD Studio peut suivre la langue du système ou utiliser l’une de ces 20 langues d’interface : +Open CAD Studio peut suivre la langue du système ou utiliser l’une de ces 21 langues d’interface : -> Arabe · Portugais du Brésil · Bulgare · Tchèque · Néerlandais · Anglais · Finnois · Français · Allemand · Hindi · Hongrois · Italien · Japonais · Coréen · Polonais · Russe · Chinois simplifié · Espagnol · Chinois traditionnel · Turc +> Arabe · Portugais du Brésil · Bulgare · Tchèque · Néerlandais · Anglais · Finnois · Français · Allemand · Grec · Hindi · Hongrois · Italien · Japonais · Coréen · Polonais · Russe · Chinois simplifié · Espagnol · Chinois traditionnel · Turc Changez la langue dans les paramètres de l’application. La version web utilise aussi la langue préférée du navigateur lorsque **Système** est sélectionné. diff --git a/docs/readme/README.hi.md b/docs/readme/README.hi.md index 67d7cd94f..253cc155e 100644 --- a/docs/readme/README.hi.md +++ b/docs/readme/README.hi.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio लोगो

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## भाषाएँ -Open CAD Studio सिस्टम भाषा का अनुसरण कर सकता है या इन 20 इंटरफ़ेस भाषाओं में से किसी एक का उपयोग कर सकता है: +Open CAD Studio सिस्टम भाषा का अनुसरण कर सकता है या इन 21 इंटरफ़ेस भाषाओं में से किसी एक का उपयोग कर सकता है: -> अरबी · ब्राज़ीलियाई पुर्तगाली · बुल्गारियाई · चेक · डच · अंग्रेज़ी · फ़िनिश · फ़्रेंच · जर्मन · हिन्दी · हंगेरियन · इतालवी · जापानी · कोरियाई · पोलिश · रूसी · सरलीकृत चीनी · स्पैनिश · पारंपरिक चीनी · तुर्की +> अरबी · ब्राज़ीलियाई पुर्तगाली · बुल्गारियाई · चेक · डच · अंग्रेज़ी · फ़िनिश · फ़्रेंच · जर्मन · यूनानी · हिन्दी · हंगेरियन · इतालवी · जापानी · कोरियाई · पोलिश · रूसी · सरलीकृत चीनी · स्पैनिश · पारंपरिक चीनी · तुर्की अनुप्रयोग सेटिंग में भाषा बदलें। **सिस्टम** चुने जाने पर ब्राउज़र संस्करण भी ब्राउज़र की पसंदीदा लोकेल का उपयोग करता है। diff --git a/docs/readme/README.hu.md b/docs/readme/README.hu.md index 655315564..d2e2d94f0 100644 --- a/docs/readme/README.hu.md +++ b/docs/readme/README.hu.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio logó

@@ -91,9 +91,9 @@ Az alkalmazás ad hoc aláírással rendelkezik, de az Apple jelenleg nem hitele ## Nyelvek -Az Open CAD Studio követheti a rendszer nyelvét, vagy használhatja az alábbi 20 felületi nyelv egyikét: +Az Open CAD Studio követheti a rendszer nyelvét, vagy használhatja az alábbi 21 felületi nyelv egyikét: -> Arab · Brazil portugál · Bolgár · Cseh · Holland · Angol · Finn · Francia · Német · Hindi · Magyar · Olasz · Japán · Koreai · Lengyel · Orosz · Egyszerűsített kínai · Spanyol · Hagyományos kínai · Török +> Arab · Brazil portugál · Bolgár · Cseh · Holland · Angol · Finn · Francia · Német · Görög · Hindi · Magyar · Olasz · Japán · Koreai · Lengyel · Orosz · Egyszerűsített kínai · Spanyol · Hagyományos kínai · Török A nyelv az alkalmazás beállításaiban módosítható. **Rendszer** választásakor a böngészős változat is a böngésző előnyben részesített területi beállítását használja. diff --git a/docs/readme/README.it.md b/docs/readme/README.it.md index e342cf765..45721e569 100644 --- a/docs/readme/README.it.md +++ b/docs/readme/README.it.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo di Open CAD Studio

@@ -91,9 +91,9 @@ L’applicazione è firmata ad hoc, ma attualmente non è autenticata da Apple. ## Lingue -Open CAD Studio può seguire la lingua di sistema o usare una di queste 20 lingue dell’interfaccia: +Open CAD Studio può seguire la lingua di sistema o usare una di queste 21 lingue dell’interfaccia: -> Arabo · Portoghese brasiliano · Bulgaro · Ceco · Olandese · Inglese · Finlandese · Francese · Tedesco · Hindi · Ungherese · Italiano · Giapponese · Coreano · Polacco · Russo · Cinese semplificato · Spagnolo · Cinese tradizionale · Turco +> Arabo · Portoghese brasiliano · Bulgaro · Ceco · Olandese · Inglese · Finlandese · Francese · Tedesco · Greco · Hindi · Ungherese · Italiano · Giapponese · Coreano · Polacco · Russo · Cinese semplificato · Spagnolo · Cinese tradizionale · Turco Cambia la lingua nelle impostazioni dell’applicazione. La versione browser usa anche la lingua preferita del browser quando è selezionato **Sistema**. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 2fbba316a..bfee5811d 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio ロゴ

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 言語 -Open CAD Studio はシステム言語に従うか、次の 20 のインターフェース言語を使用できます。 +Open CAD Studio はシステム言語に従うか、次の 21 のインターフェース言語を使用できます。 -> アラビア語 · ブラジルポルトガル語 · ブルガリア語 · チェコ語 · オランダ語 · 英語 · フィンランド語 · フランス語 · ドイツ語 · ヒンディー語 · ハンガリー語 · イタリア語 · 日本語 · 韓国語 · ポーランド語 · ロシア語 · 簡体字中国語 · スペイン語 · 繁体字中国語 · トルコ語 +> アラビア語 · ブラジルポルトガル語 · ブルガリア語 · チェコ語 · オランダ語 · 英語 · フィンランド語 · フランス語 · ドイツ語 · ギリシャ語 · ヒンディー語 · ハンガリー語 · イタリア語 · 日本語 · 韓国語 · ポーランド語 · ロシア語 · 簡体字中国語 · スペイン語 · 繁体字中国語 · トルコ語 言語はアプリケーション設定から変更できます。**システム** を選ぶと、ブラウザ版もブラウザの優先ロケールを使用します。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index ded42890c..e6da6dd7c 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio 로고

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 언어 -Open CAD Studio는 시스템 언어를 따르거나 다음 19개 인터페이스 언어 중 하나를 사용할 수 있습니다. +Open CAD Studio는 시스템 언어를 따르거나 다음 21개 인터페이스 언어 중 하나를 사용할 수 있습니다. -> 아랍어 · 브라질 포르투갈어 · 불가리아어 · 체코어 · 네덜란드어 · 영어 · 핀란드어 · 프랑스어 · 독일어 · 힌디어 · 헝가리어 · 이탈리아어 · 일본어 · 한국어 · 폴란드어 · 러시아어 · 중국어 간체 · 스페인어 · 중국어 번체 · 터키어 +> 아랍어 · 브라질 포르투갈어 · 불가리아어 · 체코어 · 네덜란드어 · 영어 · 핀란드어 · 프랑스어 · 독일어 · 그리스어 · 힌디어 · 헝가리어 · 이탈리아어 · 일본어 · 한국어 · 폴란드어 · 러시아어 · 중국어 간체 · 스페인어 · 중국어 번체 · 터키어 애플리케이션 설정에서 언어를 변경할 수 있습니다. **시스템**을 선택하면 브라우저 버전도 브라우저의 기본 로캘을 사용합니다. diff --git a/docs/readme/README.nl.md b/docs/readme/README.nl.md index 7d2e4156d..27b73f256 100644 --- a/docs/readme/README.nl.md +++ b/docs/readme/README.nl.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo van Open CAD Studio

@@ -91,9 +91,9 @@ De toepassing is ad hoc ondertekend, maar momenteel niet door Apple genotariseer ## Talen -Open CAD Studio kan de systeemtaal volgen of een van deze 20 interfacetalen gebruiken: +Open CAD Studio kan de systeemtaal volgen of een van deze 21 interfacetalen gebruiken: -> Arabisch · Braziliaans-Portugees · Bulgaars · Tsjechisch · Nederlands · Engels · Fins · Frans · Duits · Hindi · Hongaars · Italiaans · Japans · Koreaans · Pools · Russisch · Vereenvoudigd Chinees · Spaans · Traditioneel Chinees · Turks +> Arabisch · Braziliaans-Portugees · Bulgaars · Tsjechisch · Nederlands · Engels · Fins · Frans · Duits · Grieks · Hindi · Hongaars · Italiaans · Japans · Koreaans · Pools · Russisch · Vereenvoudigd Chinees · Spaans · Traditioneel Chinees · Turks Wijzig de taal in de toepassingsinstellingen. De browserversie gebruikt ook de voorkeurstaal van de browser wanneer **Systeem** is geselecteerd. diff --git a/docs/readme/README.pl.md b/docs/readme/README.pl.md index 680881a89..c41814eb9 100644 --- a/docs/readme/README.pl.md +++ b/docs/readme/README.pl.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo Open CAD Studio

@@ -91,9 +91,9 @@ Aplikacja jest podpisana ad hoc, ale obecnie nie jest notaryzowana przez Apple. ## Języki -Open CAD Studio może korzystać z języka systemu lub jednego z 20 języków interfejsu: +Open CAD Studio może korzystać z języka systemu lub jednego z 21 języków interfejsu: -> Arabski · Portugalski brazylijski · Bułgarski · Czeski · Niderlandzki · Angielski · Fiński · Francuski · Niemiecki · Hindi · Węgierski · Włoski · Japoński · Koreański · Polski · Rosyjski · Chiński uproszczony · Hiszpański · Chiński tradycyjny · Turecki +> Arabski · Portugalski brazylijski · Bułgarski · Czeski · Niderlandzki · Angielski · Fiński · Francuski · Niemiecki · Grecki · Hindi · Węgierski · Włoski · Japoński · Koreański · Polski · Rosyjski · Chiński uproszczony · Hiszpański · Chiński tradycyjny · Turecki Język zmienisz w ustawieniach aplikacji. Gdy wybrano **System**, wersja przeglądarkowa używa także preferowanych ustawień regionalnych przeglądarki. diff --git a/docs/readme/README.pt-BR.md b/docs/readme/README.pt-BR.md index bb7824322..8950a211f 100644 --- a/docs/readme/README.pt-BR.md +++ b/docs/readme/README.pt-BR.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logotipo do Open CAD Studio

@@ -91,9 +91,9 @@ O aplicativo possui assinatura ad hoc, mas atualmente não é notarizado pela Ap ## Idiomas -Open CAD Studio pode seguir o idioma do sistema ou usar um destes 20 idiomas de interface: +Open CAD Studio pode seguir o idioma do sistema ou usar um destes 21 idiomas de interface: -> Árabe · Português do Brasil · Búlgaro · Tcheco · Holandês · Inglês · Finlandês · Francês · Alemão · Hindi · Húngaro · Italiano · Japonês · Coreano · Polonês · Russo · Chinês simplificado · Espanhol · Chinês tradicional · Turco +> Árabe · Português do Brasil · Búlgaro · Tcheco · Holandês · Inglês · Finlandês · Francês · Alemão · Grego · Hindi · Húngaro · Italiano · Japonês · Coreano · Polonês · Russo · Chinês simplificado · Espanhol · Chinês tradicional · Turco Altere o idioma nas configurações do aplicativo. A versão web também usa a localidade preferida do navegador quando **Sistema** está selecionado. diff --git a/docs/readme/README.ru.md b/docs/readme/README.ru.md index 5b2d178f7..a60d046c0 100644 --- a/docs/readme/README.ru.md +++ b/docs/readme/README.ru.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Логотип Open CAD Studio

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## Языки -Open CAD Studio может следовать языку системы или использовать один из 20 языков интерфейса: +Open CAD Studio может следовать языку системы или использовать любой из следующих языков интерфейса (всего 21): -> Арабский · Бразильский португальский · Болгарский · Чешский · Нидерландский · Английский · Финский · Французский · Немецкий · Хинди · Венгерский · Итальянский · Японский · Корейский · Польский · Русский · Упрощённый китайский · Испанский · Традиционный китайский · Турецкий +> Арабский · Бразильский португальский · Болгарский · Чешский · Нидерландский · Английский · Финский · Французский · Немецкий · Греческий · Хинди · Венгерский · Итальянский · Японский · Корейский · Польский · Русский · Упрощённый китайский · Испанский · Традиционный китайский · Турецкий Язык меняется в настройках приложения. При выборе **Система** версия для браузера также использует предпочтительную локаль браузера. diff --git a/docs/readme/README.tr.md b/docs/readme/README.tr.md index 52c760b6b..04ee08115 100644 --- a/docs/readme/README.tr.md +++ b/docs/readme/README.tr.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio logosu

@@ -93,9 +93,9 @@ Uygulama geçici imzaya sahiptir ancak şu anda Apple tarafından noterlenmemiş ## Diller -Open CAD Studio sistem dilini izleyebilir veya şu 20 arayüz dilinden birini kullanabilir: +Open CAD Studio sistem dilini izleyebilir veya şu 21 arayüz dilinden birini kullanabilir: -> Arapça · Brezilya Portekizcesi · Bulgarca · Çekçe · Felemenkçe · İngilizce · Fince · Fransızca · Almanca · Hintçe · Macarca · İtalyanca · Japonca · Korece · Lehçe · Rusça · Basitleştirilmiş Çince · İspanyolca · Geleneksel Çince · Türkçe +> Arapça · Brezilya Portekizcesi · Bulgarca · Çekçe · Felemenkçe · İngilizce · Fince · Fransızca · Almanca · Yunanca · Hintçe · Macarca · İtalyanca · Japonca · Korece · Lehçe · Rusça · Basitleştirilmiş Çince · İspanyolca · Geleneksel Çince · Türkçe Dili uygulama ayarlarından değiştirin. **Sistem** seçildiğinde tarayıcı sürümü de tarayıcının tercih ettiği yerel ayarı kullanır. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 901bd350b..a561344af 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio 标志

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 语言 -Open CAD Studio 可以跟随系统语言,也可以使用以下 20 种界面语言: +Open CAD Studio 可以跟随系统语言,也可以使用以下 21 种界面语言: -> 阿拉伯语 · 巴西葡萄牙语 · 保加利亚语 · 捷克语 · 荷兰语 · 英语 · 芬兰语 · 法语 · 德语 · 印地语 · 匈牙利语 · 意大利语 · 日语 · 韩语 · 波兰语 · 俄语 · 简体中文 · 西班牙语 · 繁体中文 · 土耳其语 +> 阿拉伯语 · 巴西葡萄牙语 · 保加利亚语 · 捷克语 · 荷兰语 · 英语 · 芬兰语 · 法语 · 德语 · 希腊语 · 印地语 · 匈牙利语 · 意大利语 · 日语 · 韩语 · 波兰语 · 俄语 · 简体中文 · 西班牙语 · 繁体中文 · 土耳其语 可在应用设置中更改语言。选择**系统**时,浏览器版也会使用浏览器的首选区域设置。 diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index 8d9f2f21f..e84a40497 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio 標誌

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 語言 -Open CAD Studio 可以跟隨系統語言,也可以使用以下 20 種介面語言: +Open CAD Studio 可以跟隨系統語言,也可以使用以下 21 種介面語言: -> 阿拉伯文 · 巴西葡萄牙文 · 保加利亞文 · 捷克文 · 荷蘭文 · 英文 · 芬蘭文 · 法文 · 德文 · 北印度文 · 匈牙利文 · 義大利文 · 日文 · 韓文 · 波蘭文 · 俄文 · 簡體中文 · 西班牙文 · 繁體中文 · 土耳其文 +> 阿拉伯文 · 巴西葡萄牙文 · 保加利亞文 · 捷克文 · 荷蘭文 · 英文 · 芬蘭文 · 法文 · 德文 · 希臘文 · 北印度文 · 匈牙利文 · 義大利文 · 日文 · 韓文 · 波蘭文 · 俄文 · 簡體中文 · 西班牙文 · 繁體中文 · 土耳其文 可在應用程式設定中變更語言。選擇**系統**時,瀏覽器版也會使用瀏覽器的偏好地區設定。 diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index c97e91a90..6e57b1115 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -167,6 +167,7 @@ clipboard = .paste-to-original-coordinates = Επικόλληση στις αρχικές συντεταγμένες viewport = + .preparing-layout = Προετοιμασία διάταξης… .split-vertical = Κατακόρυφος διαχωρισμός θύρας προβολής .split-horizontal = Οριζόντιος διαχωρισμός θύρας προβολής .move = Μετακίνηση θύρας προβολής diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index d812808aa..f18f782ad 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -167,6 +167,7 @@ clipboard = .paste-to-original-coordinates = Paste to Original Coordinates viewport = + .preparing-layout = Preparing layout… .split-vertical = Split Viewport Vertically .split-horizontal = Split Viewport Horizontally .move = Move Viewport diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 11b14c6a0..2a7aa4118 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -165,6 +165,7 @@ clipboard = .paste-to-original-coordinates = Özgün Koordinatlara Yapıştır viewport = + .preparing-layout = Yerleşim hazırlanıyor… .split-vertical = Görünüm Penceresini Dikey Böl .split-horizontal = Görünüm Penceresini Yatay Böl .move = Görünüm Penceresini Taşı diff --git a/src/app/automation.rs b/src/app/automation.rs index 3836ada6b..e0b11eb66 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -138,9 +138,9 @@ fn v3(v: acadrust::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } -/// One entity as JSON: handle, type, layer, plus basic geometry for the common -/// types (others carry only the common fields). -fn entity_json(e: &acadrust::EntityType) -> Value { +/// One entity as JSON. Summary mode carries identity only, geometry adds the +/// entity's defining values, and full also includes its world bounds. +fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { use acadrust::EntityType as E; let c = e.common(); let mut obj = json!({ @@ -148,6 +148,9 @@ fn entity_json(e: &acadrust::EntityType) -> Value { "type": crate::entities::names::ui_name(e), "layer": c.layer, }); + if detail == "summary" { + return obj; + } let map = obj.as_object_mut().expect("json object"); match e { E::Line(l) => { @@ -194,9 +197,41 @@ fn entity_json(e: &acadrust::EntityType) -> Value { } _ => {} } + if detail == "full" { + let bounds = e.as_entity().bounding_box(); + map.insert("bounds".into(), json!({ + "min":[bounds.min.x,bounds.min.y,bounds.min.z], + "max":[bounds.max.x,bounds.max.y,bounds.max.z] + })); + } obj } +fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> { + let values = req[key].as_array()?; + if !(2..=3).contains(&values.len()) { + return None; + } + let x = values[0].as_f64()?; + let y = values[1].as_f64()?; + (x.is_finite() && y.is_finite()).then_some([x, y]) +} + +fn request_handle(value: &Value) -> Option { + value.as_str() + .and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok()) + .map(acadrust::Handle::new) +} + +fn projected_fields(mut entity: Value, fields: Option<&Vec>) -> Value { + let Some(fields) = fields else { return entity }; + let Some(source) = entity.as_object_mut() else { return entity }; + let keep: std::collections::HashSet<&str> = + fields.iter().filter_map(Value::as_str).collect(); + source.retain(|key, _| key == "handle" || keep.contains(key.as_str())); + entity +} + impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. #[cfg(any(test, not(target_arch = "wasm32")))] @@ -445,38 +480,156 @@ impl OpenCADStudio { Ok(()) } - /// List entities (handle, type, layer, basic geometry), optionally filtered - /// by `type` and/or `layer`, capped by `limit` (default 1000). + /// Query entities by identity, metadata and exact plane-curve relationships. fn entity_query(&self, req: &Value) -> Value { let i = self.active_tab; + let tab = &self.tabs[i]; + if let Some(pair) = req["intersections"].as_array() { + if pair.len() != 2 { + return err("query intersections expects exactly two handles"); + } + let Some(first) = request_handle(&pair[0]) else { + return err("query intersections contains an invalid first handle"); + }; + let Some(second) = request_handle(&pair[1]) else { + return err("query intersections contains an invalid second handle"); + }; + let Some(first_entity) = tab.scene.document.get_entity(first) else { + return err("query intersections first entity does not exist"); + }; + let Some(second_entity) = tab.scene.document.get_entity(second) else { + return err("query intersections second entity does not exist"); + }; + let Some(first_curve) = crate::entities::curve::entity_curve_xy(first_entity) else { + return err("query intersections first entity is not a planar curve"); + }; + let Some(second_curve) = crate::entities::curve::entity_curve_xy(second_entity) else { + return err("query intersections second entity is not a planar curve"); + }; + let crossings = cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ); + return json!({ + "ok":true, + "document_id":tab.id, + "geometry_revision":tab.scene.geometry_epoch, + "handles":[format!("{:X}",first.value()),format!("{:X}",second.value())], + "count":crossings.len(), + "intersections":crossings.into_iter().map(|crossing|json!({ + "point":[crossing.point[0],crossing.point[1]], + "parameter_first":crossing.t_a, + "parameter_second":crossing.t_b + })).collect::>() + }); + } + let type_filter = req["type"].as_str(); let layer_filter = req["layer"].as_str(); - let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; - let offset = req["offset"].as_u64().unwrap_or(0); - - let mut entities = Vec::new(); - let mut matched = 0u64; - for e in self.tabs[i].scene.document.entities() { - if let Some(tf) = type_filter { - if !crate::entities::names::ui_name(e).eq_ignore_ascii_case(tf) { - continue; - } - } - if let Some(lf) = layer_filter { - if e.common().layer != lf { - continue; - } - } - matched += 1; - if matched > offset && entities.len() < limit { - entities.push(entity_json(e)); - } + let handles: Option> = req["handles"] + .as_array() + .map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect()) + .or_else(|| request_handle(&req["handle"]) + .map(|handle| std::iter::once(handle.value()).collect())); + let near = request_point(req, "near"); + let contains = request_point(req, "contains_point"); + let bounds = req["bounds"].as_array().and_then(|values| { + (values.len() == 4).then(|| Some([ + values[0].as_f64()?, values[1].as_f64()?, + values[2].as_f64()?, values[3].as_f64()?, + ])).flatten() + }); + if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| { + parsed.len() != req["handles"].as_array().map_or(0, Vec::len) + }) { + return err("query handles contains an invalid hexadecimal handle"); } + if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() { + return err("query handle must be hexadecimal"); + } + if req.get("near").is_some() && near.is_none() { + return err("query near expects two or three finite coordinates"); + } + if req.get("contains_point").is_some() && contains.is_none() { + return err("query contains_point expects two or three finite coordinates"); + } + if req.get("bounds").is_some() && bounds.is_none_or(|bounds| { + !bounds.iter().all(|value| value.is_finite()) + || bounds[0] > bounds[2] || bounds[1] > bounds[3] + }) { + return err("query bounds expects finite [min_x,min_y,max_x,max_y]"); + } + let detail = req["detail"].as_str().unwrap_or("geometry"); + if !matches!(detail, "summary" | "geometry" | "full") { + return err("query detail must be summary, geometry or full"); + } + let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; + let offset = req["offset"].as_u64().unwrap_or(0) as usize; + + let mut matched = Vec::new(); + for e in tab.scene.document.entities() { + if handles.as_ref().is_some_and(|handles| { + !handles.contains(&e.common().handle.value()) + }) { + continue; + } + if type_filter.is_some_and(|value| { + !crate::entities::names::ui_name(e).eq_ignore_ascii_case(value) + }) || layer_filter.is_some_and(|value| e.common().layer != value) { + continue; + } + if let Some(bounds) = bounds { + let entity_bounds = e.as_entity().bounding_box(); + if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1] + || entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3] + { + continue; + } + } + let curve = (near.is_some() || contains.is_some()) + .then(|| crate::entities::curve::entity_curve_xy(e)).flatten(); + if let Some(point) = contains { + let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else { + continue; + }; + if !cadkernel::geom2d::contains( + std::slice::from_ref(curve), point, + cadkernel::geom2d::Tolerance::default(), + ) { + continue; + } + } + let nearest = near.and_then(|point| curve.as_ref() + .map(|curve| cadkernel::geom2d::closest_point(curve, point))); + if near.is_some() && nearest.is_none() { + continue; + } + let mut entity = entity_json(e, detail); + if let Some(nearest) = nearest { + let object = entity.as_object_mut().expect("entity JSON object"); + object.insert("distance".into(), json!(nearest.distance)); + object.insert("closest_point".into(), + json!([nearest.point[0], nearest.point[1]])); + object.insert("parameter".into(), json!(nearest.t)); + } + matched.push((nearest.map(|nearest| nearest.distance), entity)); + } + if near.is_some() { + matched.sort_by(|left, right| left.0.partial_cmp(&right.0) + .unwrap_or(std::cmp::Ordering::Equal)); + } + let count = matched.len(); + let fields = req["fields"].as_array(); + let entities: Vec = matched.into_iter().skip(offset).take(limit) + .map(|(_, entity)| projected_fields(entity, fields)).collect(); json!({ "ok": true, - "count": matched, + "document_id":tab.id, + "geometry_revision":tab.scene.geometry_epoch, + "count": count, "returned": entities.len(), - "next_offset": if offset + (entities.len() as u64) < matched { Some(offset + entities.len() as u64) } else { None }, + "next_offset": (offset + entities.len() < count).then_some(offset + entities.len()), "entities": entities, }) } @@ -500,6 +653,36 @@ impl OpenCADStudio { mod tests { use crate::app::OpenCADStudio; + #[test] + fn layout_notice_skips_grid_camera_and_scene_builds() { + let mut app = OpenCADStudio::new_for_test(); + assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); + assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + scene.document.add_layout("Review").unwrap(); + scene.set_current_layout("Review".to_string()); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.id = 2; + viewport.width = 100.0; + viewport.height = 50.0; + viewport.status.is_on = true; + scene.add_entity(acadrust::EntityType::Viewport(viewport)); + for entity in scene.document.entities_mut() { + if let acadrust::EntityType::Viewport(viewport) = entity { + viewport.status.grid_on = true; + } + } + let before = scene.last_tess_wires.get(); + app.layout_settling = true; + drop(app.view_main()); + assert_eq!(app.tabs[i].scene.last_tess_wires.get(), before); + let _ = app.update(crate::app::Message::LayoutSettled); + assert!(!app.layout_settling); + drop(app.view_main()); + assert!(app.tabs[i].scene.last_tess_wires.get() > before); + } + #[test] fn automation_ops_round_trip() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e0c93dfc3..7bc23ef39 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1,6 +1,6 @@ //! Semantic control shared by the GUI, headless client and web adapter. use super::{Message, OpenCADStudio}; -use crate::command::StepInput; +use crate::command::{InputKind, StepInput}; use iced::Task; use serde_json::{json, Value}; use std::{collections::VecDeque, sync::OnceLock}; @@ -166,6 +166,178 @@ fn point(req: &Value) -> Result { Ok(glam::DVec3::from_array(p)) } +fn command_examples(name: &str) -> &'static [&'static str] { + match name { + "LINE" => &["LINE 0,0 10,10"], + "CIRCLE" => &["CIRCLE 5,5 3"], + "PLINE" => &["PLINE 0,0 10,0 10,10 C"], + "ZOOM" => &["ZOOM EXTENTS"], + "MOVE" => &["MOVE 0,0 100,0"], + "ROTATE" => &["ROTATE 0,0 90"], + "UCS" => &["UCS Z 90"], + "PDMODE" => &["PDMODE 3"], + "LTSCALE" => &["LTSCALE 2.5"], + _ => &[], + } +} + +fn command_category(name: &str) -> &'static str { + if [ + "LINE", "CIRCLE", "PLINE", "ARC", "ELLIPSE", "POINT", "SPLINE", "RECT", "RECTANG", + "POLYGON", "HATCH", "RAY", "XLINE", "3DPOLY", "MLINE", "TEXT", "MTEXT", "TABLE", + ] + .contains(&name) + { + "create" + } else if [ + "MOVE", + "COPY", + "ROTATE", + "SCALE", + "MIRROR", + "ERASE", + "TRIM", + "EXTEND", + "OFFSET", + "FILLET", + "CHAMFER", + "BREAK", + "JOIN", + "EXPLODE", + "STRETCH", + "ARRAY", + "PEDIT", + "SPLINEDIT", + "REVERSE", + "ALIGN", + ] + .contains(&name) + { + "modify" + } else if ["DIST", "ID", "AREA", "MEASURE", "MEASUREGEOM"].contains(&name) { + "inspect" + } else if ["ZOOM", "PAN", "UCS", "PLAN", "VPORTS", "3DORBIT"].contains(&name) { + "view" + } else if name.starts_with("DIM") || ["LEADER", "MLEADER", "TOLERANCE"].contains(&name) { + "annotate" + } else if [ + "BOX", + "CYLINDER", + "CONE", + "SPHERE", + "TORUS", + "WEDGE", + "EXTRUDE", + "REVOLVE", + "SWEEP", + "LOFT", + "UNION", + "SUBTRACT", + "INTERSECT", + "PRESSPULL", + ] + .contains(&name) + { + "model" + } else { + "other" + } +} + +fn command_selection_policy(name: &str) -> &'static str { + if [ + "ARRAY", + "ARRAYPATH", + "ARRAYPOLAR", + "ARRAYRECT", + "BLOCK", + "COPY", + "COPYCLIP", + "CUTCLIP", + "ERASE", + "EXPLODE", + "GROUP", + "LAYFRZ", + "LAYLCK", + "LAYMCUR", + "LAYOFF", + "LAYULK", + "MIRROR", + "MOVE", + "ROTATE", + "SCALE", + "STRETCH", + ] + .contains(&name) + { + "current_selection_or_prompt" + } else { + "command_defined" + } +} + +fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { + let options = command.options(); + let mut accepts = Vec::new(); + if command.is_selection_gathering() { + accepts.push("selection"); + } + if command.needs_structure_point_pick() { + accepts.push("structure"); + } + if command.needs_entity_pick() { + accepts.push("entity"); + } + if accepts.is_empty() && !command.needs_tangent_pick() { + accepts.push(match command.input_kind() { + InputKind::Point => "point", + InputKind::SingleToken => "token", + InputKind::FreeText => "text", + }); + } + if options.iter().any(|option| !option.keyword.is_empty()) && !accepts.contains(&"token") { + accepts.push("token"); + } + accepts.push("enter"); + + let input_example = if accepts.contains(&"point") { + json!({"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}) + } else if accepts.contains(&"entity") { + json!({"op":"input","kind":"entity","handle":"HANDLE","point":[0,0,0]}) + } else if accepts.contains(&"structure") { + json!({"op":"input","kind":"structure","handle":"HANDLE","point":[0,0,0]}) + } else if accepts.contains(&"selection") { + json!({"op":"input","kind":"selection"}) + } else if accepts.contains(&"text") { + json!({"op":"input","kind":"text","text":"value"}) + } else if accepts.contains(&"token") { + let token = options + .iter() + .find(|option| !option.keyword.is_empty()) + .map_or("VALUE", |option| option.keyword.as_str()); + json!({"op":"input","kind":"token","text":token}) + } else { + json!({"op":"input","kind":"enter"}) + }; + + json!({ + "name":command.name(), + "prompt":command.prompt(), + "accepts":accepts, + "input_example":input_example, + "options":options.iter().map(|option| json!({ + "label":option.label, + "keyword":option.keyword, + "kind":if option.keyword.is_empty(){"enter"}else{"token"} + })).collect::>(), + "entity_pick":command.needs_entity_pick(), + "structure_pick":command.needs_structure_point_pick(), + "selection":command.is_selection_gathering(), + "tangent_pick":command.needs_tangent_pick(), + "free_text":command.is_free_text_step() + }) +} + impl OpenCADStudio { pub(super) fn control_busy(&self) -> bool { self.control.pending.is_some() @@ -173,10 +345,7 @@ impl OpenCADStudio { pub(super) fn control_state(&self) -> Value { let tab = &self.tabs[self.active_tab]; - let command = tab.active_cmd.as_ref().map(|c| json!({ - "name":c.name(),"prompt":c.prompt(),"options":c.options().iter().map(|o|json!({"label":o.label,"keyword":o.keyword})).collect::>(), - "entity_pick":c.needs_entity_pick(),"structure_pick":c.needs_structure_point_pick(),"selection":c.is_selection_gathering(),"tangent_pick":c.needs_tangent_pick(),"free_text":c.is_free_text_step() - })); + let command = tab.active_cmd.as_deref().map(active_command_metadata); 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, "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, @@ -192,7 +361,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","step_input","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","measure"] + "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] }) } @@ -272,8 +441,61 @@ impl OpenCADStudio { names.extend(self.command_line.dynamic_commands.iter().cloned()); names.sort_unstable(); names.dedup(); + if let Some(requested) = req["name"].as_str() { + let Some(name) = names + .iter() + .find(|name| name.eq_ignore_ascii_case(requested)) + else { + return ( + failure( + "unknown_command", + format!("Unknown command {requested}; call commands without name to list commands"), + ), + Task::none(), + ); + }; + return ( + json!({ + "ok":true, + "command":{ + "name":name, + "category":command_category(name), + "preconditions":{ + "document":true, + "selection":command_selection_policy(name) + }, + "batch":{ + "op":"run", + "syntax":"Command name followed by prompt answers separated by spaces. Points use x,y or x,y,z; options use their token.", + "examples":command_examples(name) + }, + "interactive":{ + "op":"start", + "request":{"op":"start","cmd":name}, + "next":"Follow state.command.accepts, options and input_example after every step." + }, + "completion":"completed is final; waiting_input requires another input step; failed includes the reason." + } + }), + Task::none(), + ); + } + if let Some(search) = req["search"].as_str().filter(|search| !search.is_empty()) { + let search = search.to_ascii_uppercase(); + names.retain(|name| name.contains(&search)); + } + let count = names.len(); + let offset = req["offset"].as_u64().unwrap_or(0) as usize; + let limit = req["limit"].as_u64().unwrap_or(200).min(1000) as usize; + let commands: Vec = names.into_iter().skip(offset).take(limit).collect(); return ( - json!({"ok":true,"commands":names,"actions":actions::NAMES}), + json!({ + "ok":true,"count":count,"returned":commands.len(), + "next_offset":(offset + commands.len() < count).then_some(offset + commands.len()), + "commands":commands,"actions":actions::NAMES, + "detail_parameters":{"name":"LINE"},"search_parameter":{"search":"LINE"}, + "guidance":"Request one command by name for batch examples and interactive input guidance." + }), Task::none(), ); } @@ -751,7 +973,16 @@ impl OpenCADStudio { h.isolines.max(0) as usize, ) }); - out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); + let curve = crate::entities::curve::entity_curve(entity).map(|planar| { + let length = planar.curve.length(); + json!({ + "bounded":length.is_finite(), + "closed":planar.curve.is_closed(), + "length":length.is_finite().then_some(length), + "area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs()) + }) + }); + out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); } json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out}) } @@ -763,17 +994,66 @@ impl OpenCADStudio { ) { let result = (|| -> Result { let s = screenshot.ok_or("Renderer did not return an image")?; - image::save_buffer_with_format( - &path, - &s.rgba, - s.size.width, - s.size.height, - image::ColorType::Rgba8, - image::ImageFormat::Png, - ) - .map_err(|e| e.to_string())?; + let requested_scope = self + .control + .pending + .as_ref() + .and_then(|pending| pending.request["scope"].as_str()) + .unwrap_or("window"); + let max_dimension = self + .control + .pending + .as_ref() + .and_then(|pending| pending.request["max_dimension"].as_u64()) + .unwrap_or(1600) + .clamp(256, 4096) as u32; + let mut image = + image::RgbaImage::from_raw(s.size.width, s.size.height, s.rgba.to_vec()) + .ok_or("Renderer returned malformed image data")?; + let mut actual_scope = "window"; + if requested_scope == "viewport" { + if let Some(bounds) = crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) { + let scale = s.scale_factor; + let left = (bounds.x * scale).floor().clamp(0.0, image.width() as f32) as u32; + let top = (bounds.y * scale).floor().clamp(0.0, image.height() as f32) as u32; + let right = ((bounds.x + bounds.width) * scale) + .ceil() + .clamp(0.0, image.width() as f32) as u32; + let bottom = ((bounds.y + bounds.height) * scale) + .ceil() + .clamp(0.0, image.height() as f32) as u32; + if right > left && bottom > top { + image = image::imageops::crop_imm( + &image, + left, + top, + right - left, + bottom - top, + ) + .to_image(); + actual_scope = "viewport"; + } + } + } + let longest = image.width().max(image.height()); + if longest > max_dimension { + let scale = max_dimension as f64 / longest as f64; + let width = ((image.width() as f64 * scale).round() as u32).max(1); + let height = ((image.height() as f64 * scale).round() as u32).max(1); + image = image::imageops::resize( + &image, + width, + height, + image::imageops::FilterType::Triangle, + ); + } + image + .save_with_format(&path, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; Ok( - json!({"path":path,"width":s.size.width,"height":s.size.height,"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}), + json!({"path":path,"scope":actual_scope,"width":image.width(),"height":image.height(),"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}), ) })(); match result { @@ -787,6 +1067,9 @@ impl OpenCADStudio { } } mod actions; +pub(super) fn action_names() -> &'static [&'static str] { + actions::NAMES +} #[cfg(test)] mod tests { @@ -816,9 +1099,15 @@ mod tests { request(&mut app, json!({"op":"new"}))["status"], "completed" ); + let started = request(&mut app, json!({"op":"start","cmd":"LINE"})); + assert_eq!(started["status"], "waiting_input"); + assert!(started["state"]["command"]["accepts"] + .as_array() + .unwrap() + .contains(&json!("point"))); assert_eq!( - request(&mut app, json!({"op":"start","cmd":"LINE"}))["status"], - "waiting_input" + started["state"]["command"]["input_example"]["kind"], + "point" ); assert_eq!( request( @@ -864,6 +1153,76 @@ mod tests { assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0); } #[test] + fn command_discovery_explains_batch_and_interactive_use() { + let mut app = OpenCADStudio::new_for_test(); + let detail = app + .control_request(json!({"op":"commands","name":"pline"})) + .0; + assert_eq!(detail["command"]["name"], "PLINE"); + assert_eq!( + detail["command"]["batch"]["examples"][0], + "PLINE 0,0 10,0 10,10 C" + ); + assert_eq!(detail["command"]["interactive"]["op"], "start"); + assert_eq!(detail["command"]["category"], "create"); + assert_eq!(detail["command"]["preconditions"]["document"], true); + assert_eq!( + app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"})) + .0["code"], + "unknown_command" + ); + } + #[test] + fn control_queries_exact_curve_relationships_and_metrics() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE -5,0 5,0"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,-5 0,5"})); + request(&mut app, json!({"op":"run","cmd":"CIRCLE 20,0 2"})); + + let lines = app.control_request(json!({"op":"query","type":"Line"})).0; + let first = lines["entities"][0]["handle"].as_str().unwrap(); + let second = lines["entities"][1]["handle"].as_str().unwrap(); + let intersections = app + .control_request(json!({"op":"query","intersections":[first,second]})) + .0; + assert_eq!(intersections["count"], 1, "{intersections}"); + assert_eq!( + intersections["intersections"][0]["point"], + json!([0.0, 0.0]) + ); + + let nearest = app + .control_request(json!({"op":"query","near":[20.0,0.0],"limit":1})) + .0; + assert_eq!(nearest["entities"][0]["type"], "Circle"); + assert_eq!(nearest["entities"][0]["distance"], 2.0); + let circle = nearest["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + + let contains = app + .control_request(json!({"op":"query","contains_point":[20.0,0.0]})) + .0; + assert_eq!(contains["count"], 1, "{contains}"); + assert_eq!(contains["entities"][0]["handle"], circle); + + let projected = app + .control_request( + json!({"op":"query","handle":circle,"detail":"full","fields":["type"]}), + ) + .0; + assert_eq!(projected["entities"][0].as_object().unwrap().len(), 2); + + let measured = app + .control_request(json!({"op":"measure","handles":[circle]})) + .0; + let curve = &measured["measurements"][0]["curve"]; + assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9); + assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9); + } + #[test] fn control_retry_is_idempotent_and_stale_state_rejected() { let mut app = OpenCADStudio::new_for_test(); request(&mut app, json!({"op":"new"})); diff --git a/src/app/mod.rs b/src/app/mod.rs index eb89bc04c..a4547f0dd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,9 @@ mod alias; mod automation; mod control; +pub(crate) fn automation_action_names() -> &'static [&'static str] { + control::action_names() +} pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; @@ -1052,6 +1055,8 @@ pub(super) struct OpenCADStudio { /// `Some` while a CAD file is loading — drives the modal overlay. /// Cleared when the load finishes, errors, or the user cancels. pub(super) opening: Option, + /// Show a lightweight notice until the next layout redraw. + pub(super) layout_settling: bool, open_job_serial: u64, /// Last repair or failed-open report shown in the recovery modal. recovery_report: Option, @@ -2280,6 +2285,8 @@ pub enum Message { /// Timer pulse while the cursor is dwelling on a grip; drives the /// dwell-to-popup transition without requiring further mouse motion. GripDwellTick, + /// The notice redraw allows the next frame to build the scene. + LayoutSettled, /// Timer pulse while a rollover hit-test is queued; fires when the /// cursor has been still long enough to safely run the pick. HoverDwellTick, @@ -3481,6 +3488,7 @@ impl OpenCADStudio { print_all_plot_window_prev: None, print_all_plot_setup_prev: None, opening: None, + layout_settling: false, open_job_serial: 0, recovery_report: None, pending_opens: std::collections::VecDeque::new(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bd903e6d5..5e04de369 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3045,6 +3045,11 @@ impl OpenCADStudio { Task::none() } + Message::LayoutSettled => { + // Restore the scene after the notice redraw. + self.layout_settling = false; + Task::none() + } Message::GripDwellTick => { let i = self.active_tab; // Reuse the move-time logic — `p` is the last cursor diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 3474330de..7541e8742 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2407,6 +2407,7 @@ impl OpenCADStudio { let far_pos = base + dir * far; let far_neg = base - dir * far; let guide = crate::scene::WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -5534,6 +5535,8 @@ impl OpenCADStudio { .layers .sync_with_viewports(&doc_layers, vp_info); let layers_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; + // Give the layout rebuild a visible notice first. + self.layout_settling = true; if perf { crate::perf_record!( "[perf] layout-switch from={} to={} total={:.2}ms sync={:.2}ms switch={:.2}ms restore={:.2}ms layers={:.2}ms epoch={}", diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3789f6366..2895b2aae 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -36,11 +36,7 @@ pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bo const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; -/// Base surface directly under the crosshair, selection marquee, and grid overlay. -/// In paper space, drafting surface context is unconditionally the white sheet (`tab.scene.paper_bg_color`), -/// matching AutoCAD's model (where crosshairs and overlays adapt per context — model vs. sheet/layout — -/// rather than per-pixel cursor sampling). This avoids flicker across sheet edges and provides a clean -/// dark crosshair that maintains ~5.85:1 contrast against the default medium-gray desk margin (#8A8A8A). +/// Background used by drafting overlays in model or paper space. fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] { if !is_paper { return tab.scene.bg_color; @@ -197,6 +193,20 @@ impl OpenCADStudio { /// `view` so the single-window web build can render it directly, bypassing /// the multi-window id dispatch above (the web build has no extra windows). pub fn view_main(&self) -> Element<'_, Message> { + // Section marks for `view-detail`. A closure over a RefCell rather + // than one binding per section: two of these sections sit inside a + // nested block and would otherwise be out of scope at the report. + let view_t0 = crate::perf::enabled().then(iced::time::Instant::now); + let view_marks: std::cell::RefCell> = + std::cell::RefCell::new(Vec::with_capacity(8)); + let mark = |name: &'static str| { + if let Some(t0) = view_t0 { + view_marks + .borrow_mut() + .push((name, t0.elapsed().as_secs_f64() * 1000.0)); + } + }; + let i = self.active_tab; let tab = &self.tabs[i]; let thumbnail_capture_clean = self.thumbnail_capture_clean; @@ -257,7 +267,31 @@ impl OpenCADStudio { // the same shader as model space: a full-canvas top-locked "sheet" // viewport draws the layout's own geometry (white sheet + entities + // borders) and the floating content viewports blit on top. - let viewport_3d: Element<'_, Message> = if tab.is_start { + mark("viewport_3d"); + let viewport_3d: Element<'_, Message> = if self.layout_settling && !tab.is_start { + // Omit the shader and drafting overlays for the notice frame. + let bg = crosshair_background(tab, is_paper); + let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]; + let ink = if lum > 0.5 { + Color::from_rgba(0.0, 0.0, 0.0, 0.55) + } else { + Color::from_rgba(1.0, 1.0, 1.0, 0.55) + }; + container( + text(crate::tr!("viewport", "preparing-layout")) + .size(15) + .color(ink), + ) + .width(Fill) + .height(Fill) + .center_x(Fill) + .center_y(Fill) + .style(move |_| container::Style { + background: Some(Color::from_rgba(bg[0], bg[1], bg[2], bg[3]).into()), + ..container::Style::default() + }) + .into() + } else if tab.is_start { start_page_view( &self.patrons, &self.videos, @@ -334,7 +368,8 @@ impl OpenCADStudio { // the shader pane_grid (same `model_panes` → identical layout). Layered // above the crosshair overlay so it actually receives mouse events, and // it owns the divider resize. Only built for the Model layout. - let model_input_layer: Option> = if is_paper || tab.is_start { + mark("model_input"); + let model_input_layer: Option> = if is_paper || tab.is_start || self.layout_settling { None } else { let scene = &tab.scene; @@ -351,7 +386,10 @@ impl OpenCADStudio { ) }; - let grid_overlay = { + mark("grid"); + let grid_overlay = if self.layout_settling || tab.is_start { + Space::new().into() + } else { let (vw, vh) = tab.scene.selection.borrow().vp_size; let model_basis = { let (o, ux, uy, uz) = tab.ucs_xform().axes(); @@ -365,9 +403,15 @@ impl OpenCADStudio { ); (o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3())) }; - let grid: Vec = tab - .scene - .grid_views(vw, vh) + // The first paper frame spends seconds in this block and `grid` + // alone cannot say which part. Reported only when it costs + // something, so ordinary frames stay quiet. + let t_grid = crate::perf::enabled().then(iced::time::Instant::now); + let views = tab.scene.grid_views(vw, vh); + let views_ms = crate::perf::elapsed_ms(t_grid); + let view_count = views.len(); + let t_params = crate::perf::enabled().then(iced::time::Instant::now); + let grid: Vec = views .into_iter() .map(|(bounds, cam, handle)| { let (origin, mut axes): (glam::DVec3, _) = if is_paper { @@ -411,7 +455,16 @@ impl OpenCADStudio { } }) .collect(); + let params_ms = crate::perf::elapsed_ms(t_params); + let t_bg = crate::perf::enabled().then(iced::time::Instant::now); let bg = crosshair_background(tab, is_paper); + let bg_ms = crate::perf::elapsed_ms(t_bg); + if views_ms + params_ms + bg_ms >= 1.0 { + crate::perf_record!( + "[perf] grid-detail views={views_ms:.1}ms params={params_ms:.1}ms \ +bg={bg_ms:.1}ms n={view_count}" + ); + } let bg_lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]; let grid_style = crate::ui::overlay::GridStyle { opacity: self.model_space.grid_opacity, @@ -420,7 +473,10 @@ impl OpenCADStudio { crate::ui::overlay::grid_overlay(grid, grid_style) }; - let selection_overlay = { + mark("selection_overlay"); + let selection_overlay = if self.layout_settling || tab.is_start { + Space::new().into() + } else { // Hold a single `Ref<'_, SelectionState>` for the whole overlay // block. The `vp_size` and `last_move_pos` reads below become // field accesses on `sel_ref` (no redundant `borrow()`s), and @@ -790,6 +846,7 @@ impl OpenCADStudio { ) }; + mark("viewport_mouse"); let viewport_mouse = mouse_area(container( iced::widget::Space::new().width(Fill).height(Fill), )) @@ -935,7 +992,8 @@ impl OpenCADStudio { None }; - let mut viewport_stack = if tab.is_start { + mark("viewport_stack"); + let mut viewport_stack = if tab.is_start || self.layout_settling { // Start tab: only the welcome widget over a flat background. // Skip every drawing-only overlay (selection markers, snap info, // mouse-area capturing draw clicks, viewcube, nav toolbar, …). @@ -996,7 +1054,7 @@ impl OpenCADStudio { .height(Fill) }; - if !thumbnail_capture_clean { + if !thumbnail_capture_clean && !self.layout_settling { // Per-pane input pane_grid goes ABOVE the crosshair overlay so it // receives mouse events (the overlay's `Hidden` cursor would otherwise // starve any layer beneath it). The controls bar is pushed on top of it. @@ -1053,6 +1111,7 @@ impl OpenCADStudio { // both layered ABOVE the viewport mouse_area so they receive // clicks (the shader viewport sits below it). Positioned with // leading Spaces sized to the viewport's screen rectangle. + mark("active_vp_rect"); let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = if is_paper && !tab.is_start { tab.scene.active_viewport.and_then(|h| { @@ -1383,6 +1442,7 @@ impl OpenCADStudio { } // Reserve the overlaid command line when placing cursor-anchored panels. + mark("chrome"); let command_line_inset = if self.command_line.history_open { self.command_line.history_height.clamp( crate::ui::command_line::HISTORY_HEIGHT_MIN, @@ -2102,6 +2162,34 @@ impl OpenCADStudio { }; // Shared CAD colour picker. Indexed ACI colours use OpenCADStudio's own // dialog; True Color keeps the existing iced_aw gradient picker. + // Which part of the widget tree the frame went into. Reported as a + // breakdown rather than one number, because a first switch to a paper + // layout spends seconds here and `view` alone cannot say where. + if let Some(t0) = view_t0 { + mark("end"); + let total = t0.elapsed().as_secs_f64() * 1000.0; + if total >= 1.0 { + let marks = view_marks.borrow(); + // A mark opens a section; the following mark closes it, so a + // section's cost is the gap to its successor. The first mark + // is preceded by the preamble, reported as `head`. + let mut detail = String::new(); + if let Some((_, first)) = marks.first() { + let _ = std::fmt::Write::write_fmt( + &mut detail, + format_args!(" head={first:.1}"), + ); + } + for pair in marks.windows(2) { + let _ = std::fmt::Write::write_fmt( + &mut detail, + format_args!(" {}={:.1}", pair[0].0, pair[1].1 - pair[0].1), + ); + } + crate::perf_record!("[perf] view-detail total={total:.1}ms{detail}"); + } + } + if let Some((_, current)) = self.color_pick_target.as_ref() { match self.color_picker_tab { super::ColorPickerTab::Index => { @@ -2188,6 +2276,12 @@ impl OpenCADStudio { // the mouse perfectly still — `ViewportMove` alone would never // fire again. Auto-stops once the hover clears or the popup is // already open. + // Resume scene construction after the notice redraw. + let layout_settle = if self.layout_settling { + window::frames().map(|_| Message::LayoutSettled) + } else { + Subscription::none() + }; let grip_dwell = if self.grip_hover.is_some() && self.grip_popup.is_none() { window::frames().map(|_| Message::GripDwellTick) } else { @@ -2413,6 +2507,7 @@ impl OpenCADStudio { control, frames, history_tick, + layout_settle, grip_dwell, hover_dwell, nav_settle, diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 730cb421c..a219b0de3 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3882,6 +3882,7 @@ fn tessellate_dimension_inner( let (ext1, ext2) = split_ext_lines(&geom.ext_lines); if !ext1.is_empty() { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3917,6 +3918,7 @@ fn tessellate_dimension_inner( } if !ext2.is_empty() { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3952,6 +3954,7 @@ fn tessellate_dimension_inner( } } else { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3988,6 +3991,7 @@ fn tessellate_dimension_inner( } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4032,6 +4036,7 @@ fn tessellate_dimension_inner( let mut points = Vec::new(); add_polyline(&mut points, &symbol); wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4080,6 +4085,7 @@ fn tessellate_dimension_inner( aci_to_rgba(&c) }; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4130,6 +4136,7 @@ fn tessellate_dimension_inner( let p3 = rect[2]; let p4 = rect[5]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 91aee670c..901cf6f63 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -672,6 +672,7 @@ impl LeaderTess for Leader { if verts.len() < 2 { return WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -852,6 +853,7 @@ impl LeaderTess for Leader { crate::scene::convert::tessellate::points_to_ds(fill_tris); WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index c1c4bcc65..d020227e4 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2068,6 +2068,7 @@ impl MultiLeaderTess for MultiLeader { // WireModels so the renderer respects per-piece coloring. let mut wires: Vec = Vec::new(); wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2367,6 +2368,7 @@ impl MultiLeaderTess for MultiLeader { xy = xy.max(p[1] as f64); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2441,6 +2443,7 @@ impl MultiLeaderTess for MultiLeader { pts.push([bx, by, z]); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2530,6 +2533,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[3], ]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2574,6 +2578,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[0], ]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/table.rs b/src/entities/table.rs index 3b5174840..cb1c8c9c9 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1990,6 +1990,7 @@ pub fn tessellate_table( let mk = |color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/mcp.rs b/src/mcp.rs index 20bafd840..887528b62 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -3,11 +3,11 @@ //! `OpenCADStudio --mcp` speaks MCP over stdio. All drawing work is forwarded //! to the authenticated GUI control bridge; this module contains no geometry. -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde::Deserialize; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, fs::{File, OpenOptions}, io::{self, BufRead, BufReader, Read, Write}, net::TcpStream, @@ -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 = "Use ocs_sessions first; it opens OpenCADStudio when needed. Read state and commands before editing. Preserve session_id, document_id, revision, selection and request_id. 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 OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. 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 READ_OPS: &[&str] = &[ "state", "hello", @@ -38,9 +38,14 @@ const READ_OPS: &[&str] = &[ "operation", ]; const EXECUTE_OPS: &[&str] = &[ + "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", + "property", "action", "save", "stop", "batch", +]; +const BATCH_STEP_OPS: &[&str] = &[ "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", "property", "action", "save", "stop", ]; +const MAX_BATCH_STEPS: usize = 64; #[derive(Clone, Deserialize)] struct Descriptor { @@ -53,6 +58,44 @@ struct GuiClient { descriptor: Descriptor, state: Value, client_id: String, + batches: VecDeque, +} + +struct BatchExecution { + id: String, + request: Value, + steps: Vec, + next: usize, + active: Option, + results: Vec, + changes: Vec, + state: Option, + terminal: Option, +} + +struct McpTask { + id: String, + name: String, + arguments: Value, + created_at: String, + last_updated_at: String, + result: Option, + error: Option, +} + +#[derive(Default)] +struct TaskStore { + tasks: VecDeque, +} + +impl TaskStore { + fn insert(&mut self, task: McpTask) { + self.tasks.push_back(task); + } + + fn get_mut(&mut self, id: &str) -> Option<&mut McpTask> { + self.tasks.iter_mut().find(|task| task.id == id) + } } fn random_id() -> Result { @@ -61,6 +104,31 @@ fn random_id() -> Result { Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) } +fn iso8601_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let days = seconds.div_euclid(86_400); + let day_seconds = seconds.rem_euclid(86_400); + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + let hour = day_seconds / 3_600; + let minute = day_seconds % 3_600 / 60; + let second = day_seconds % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + #[cfg(unix)] fn private_descriptor(path: &Path) -> bool { use std::os::unix::fs::MetadataExt; @@ -226,6 +294,7 @@ impl GuiClient { descriptor, state, client_id: random_id()?, + batches: VecDeque::new(), }) } @@ -287,6 +356,206 @@ impl GuiClient { } Ok(response) } + + fn execute_batch(&mut self, request: Value, wait_seconds: f64) -> Result { + let id = required_string(&request, "request_id")?.to_owned(); + let mut batch = if let Some(position) = self.batches.iter().position(|batch| batch.id == id) + { + let batch = self + .batches + .remove(position) + .expect("batch position exists"); + if batch.request != request { + self.batches.push_back(batch); + return Err("request_id was already used for a different batch".into()); + } + batch + } else { + let steps = request["steps"] + .as_array() + .cloned() + .ok_or_else(|| "batch requires a steps array".to_string())?; + BatchExecution { + id, + request, + steps, + next: 0, + active: None, + results: Vec::new(), + changes: Vec::new(), + state: None, + terminal: None, + } + }; + + if let Some(result) = batch.terminal.clone() { + self.batches.push_back(batch); + return Ok(result); + } + + let deadline = Instant::now() + Duration::from_secs_f64(wait_seconds.clamp(0.0, 60.0)); + let mut attempted = false; + loop { + if batch.next == batch.steps.len() { + let waiting = batch + .state + .as_ref() + .is_some_and(|state| !state["command"].is_null()); + let result = batch_result( + &batch, + if waiting { + "waiting_input" + } else { + "completed" + }, + true, + ); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + if attempted && Instant::now() >= deadline { + let result = batch_result(&batch, "running", true); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + attempted = true; + + let step_id = batch + .active + .clone() + .unwrap_or_else(|| batch_step_id(&batch.id, batch.next)); + let response = if batch.active.is_some() { + self.request( + json!({"op":"operation","request_id":step_id}), + deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(), + ) + } else { + let mut step = batch.steps[batch.next] + .as_object() + .cloned() + .ok_or_else(|| format!("batch step {} must be an object", batch.next))?; + for key in [ + "revision", + "geometry_revision", + "camera_revision", + "selection", + "client_id", + ] { + step.remove(key); + } + step.insert("request_id".into(), Value::String(step_id.clone())); + self.request( + Value::Object(step), + deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(), + ) + }; + let response = match response { + Ok(response) => response, + Err(error) => { + batch.active = Some(step_id); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Err(error); + } + }; + + if matches!(response["status"].as_str(), Some("accepted" | "running")) { + batch.active = Some(step_id); + let result = batch_result(&batch, "running", true); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + + batch.active = None; + if let Some(state) = response.get("state") { + batch.state = Some(state.clone()); + } + if let Some(changes) = response["changes"].as_array() { + batch + .changes + .extend(changes.iter().cloned().map(|mut change| { + if let Some(object) = change.as_object_mut() { + object.insert("step".into(), Value::from(batch.next)); + } + change + })); + } + let mut compact = response.clone(); + if let Some(object) = compact.as_object_mut() { + object.remove("state"); + object.remove("changes"); + object.insert("step".into(), Value::from(batch.next)); + object.insert("op".into(), batch.steps[batch.next]["op"].clone()); + } + batch.results.push(compact); + batch.next += 1; + + if response["ok"].as_bool() == Some(false) + || matches!(response["status"].as_str(), Some("failed" | "cancelled")) + { + let result = batch_result( + &batch, + response["status"].as_str().unwrap_or("failed"), + false, + ); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + if response["status"] == "waiting_input" + && batch + .steps + .get(batch.next) + .and_then(|step| step["op"].as_str()) + .is_none_or(|op| !matches!(op, "input" | "cancel")) + { + let result = batch_result(&batch, "waiting_input", true); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + } + } +} + +fn batch_step_id(id: &str, step: usize) -> String { + let hash = id + .as_bytes() + .iter() + .fold(0xcbf29ce484222325u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + format!("batch-{hash:016x}-{step}") +} + +fn batch_result(batch: &BatchExecution, status: &str, ok: bool) -> Value { + json!({ + "ok":ok, + "status":status, + "request_id":batch.id, + "completed_steps":batch.next, + "total_steps":batch.steps.len(), + "next_step":(batch.next < batch.steps.len()).then_some(batch.next), + "results":batch.results, + "changes":batch.changes, + "state":batch.state + }) +} + +fn trim_batches(batches: &mut VecDeque) { + while batches.len() > 64 { + batches.pop_front(); + } } fn client<'a>( @@ -306,6 +575,176 @@ fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, Strin .ok_or_else(|| format!("Missing {key}")) } +fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { + let missing = |field: &str, example: &str| { + Err(format!( + "Missing {field} for {op}. Example request: {example}" + )) + }; + match op { + "batch" => { + let steps = request["steps"].as_array().ok_or_else(|| { + r#"Missing steps for batch. Example request: {"op":"batch","request_id":"draw-1","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#.to_string() + })?; + if steps.is_empty() || steps.len() > MAX_BATCH_STEPS { + return Err(format!( + "batch steps must contain 1 to {MAX_BATCH_STEPS} operations" + )); + } + for (index, step) in steps.iter().enumerate() { + let step = step + .as_object() + .ok_or_else(|| format!("batch step {index} must be an object"))?; + if step.contains_key("request_id") { + return Err(format!( + "batch step {index} must omit request_id; the batch assigns idempotency keys" + )); + } + let step = Value::Object(step.clone()); + let step_op = step["op"] + .as_str() + .ok_or_else(|| format!("batch step {index} is missing op"))?; + if !BATCH_STEP_OPS.contains(&step_op) { + return Err(format!("Unknown operation {step_op} in batch step {index}")); + } + validate_execute_request(&step, step_op) + .map_err(|error| format!("batch step {index}: {error}"))?; + } + Ok(()) + } + "open" if request["path"].as_str().is_none_or(str::is_empty) => { + missing("path", r#"{"op":"open","path":"/path/drawing.dxf"}"#) + } + "activate" if request["document_id"].as_u64().is_none() => { + missing("document_id", r#"{"op":"activate","document_id":2}"#) + } + "run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => { + missing("cmd", r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#) + } + "input" => match request["kind"].as_str() { + Some("text") => Ok(()), + Some("token") + if request["text"] + .as_str() + .is_some_and(|value| !value.is_empty()) => + { + Ok(()) + } + Some("token") => missing("text", r#"{"op":"input","kind":"token","text":"C"}"#), + Some("point") + if request["point"].as_array().is_some_and(|values| { + values.len() == 3 && values.iter().all(Value::is_number) + }) => + { + Ok(()) + } + Some("point") => missing( + "point", + r#"{"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}"#, + ), + Some("entity" | "structure") + if request["handle"] + .as_str() + .is_some_and(|value| !value.is_empty()) + && request["point"].as_array().is_some_and(|values| { + values.len() == 3 && values.iter().all(Value::is_number) + }) => + { + Ok(()) + } + Some("entity" | "structure") => missing( + "handle and point", + r#"{"op":"input","kind":"entity","handle":"2A","point":[0,0,0]}"#, + ), + Some("selection" | "enter") => Ok(()), + Some(kind) => Err(format!( + "Unknown input kind {kind}. Use text, token, point, entity, structure, selection or enter" + )), + None => missing("kind", r#"{"op":"input","kind":"point","point":[0,0,0]}"#), + }, + "property" + if request["field"].as_str().is_none_or(str::is_empty) + || request.get("value").is_none() => + { + missing( + "field or value", + r#"{"op":"property","field":"color","value":1}"#, + ) + } + "action" => match request["name"].as_str() { + Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()), + Some(name) => Err(format!( + "Unknown action {name}. Call ocs_read commands to list actions" + )), + None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#), + }, + _ => Ok(()), + } +} + +fn compact_state(state: &Value) -> Value { + let mut compact = Map::new(); + for key in [ + "session_id", + "document_id", + "revision", + "geometry_revision", + "camera_revision", + "selection", + "command", + "modal", + "event_cursor", + "operation", + ] { + if let Some(value) = state.get(key) { + compact.insert(key.into(), value.clone()); + } + } + Value::Object(compact) +} + +fn response_handles(response: &Value) -> Vec { + let mut handles = Vec::new(); + if let Some(changes) = response["changes"].as_array() { + for handle in changes + .iter() + .filter_map(|change| change["handle"].as_str()) + { + if !handles.iter().any(|value| value == handle) { + handles.push(handle.to_owned()); + } + } + } + handles +} + +fn shape_execute_response( + mut response: Value, + detail: &str, + gui: &mut GuiClient, +) -> Result { + if detail == "changed_entities" { + let handles = response_handles(&response); + if !handles.is_empty() { + let entities = gui.request( + json!({"op":"query","handles":handles,"detail":"geometry","limit":MAX_BATCH_STEPS * 100}), + 30.0, + )?; + if let Some(object) = response.as_object_mut() { + object.insert("changed_entities".into(), entities["entities"].clone()); + } + } + } + if detail != "full" { + if let Some(state) = response.get("state").cloned() { + if let Some(object) = response.as_object_mut() { + object.insert("state".into(), compact_state(&state)); + } + } + } + Ok(response) +} + fn call_tool( name: &str, arguments: &Value, @@ -344,14 +783,24 @@ fn call_tool( if request_id.len() > 128 { return Err("request_id must not exceed 128 bytes".into()); } + validate_execute_request(&request, op)?; let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); - client(clients, session_id)?.request(request, wait) + let detail = arguments["response_detail"].as_str().unwrap_or("compact"); + let gui = client(clients, session_id)?; + let response = if op == "batch" { + gui.execute_batch(request, wait)? + } else { + gui.request(request, wait)? + }; + shape_execute_response(response, detail, gui) } "ocs_capture" => { let session_id = required_string(arguments, "session_id")?; let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?)); + let scope = arguments["scope"].as_str().unwrap_or("viewport"); + let max_dimension = arguments["max_dimension"].as_u64().unwrap_or(1600); let result = client(clients, session_id)? - .request(json!({"op":"capture","path":path.to_string_lossy()}), 30.0)?; + .request(json!({"op":"capture","path":path.to_string_lossy(),"scope":scope,"max_dimension":max_dimension}), 30.0)?; if result["ok"].as_bool() != Some(true) || result["status"].as_str() != Some("completed") { @@ -365,30 +814,151 @@ fn call_tool( } } +fn batch_step_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "op":{"type":"string","enum":BATCH_STEP_OPS}, + "document_id":{"type":"integer","minimum":0}, + "cmd":{"type":"string","minLength":1}, + "path":{"type":"string","minLength":1}, + "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"]}, + "text":{"type":"string"}, + "point":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}, + "space":{"type":"string","enum":["wcs","ucs","relative"]}, + "handle":{"type":"string"}, + "handles":{"type":"array","items":{"type":"string"}}, + "type":{"type":"string"}, + "layer":{"type":"string"}, + "clear":{"type":"boolean"}, + "field":{"type":"string"}, + "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "name":{"type":"string","enum":crate::app::automation_action_names()} + }, + "required":["op"], + "additionalProperties":false + }) +} + +fn execute_request_schema() -> Value { + let handle = json!({ + "type":"string", + "pattern":"^(0[xX])?[0-9A-Fa-f]+$", + "description":"Entity handle returned by ocs_read entities or state selection." + }); + let point = json!({ + "type":"array","items":{"type":"number"},"minItems":3,"maxItems":3, + "description":"Finite [x,y,z] coordinates." + }); + json!({ + "type":"object", + "properties":{ + "op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."}, + "request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."}, + "document_id":{"type":"integer","minimum":0,"description":"Target document from current state."}, + "revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."}, + "geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."}, + "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 drawing path for open or save."}, + "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."}, + "text":{"type":"string","description":"Free text or one option/value token."}, + "point":point, + "space":{"type":"string","enum":["wcs","ucs","relative"],"default":"wcs","description":"Coordinate space for point input."}, + "handle":handle.clone(), + "handles":{"type":"array","items":handle,"description":"Entity handles to select."}, + "type":{"type":"string","description":"Entity type filter for select."}, + "layer":{"type":"string","description":"Layer filter for select."}, + "clear":{"type":"boolean","description":"Clear the current selection before applying select filters."}, + "field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."}, + "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."}, + "steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()} + }, + "required":["op","request_id"], + "additionalProperties":false, + "oneOf":[ + {"properties":{"op":{"const":"new"}}}, + {"properties":{"op":{"const":"open"}},"required":["path"]}, + {"properties":{"op":{"const":"activate"}},"required":["document_id"]}, + {"properties":{"op":{"const":"run"}},"required":["cmd"]}, + {"properties":{"op":{"const":"start"}},"required":["cmd"]}, + {"properties":{"op":{"const":"input"}},"required":["kind"],"oneOf":[ + {"properties":{"kind":{"const":"text"}}}, + {"properties":{"kind":{"const":"token"}},"required":["text"]}, + {"properties":{"kind":{"const":"point"}},"required":["point"]}, + {"properties":{"kind":{"const":"entity"}},"required":["handle","point"]}, + {"properties":{"kind":{"const":"structure"}},"required":["handle","point"]}, + {"properties":{"kind":{"const":"selection"}}}, + {"properties":{"kind":{"const":"enter"}}} + ]}, + {"properties":{"op":{"const":"cancel"}}}, + {"properties":{"op":{"const":"undo"}}}, + {"properties":{"op":{"const":"redo"}}}, + {"properties":{"op":{"const":"select"}}}, + {"properties":{"op":{"const":"property"}},"required":["field","value"]}, + {"properties":{"op":{"const":"action"}},"required":["name"]}, + {"properties":{"op":{"const":"save"}}}, + {"properties":{"op":{"const":"stop"}}}, + {"properties":{"op":{"const":"batch"}},"required":["steps"]} + ] + }) +} + +fn read_output_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "ok":{"type":"boolean"},"status":{"type":"string"},"code":{"type":"string"}, + "error":{"anyOf":[{"type":"string"},{"type":"null"}]},"document_id":{"type":"integer"}, + "revision":{"type":"integer"},"geometry_revision":{"type":"integer"}, + "camera_revision":{"type":"integer"} + }, + "required":["ok"],"additionalProperties":true + }) +} + +fn execute_output_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "ok":{"type":"boolean"}, + "status":{"type":"string","enum":["accepted","running","waiting_input","completed","cancelled","failed"]}, + "request_id":{"type":"string"},"code":{"type":"string"},"error":{"anyOf":[{"type":"string"},{"type":"null"}]}, + "result":{"type":"object"},"changes":{"anyOf":[{"type":"array"},{"type":"null"}]},"state":{"type":"object"} + }, + "required":["ok"],"additionalProperties":true + }) +} + fn tool_definitions() -> Value { json!([ { "name":"ocs_sessions", "description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.", "inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true,"description":"Launch OpenCADStudio when no live session exists."}},"additionalProperties":false}, + "outputSchema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"ok":{"const":true},"session_id":{"type":"string"},"document_id":{"type":"integer"},"revision":{"type":"integer"},"selection":{"type":"array","items":{"type":"string"}},"documents":{"type":"array"}},"required":["ok","session_id","document_id","revision","selection","documents"],"additionalProperties":true}}},"required":["result"],"additionalProperties":false}, "annotations":{"title":"List OCS sessions","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_read", - "description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters such as document_id, handles, after, or request_id."}},"required":["session_id"],"additionalProperties":false}, + "description":"Read state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session 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 for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"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."},"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":["session_id"],"additionalProperties":false}, + "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_execute", - "description":"Execute one semantic OCS action. Use state document_id and revision. request_id is the idempotency key. Accepted, running and waiting_input are not completion.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":{"type":"object","properties":{"op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."},"request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."},"document_id":{"type":"integer","minimum":0,"description":"Target document from current state."},"revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."},"geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."},"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},"selection":{"type":"array","items":{"type":"string"},"description":"Expected selected handles from current state."}},"required":["op","request_id"]},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false}, + "description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, or start plus input for guided steps. accepted, running and waiting_input are not completion.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["session_id","request"],"additionalProperties":false}, + "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_capture", - "description":"Capture the actual current OpenCADStudio window as PNG for visual verification.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."}},"required":["session_id"],"additionalProperties":false}, + "description":"Capture the actual current OCS drawing viewport or window as a bounded PNG for visual verification.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["session_id"],"additionalProperties":false}, "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} } ]) @@ -414,7 +984,7 @@ fn error_result(message: impl ToString) -> Value { let message = message.to_string(); json!({ "content":[{"type":"text","text":message.clone()}], - "structuredContent":{"ok":false,"error":message}, + "structuredContent":{"ok":false,"status":"failed","code":"invalid_arguments","error":message,"retryable":false}, "isError":true }) } @@ -432,6 +1002,56 @@ fn modern_request(params: &Value) -> bool { == Some(MODERN_PROTOCOL_VERSION) } +fn supports_tasks(params: &Value) -> bool { + params["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"] + ["io.modelcontextprotocol/tasks"] + .is_object() +} + +fn task_value(task: &McpTask, status: &str) -> Value { + let mut value = json!({ + "resultType":"complete", + "taskId":task.id, + "status":status, + "createdAt":task.created_at, + "lastUpdatedAt":task.last_updated_at, + "ttlMs":3_600_000, + "pollIntervalMs":250 + }); + if let Some(result) = &task.result { + value["result"] = result.clone(); + } + if let Some(error) = &task.error { + value["error"] = error.clone(); + } + value +} + +fn poll_task(task: &mut McpTask, clients: &mut HashMap) -> Value { + if task.result.is_some() { + return task_value(task, "completed"); + } + if task.error.is_some() { + return task_value(task, "failed"); + } + task.last_updated_at = iso8601_now(); + let mut arguments = task.arguments.clone(); + arguments["wait_seconds"] = Value::from(0); + match call_tool(&task.name, &arguments, clients) { + Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => { + task_value(task, "working") + } + Ok(value) => { + task.result = Some(tool_result(value)); + task_value(task, "completed") + } + Err(error) => { + task.error = Some(json!({"code":-32000,"message":error})); + task_value(task, "failed") + } + } +} + fn protocol_result(mut result: Value, modern: bool, cacheable: bool) -> Value { if modern { let object = result @@ -468,7 +1088,11 @@ fn unsupported_protocol(id: Value, requested: &str) -> Value { }) } -fn handle_message(message: Value, clients: &mut HashMap) -> Option { +fn handle_message( + message: Value, + clients: &mut HashMap, + tasks: &mut TaskStore, +) -> Option { let id = message.get("id").cloned(); let method = message.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -509,7 +1133,7 @@ fn handle_message(message: Value, clients: &mut HashMap) -> O protocol_result( json!({ "supportedVersions":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION], - "capabilities":{"tools":{}}, + "capabilities":{"tools":{},"extensions":{"io.modelcontextprotocol/tasks":{}}}, "instructions":INSTRUCTIONS }), true, @@ -529,11 +1153,72 @@ fn handle_message(message: Value, clients: &mut HashMap) -> O .get("arguments") .cloned() .unwrap_or_else(|| json!({})); - let result = call_tool(name, &arguments, clients) - .map(tool_result) - .unwrap_or_else(error_result); + let called = call_tool(name, &arguments, clients); + if modern && supports_tasks(¶ms) { + if let Ok(value) = &called { + if matches!(value["status"].as_str(), Some("accepted" | "running")) { + let task_id = + random_id().unwrap_or_else(|_| format!("task-{}", iso8601_now())); + let now = iso8601_now(); + tasks.insert(McpTask { + id: task_id.clone(), + name: name.to_owned(), + arguments, + created_at: now.clone(), + last_updated_at: now, + result: None, + error: None, + }); + return Some(response( + id, + protocol_result( + json!({ + "resultType":"task", + "taskId":task_id, + "status":"working", + "statusMessage":"OCS operation is running.", + "createdAt":tasks.tasks.back().unwrap().created_at, + "lastUpdatedAt":tasks.tasks.back().unwrap().last_updated_at, + "ttlMs":3_600_000, + "pollIntervalMs":250 + }), + true, + false, + ), + )); + } + } + } + let result = called.map(tool_result).unwrap_or_else(error_result); response(id, protocol_result(result, modern, false)) } + "tasks/get" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + let Some(task) = tasks.get_mut(task_id) else { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + }; + response(id, protocol_result(poll_task(task, clients), true, false)) + } + "tasks/update" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + if tasks.get_mut(task_id).is_none() { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + } + response(id, protocol_result(json!({}), true, false)) + } + "tasks/cancel" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + if tasks.get_mut(task_id).is_none() { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + } + response(id, protocol_result(json!({}), true, false)) + } _ => rpc_error(id, -32601, format!("Method not found: {method}")), }) } @@ -544,10 +1229,11 @@ pub fn run() { let stdout = io::stdout(); let mut output = stdout.lock(); let mut clients = HashMap::new(); + let mut tasks = TaskStore::default(); for line in stdin.lock().lines() { let response = match line { Ok(line) if !line.trim().is_empty() => match serde_json::from_str::(&line) { - Ok(message) => handle_message(message, &mut clients), + Ok(message) => handle_message(message, &mut clients, &mut tasks), Err(error) => Some(rpc_error(Value::Null, -32700, error)), }, Ok(_) => None, @@ -589,6 +1275,32 @@ mod tests { tools[2]["inputSchema"]["properties"]["request"]["required"], json!(["op", "request_id"]) ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["oneOf"] + .as_array() + .unwrap() + .len(), + EXECUTE_OPS.len() + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["steps"]["maxItems"], + MAX_BATCH_STEPS + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["response_detail"]["default"], + "compact" + ); + assert_eq!( + tools[3]["inputSchema"]["properties"]["scope"]["default"], + "viewport" + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], + "LINE 0,0 10,10" + ); + assert!(tools[0].get("outputSchema").is_some()); + assert!(tools[1].get("outputSchema").is_some()); + assert!(tools[2].get("outputSchema").is_some()); } #[test] @@ -597,17 +1309,21 @@ mod tests { let initialized = handle_message( json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25"); - assert!(initialized["result"]["instructions"] - .as_str() - .unwrap() - .contains("geometry kernel")); + assert!( + initialized["result"]["instructions"] + .as_str() + .unwrap() + .contains("geometry kernel") + ); let listed = handle_message( json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4); @@ -621,12 +1337,17 @@ mod tests { let discovered = handle_message( json!({"jsonrpc":"2.0","id":"discover","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(discovered["result"]["resultType"], "complete"); assert_eq!(discovered["result"]["ttlMs"], CACHE_TTL_MS); assert_eq!(discovered["result"]["cacheScope"], "public"); assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28"); + assert!( + discovered["result"]["capabilities"]["extensions"]["io.modelcontextprotocol/tasks"] + .is_object() + ); assert_eq!( discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "OpenCADStudio" @@ -635,6 +1356,7 @@ mod tests { let listed = handle_message( json!({"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(listed["result"]["resultType"], "complete"); @@ -648,6 +1370,7 @@ mod tests { let called = handle_message( json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -659,6 +1382,7 @@ mod tests { let rejected = handle_message( json!({"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(rejected["error"]["code"], -32022); @@ -675,13 +1399,86 @@ mod tests { let called = handle_message( json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"undo"}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); - assert!(called["result"]["structuredContent"]["error"] + assert!( + called["result"]["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("request_id") + ); + } + + #[test] + fn execute_errors_explain_missing_operation_fields() { + let mut clients = HashMap::new(); + let called = handle_message( + json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), + &mut clients, + &mut TaskStore::default(), + ) + .unwrap(); + assert_eq!(called["result"]["isError"], true); + assert_eq!( + called["result"]["structuredContent"]["code"], + "invalid_arguments" + ); + let error = called["result"]["structuredContent"]["error"] .as_str() - .unwrap() - .contains("request_id")); + .unwrap(); + assert!(error.contains("Missing cmd"), "{error}"); + assert!(error.contains("LINE 0,0 10,10"), "{error}"); + } + + #[test] + fn validates_batch_steps_and_compacts_state() { + let batch = json!({ + "op":"batch", + "request_id":"draw", + "steps":[{"op":"run","cmd":"LINE 0,0 10,0"},{"op":"run","cmd":"CIRCLE 5,5 2"}] + }); + assert!(validate_execute_request(&batch, "batch").is_ok()); + let invalid = json!({ + "op":"batch", + "request_id":"draw", + "steps":[{"op":"run","request_id":"nested","cmd":"LINE 0,0 10,0"}] + }); + assert!( + validate_execute_request(&invalid, "batch") + .unwrap_err() + .contains("omit request_id") + ); + + let compact = compact_state(&json!({ + "session_id":"s","document_id":3,"revision":4,"geometry_revision":5, + "camera_revision":6,"selection":[],"command":null,"documents":[1,2,3], + "camera":{"distance":100.0},"event_cursor":7 + })); + assert_eq!(compact["revision"], 4); + assert!(compact.get("camera").is_none()); + assert!(compact.get("documents").is_none()); + } + + #[test] + fn task_metadata_uses_the_modern_shape() { + let now = iso8601_now(); + assert_eq!(now.len(), 20); + assert!(now.ends_with('Z')); + let task = McpTask { + id: "task".into(), + name: "ocs_execute".into(), + arguments: json!({}), + created_at: now.clone(), + last_updated_at: now, + result: None, + error: None, + }; + let value = task_value(&task, "working"); + assert_eq!(value["resultType"], "complete"); + assert_eq!(value["status"], "working"); + assert_eq!(value["pollIntervalMs"], 250); } #[test] diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index e4dd5d475..d6eb177a3 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -286,6 +286,7 @@ impl CadCommand for AlignedDimensionCommand { let p1 = p1.as_vec3(); let p2 = p2.as_vec3(); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -348,6 +349,7 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { let half_height = 0.16; let to_point = |point: DVec3| point.as_vec3().to_array(); WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 811dc0602..ae736581b 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -1132,6 +1132,7 @@ fn nan() -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index 80032620a..6669a04f9 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -63,6 +63,7 @@ impl DataLinkPlaceCommand { ); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 5077dae15..de60b43b4 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -272,6 +272,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel { let separator = [f32::NAN; 3]; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 834222a34..a5188dacb 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -709,6 +709,7 @@ fn preview_for_dimension(dimension: &Dimension) -> WireModel { _ => {} } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dimjogline.rs b/src/modules/annotate/dimjogline.rs index 168b631da..02fbd90b3 100644 --- a/src/modules/annotate/dimjogline.rs +++ b/src/modules/annotate/dimjogline.rs @@ -86,6 +86,7 @@ impl CadCommand for DimJogLineCommand { } let d = 0.3_f32; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dimtedit.rs b/src/modules/annotate/dimtedit.rs index 6c5a60b98..97c28168e 100644 --- a/src/modules/annotate/dimtedit.rs +++ b/src/modules/annotate/dimtedit.rs @@ -103,6 +103,7 @@ impl CadCommand for DimTeditCommand { } let d = 0.2_f32; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 8c3f219cf..711935b49 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -442,6 +442,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index c8dfece71..0c1552b8f 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -300,6 +300,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 68bb4d133..78a3e6772 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -467,6 +467,7 @@ fn ocs_point(point: [f64; 2], elevation: f64, normal: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index c10f4e04a..d897d49d4 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -764,6 +764,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index 07a7feab9..f01134038 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -490,6 +490,7 @@ impl CadCommand for MLeaderCollectCommand { fn preview_wire(pts: &[Vec3]) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index ea5b94c8c..8b917e33f 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -270,6 +270,7 @@ fn is_x_type(feature: DVec3, leader: DVec3) -> bool { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index f68c26f6b..b61db83fb 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -277,6 +277,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 2ffb34878..664532093 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -212,6 +212,7 @@ impl TableCommand { ); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index 192835bc8..e20995b65 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -81,6 +81,7 @@ impl CadCommand for ToleranceCommand { })); } Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index bba45cb11..a6ee36198 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -199,6 +199,7 @@ impl CadCommand for AttdefCommand { pt + self.plane.y * d, ]; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index c476b2358..79d24ff5f 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -180,6 +180,7 @@ impl MlineCommand { let (fill_tris, fill_tris_low) = crate::scene::convert::tessellate::points_to_ds(fill_tris); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index f2001719b..8f169fb9c 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -123,6 +123,7 @@ impl CadCommand for ImageCommand { let [p0, p1, p2, p3] = corners; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 63d374d00..0b349b42e 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -85,6 +85,7 @@ impl CadCommand for RayCommand { let dir = (pt - base).normalize_or_zero(); let far = base + dir * DISPLAY_EXTENT; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -189,6 +190,7 @@ impl CadCommand for XLineCommand { let far_pos = base + dir * DISPLAY_EXTENT; let far_neg = base - dir * DISPLAY_EXTENT; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 2e41e149a..645dc701c 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -365,6 +365,7 @@ impl CadCommand for AreaCommand { points.push(to_render(point)); points.push(to_render(self.points[0])); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs index ff3729f19..3ee40fb6f 100644 --- a/src/modules/draw/inquiry/dist.rs +++ b/src/modules/draw/inquiry/dist.rs @@ -86,6 +86,7 @@ impl CadCommand for DistCommand { let p1 = self.first?; // The preview wire is purely visual, so f32 vertices are fine here. Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index 25e47ac68..fd5844d51 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -70,6 +70,7 @@ impl MeasureGeomCommand { /// Build a cyan preview wire connecting the picked points and the cursor. fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index d1341013a..e5f2a5b59 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -3746,6 +3746,7 @@ fn collect_runs(pts: &[[f64; 2]], take: &dyn Fn([f64; 2]) -> bool, out: &mut Vec /// Build a preview wire from a NaN-break point list. fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index c430ffc30..18ee79a87 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -10,24 +10,24 @@ use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, pub struct ModelModule; -const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/box3d.svg"); -const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/cylinder3d.svg"); -const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/cone3d.svg"); -const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/sphere3d.svg"); -const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/pyramid3d.svg"); -const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/wedge3d.svg"); -const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/torus3d.svg"); -const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/polysolid.svg"); -const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/extrude.svg"); -const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/revolve.svg"); -const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/loft.svg"); -const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/sweep.svg"); -const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/presspull.svg"); -const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/union.svg"); -const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/subtract.svg"); -const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/intersect.svg"); -const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/fillet.svg"); -const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/chamfer.svg"); +const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/model/box.svg"); +const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/model/cylinder.svg"); +const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/cone.svg"); +const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/sphere.svg"); +const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/model/pyramid.svg"); +const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/wedge.svg"); +const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/model/torus.svg"); +const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/model/polysolid.svg"); +const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/extrude.svg"); +const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/revolve.svg"); +const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/model/loft.svg"); +const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/model/sweep.svg"); +const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/model/presspull.svg"); +const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/model/union.svg"); +const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/model/subtract.svg"); +const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/model/intersect.svg"); +const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/model/fillet.svg"); +const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/model/chamfer.svg"); /// Helper to declare a ribbon tool that fires a named command. fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 3beecc06f..bc586caca 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -3621,6 +3621,7 @@ inventory::submit!(crate::command::CommandRegistration { fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index 03341c085..896985e39 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -54,6 +54,7 @@ impl CadCommand for PlotWindowCommand { let p1 = self.p1?.as_vec3(); // Draw the selection rectangle. Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index a22f030e5..35fefe986 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -136,6 +136,7 @@ impl CadCommand for ZoomWindowCommand { let max = p1.max(pt); // Draw a rectangle preview Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 5e04a9613..aa8acca81 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1591,6 +1591,25 @@ impl Batches { [b.min_x, b.min_y, b.max_x, b.max_y] }; let contrast_bg = b.contrast_bg.unwrap_or(bg_color); + // Record what this resolution consumed, so switching layout can + // redo it for another background instead of re-running the + // tessellation that produced identical geometry. Skipped when + // the resolution is a no-op — a preserved colour with no canvas + // fill looks the same under every background. + let bg_adapt: crate::scene::model::wire_model::BgAdapt = + (b.canvas_color || !b.preserve_color).then(|| { + Box::new(crate::scene::model::wire_model::BgAdaptInputs { + raw_color: b.color, + text_raw_colors: if b.preserve_color { + Vec::new() + } else { + b.text_verts.iter().map(|v| v.color).collect() + }, + contrast_bg: b.contrast_bg, + canvas_color: b.canvas_color, + preserve_color: b.preserve_color, + }) + }); let color = if b.canvas_color { bg_color } else if b.preserve_color { @@ -1615,6 +1634,7 @@ impl Batches { ); } WireModel { + bg_adapt, point_marker: b.point_marker, taper_widths: Vec::new(), pattern_stations: b.pattern_stations, @@ -2728,3 +2748,119 @@ fn is_unreasonable_extent(e: &EntityType) -> bool { _ => false, } } + +#[cfg(test)] +mod bg_resolution_tests { + use super::*; + use crate::scene::pipeline::text_gpu::TextVertex; + use crate::scene::view::render::{adapt_to_bg, resolve_colors_for_bg}; + + const DARK: [f32; 4] = [0.1, 0.1, 0.1, 1.0]; + const LIGHT: [f32; 4] = [0.95, 0.95, 0.95, 1.0]; + /// Near-white, not pure white: the case that makes `adapt_to_bg` + /// non-re-appliable, so it must survive a round trip through the recorded + /// raw colour. + const NEAR_WHITE: [f32; 4] = [0.97, 0.99, 0.96, 1.0]; + + fn entry( + color: [f32; 4], + contrast_bg: Option<[f32; 4]>, + preserve_color: bool, + canvas_color: bool, + ) -> BatchEntry { + let mut e = BatchEntry::new( + color, + contrast_bg, + preserve_color, + canvas_color, + 0.0, + [0.0; 8], + 1.0, + 0.0, + 0.0, + None, + 1, + false, + false, + false, + false, + true, + false, + ); + e.points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + e.text_verts = vec![TextVertex { + pos: [0.0; 3], + pos_low: [0.0; 3], + uv: [0.0; 2], + color, + draw_depth: 0.0, + }]; + e + } + + /// Every combination `finalize` distinguishes, in one set. + fn batches() -> Batches { + Batches { + by_style: HashMap::default(), + closed: vec![ + entry(NEAR_WHITE, None, false, false), + entry([0.0, 0.0, 0.0, 1.0], None, false, false), + entry(NEAR_WHITE, None, true, false), + entry(NEAR_WHITE, Some(LIGHT), false, false), + entry(NEAR_WHITE, None, false, true), + ], + extra_wires: Vec::new(), + nested_prototypes: HashMap::default(), + } + } + + fn colors(wires: &[WireModel]) -> Vec<([f32; 4], Vec<[f32; 4]>)> { + wires + .iter() + .map(|w| (w.color, w.text_verts.iter().map(|v| v.color).collect())) + .collect() + } + + /// The contract the whole background-key removal rests on: resolving a set + /// built under one background for another must land exactly where building + /// it under that background would have. + #[test] + fn resolving_for_a_background_matches_building_under_it() { + for (from, to) in [(DARK, LIGHT), (LIGHT, DARK), (DARK, DARK), (LIGHT, LIGHT)] { + let mut resolved = batches().finalize("b", false, from); + resolve_colors_for_bg(&mut resolved, to); + let direct = batches().finalize("b", false, to); + assert_eq!( + colors(&resolved), + colors(&direct), + "built under {from:?}, resolved for {to:?}", + ); + } + } + + /// Safe to apply twice, and over a set mixing already-resolved wires with + /// freshly built ones — which is exactly what a memo hit plus a miss is. + #[test] + fn resolving_twice_changes_nothing() { + let mut once = batches().finalize("b", false, DARK); + resolve_colors_for_bg(&mut once, LIGHT); + let mut twice = once.clone(); + resolve_colors_for_bg(&mut twice, LIGHT); + assert_eq!(colors(&once), colors(&twice)); + } + + /// Why the raw colour is stored instead of re-adapting the resolved one. + /// If this ever starts passing, `raw_color` could be dropped — and if it + /// is removed while this still fails, near-white geometry loses its tint + /// on the first layout switch. + #[test] + fn adapt_to_bg_cannot_be_reapplied() { + let once = adapt_to_bg(NEAR_WHITE, LIGHT); + assert_eq!(once, [0.0, 0.0, 0.0, 1.0], "near-white snaps to pure black"); + assert_ne!( + adapt_to_bg(once, DARK), + adapt_to_bg(NEAR_WHITE, DARK), + "re-adapting the resolved colour must not equal adapting the raw one", + ); + } +} diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index b877827dd..a84d3d60d 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -923,6 +923,7 @@ fn tessellate_entity_inner( Vec::new() }; return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1167,6 +1168,7 @@ fn tessellate_entity_inner( (ins.insert_point.z) as f32, ); let marker = WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1510,6 +1512,7 @@ fn lod_stub_wire( // LOD boundary. #19. let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1605,6 +1608,7 @@ fn lod_stub_wire_3d( } let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 6bf477734..5d0044060 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -220,6 +220,7 @@ fn point_cloud_wires( }; let (points, points_low) = points_to_ds(body_points); let mut wires = vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -474,6 +475,7 @@ pub fn tessellate( } }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -637,6 +639,7 @@ pub fn tessellate( None }; elem_wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1026,6 +1029,7 @@ pub fn tessellate( } } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1082,6 +1086,7 @@ pub fn tessellate( } } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1135,6 +1140,7 @@ pub fn tessellate( low.push(ll); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1169,6 +1175,7 @@ pub fn tessellate( }); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1233,6 +1240,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1279,6 +1287,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1322,6 +1331,7 @@ pub fn tessellate( // are empty → the early-return path above). if !sdf_verts.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1358,6 +1368,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1428,6 +1439,7 @@ pub fn tessellate( .map(|[kx, ky, kz]| [kx, ky, kz]) .collect(); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1556,6 +1568,7 @@ pub fn tessellate( let point_marker = crate::entities::point::relative_marker_spec(entity, document); out.push(WireModel { + bg_adapt: None, point_marker, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1601,6 +1614,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1637,6 +1651,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1695,6 +1710,7 @@ pub fn tessellate( &station_pieces, ); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: station_data, @@ -1741,6 +1757,7 @@ pub fn tessellate( // treatment as the Contour arm, restarting the dash per segment. let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1791,6 +1808,7 @@ pub fn tessellate( let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); let world_width = widths.iter().copied().fold(0.0f32, f32::max); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: widths, pattern_stations: Vec::new(), @@ -1899,6 +1917,7 @@ pub fn tessellate( _ => (Vec::new(), Vec::new()), }; vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9642523f1..d817ff041 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1495,6 +1495,9 @@ pub enum ViewportRefreshScope { All, } +/// Entity membership in document order, keyed by geometry epoch. +type BlockMembers = (u64, HashMap>); + pub struct Scene { pub camera: Rc>, /// View saved immediately before the latest navigation operation. ZOOM @@ -1833,6 +1836,10 @@ pub struct Scene { /// Reverse map: entity_handle → block_record_handle, built from entity_handles lists. /// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block. entity_block_map_cache: RefCell)>>, + /// Candidate handles per block, in document order and keyed by geometry epoch. + block_members_cache: RefCell>, + /// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index. + unindexable_cache: RefCell)>>, /// Distinct entity type names present in a layout, keyed by /// (geometry_epoch, layout block). The status bar asks for this on every /// frame to populate the selection-filter menu, but the answer only @@ -1936,15 +1943,9 @@ pub struct Scene { /// Hash of the tessellation parameters `tess_memo` was built under. When /// the current call's parameters differ, the memo is stale and cleared. tess_memo_guard: std::cell::Cell, - /// Per-entity memo for the **resident** model wire set (`model_tile_wires_arc`, - /// the one the main GPU render holds). Kept separate from `tess_memo` because - /// the resident set is camera-INDEPENDENT (no view cull, no zoom LOD), so its - /// guard depends only on anno-scale / background — it survives pan/zoom, and a - /// single-entity edit re-tessellates just the changed entity instead of the - /// whole model. Sharing `tess_memo` would let the camera-dependent culled path - /// thrash it on every zoom. (#perf) + /// Camera-independent model tessellation, separate from the culled memo. resident_tess_memo: RefCell>>>, - /// Guard hash for `resident_tess_memo` (anno-scale / bg only). + /// Annotation, background, viewport and glyph-atlas signature. resident_tess_guard: std::cell::Cell, /// Per-mutation delta journal: which handles changed on each `geometry_epoch` /// bump. Bounded ring ([`GEOMETRY_JOURNAL_CAP`]); a derived cache replays the @@ -2077,6 +2078,8 @@ impl Scene { annotation_affects_wires: std::cell::Cell::new(None), model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), + block_members_cache: RefCell::new(None), + unindexable_cache: RefCell::new(None), layout_type_names_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), @@ -8885,6 +8888,12 @@ impl Scene { let sort_cache_ms = crate::perf::elapsed_ms(t_fn); let t_visible = perf.then(iced::time::Instant::now); + // Which path the selection took, and how many candidates it looked at. + // Left uninitialised on purpose: every branch below must set it, and + // the compiler enforces that. A default here is what made the first + // version of this diagnostic report a constant. + let visible_path: &str; + let mut visible_candidates: usize; // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate @@ -8893,6 +8902,7 @@ impl Scene { // Paper space and the first-frame "settle" path fall back to the // full doc scan — preserving prior behaviour. let visible: Vec<&EntityType> = if let Some(local_view) = view_aabb { + visible_path = "quadtree"; let view_wcs: [f64; 4] = [ local_view[0] as f64, local_view[1] as f64, @@ -8905,6 +8915,7 @@ impl Scene { }; let mut out: Vec<&EntityType> = Vec::with_capacity(candidates.len() + unbounded.len() + 16); + visible_candidates = candidates.len() + unbounded.len(); for h in candidates { if let Some(e) = self.document.get_entity(h) { if visibility_ok(e) { @@ -8922,18 +8933,56 @@ impl Scene { } } // Inserts/Viewports/Block/BlockEnd — handled by their own paths - // (block expansion, viewport rendering); always candidates. - for e in self.document.entities() { - if is_unindexable_entity(e) && visibility_ok(e) { - out.push(e); + // (block expansion, viewport rendering); always candidates. Taken + // from the per-epoch list rather than by walking the document, + // which preserves document order because the list is built that + // way. + { + let unindexable = self.unindexable_handles(); + visible_candidates += unindexable.1.len(); + for &h in &unindexable.1 { + if let Some(e) = self.document.get_entity(h) { + if visibility_ok(e) { + out.push(e); + } + } } } out - } else { + } else if block_handle.is_null() || self.entity_block_map().is_empty() { + // Either every entity qualifies, or the drawing declares no block + // contents anywhere and `belongs_to_visible_block` turns permissive + // — the legacy-DXF case. Both need the full walk, and both must + // keep document order, which is draw order for the wire pass. + visible_path = "full-scan"; + visible_candidates = self.document.entity_count(); self.document .entities() .filter(|e| visibility_ok(e)) .collect() + } else { + // Ask the per-epoch membership index for this block's candidates + // instead of testing every entity in the document. For a paper + // sheet holding 43 entities in a 1.17 M-entity drawing that is the + // difference between 313 ms and nothing measurable. + // + // The index is filled by walking `document.entities()`, so each + // block's list is already in document order and the wires come out + // in exactly the order the full scan produced. + visible_path = "block-index"; + let members = self.block_members(); + let (_, by_block) = &*members; + let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); + visible_candidates = claimed.len(); + let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); + for &handle in claimed { + if let Some(entity) = self.document.get_entity(handle) { + if visibility_ok(entity) { + out.push(entity); + } + } + } + out }; let visible_ms = crate::perf::elapsed_ms(t_visible); @@ -9011,12 +9060,14 @@ impl Scene { let mut materialize_ms = 0.0f64; let mut memo_hits = 0usize; let memo_misses; + // Two consecutive identical resident builds both missed every entry, + // which a stable guard cannot explain — the memo is being cleared + // between them. Report the guard and the inputs that can move it. + let mut guard_dbg = 0u64; + let mut guard_was_stale = false; let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { - // Guard hash of everything tessellate_entity output depends on - // besides the entity itself. A mismatch (zoom/tol, view, anno, - // offset, bg, entered viewport) means the memo is stale. For the - // resident path wpp/view_aabb are None, so this collapses to anno/bg. + // Invalidate when any baked display input changes. let guard = { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; @@ -9029,8 +9080,10 @@ impl Scene { mix(anno.to_bits() as u64); mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); - for c in bg { - mix(c.to_bits() as u64); + // Direct entities and inherited/faded block colours still bake + // the background before Batches::finalize records its inputs. + for channel in bg { + mix(channel.to_bits() as u64); } mix(avp.map(|h| h.value()).unwrap_or(0)); // SDF glyph quads bake the atlas UV of each tile, so a growth or @@ -9045,7 +9098,9 @@ impl Scene { } else { (&self.tess_memo, &self.tess_memo_guard) }; - if guard_cell.get() != guard { + guard_dbg = guard; + guard_was_stale = guard_cell.get() != guard; + if guard_was_stale { memo_cell.borrow_mut().clear(); guard_cell.set(guard); } @@ -9133,6 +9188,14 @@ impl Scene { }; let build_ms = crate::perf::elapsed_ms(t_build); + // Resolve display colours for the live background. Memo hits were + // built under whatever background was current then, misses under this + // one; the pass recomputes both from their recorded raw colour, so a + // mixed set lands in the same place either way. + let t_colors = perf.then(iced::time::Instant::now); + crate::scene::view::render::resolve_colors_for_bg(&mut wires, bg); + let colors_ms = crate::perf::elapsed_ms(t_colors); + // Apply draw order via the cached index (O(1) block lookup). let t_sort = perf.then(iced::time::Instant::now); let mut sorted = false; @@ -9157,13 +9220,15 @@ impl Scene { } if perf { crate::perf_record!( - "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} \ + "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} colors={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ -entities={} memo_hit={} memo_miss={} wires={} memo={}", +entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \ +guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, blk_ms, + colors_ms, build_ms, classify_ms, hits_ms, @@ -9176,12 +9241,71 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", memo_misses, wires.len(), if memo_active { "on" } else { "off" }, + visible_path, + visible_candidates, + guard_dbg, + guard_was_stale, + avp.map(|h| h.value()).unwrap_or(0), + anno, + annotation_scale_handle.map(|h| h.value()).unwrap_or(0), + all_visible, + crate::scene::text::sdf_atlas::generation(), ); } wires } - /// Decide whether an entity should be drawn as direct content of `block_handle`. + /// Unindexable entity handles in document order, cached per geometry epoch. + fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec)> { + { + let cache = self.unindexable_cache.borrow(); + if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { + drop(cache); + return std::cell::Ref::map(self.unindexable_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } + } + let handles: Vec = self + .document + .entities() + .filter(|entity| is_unindexable_entity(entity)) + .map(|entity| entity.common().handle) + .collect(); + *self.unindexable_cache.borrow_mut() = Some((self.geometry_epoch, handles)); + std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap()) + } + + /// Resolve owner handles and block-record membership once per geometry epoch. + fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { + { + let cache = self.block_members_cache.borrow(); + if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { + drop(cache); + return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } + } + let mut members: HashMap> = HashMap::default(); + { + let map = self.entity_block_map(); + for entity in self.document.entities() { + let common = entity.common(); + let owner = common.owner_handle; + if !owner.is_null() { + members.entry(owner).or_default().push(common.handle); + } else if let Some(&block) = map.get(&common.handle) { + members.entry(block).or_default().push(common.handle); + } + } + } + *self.block_members_cache.borrow_mut() = + Some((self.geometry_epoch, members)); + std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap()) + } + + /// Whether the entity is direct content of this block. fn belongs_to_visible_block( &self, entity_handle: Handle, @@ -9877,21 +10001,24 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", std::cell::Ref::map(self.entity_index_cache.borrow(), |c| &c.as_ref().unwrap().1) } - /// Full tessellation pipeline for one entity. - fn tessellate_one(&self, e: &EntityType) -> Vec { - let bg = if self.current_layout == "Model" { + /// Resolve the active layout and annotation context for tessellation. + fn tessellation_context( + &self, + ) -> ([f32; 4], f32, Option, Arc, bool) { + let is_model = self.current_layout == "Model"; + let bg = if is_model { self.bg_color } else { self.paper_bg_color }; - let anno = if self.current_layout == "Model" { + let anno = if is_model { self.annotation_scale } else if let Some(viewport) = self.active_viewport { self.viewport_annotation_multiplier(viewport) } else { 1.0 }; - let annotation_scale_handle = if self.current_layout == "Model" { + let annotation_scale_handle = if is_model { crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, @@ -9906,6 +10033,12 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", self.annotation_all_visible(), self.active_viewport, ); + (bg, anno, annotation_scale_handle, blk_cache, !is_model) + } + + fn tessellate_one(&self, e: &EntityType) -> Vec { + let (bg, anno, annotation_scale_handle, blk_cache, paper) = + self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. tessellate_entity( @@ -9919,7 +10052,7 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", Some(&blk_cache), None, None, - self.current_layout != "Model", + paper, ) } @@ -10043,114 +10176,43 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", if model_block.is_null() { return None; } - let mut min = glam::Vec3::splat(f32::INFINITY); - let mut max = glam::Vec3::splat(f32::NEG_INFINITY); - let mut any = false; - - // Prefer the already-computed wire AABB cache when available — avoids re-tessellating. - if self.current_layout == "Model" { - let cache = self.wire_cache.borrow(); - if let Some(((epoch, _cam_gen), _gen, ref arc)) = *cache { - if epoch == self.geometry_epoch { - for wire in arc.iter() { - let [ax, ay, bx, by] = wire.aabb; - let lo = glam::Vec3::new(ax, ay, 0.0); - let hi = glam::Vec3::new(bx, by, 0.0); - // Reject the whole AABB unless every component is finite: - // rays/xlines carry an unbounded AABB, and checking only - // x let a vertical ray's infinite y poison the extents. - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - // 3D solids render as meshes, not wires, so fold their - // complete 3D boxes in too — otherwise view fitting loses - // both solid-only drawings and their depth. - for (&handle, set) in &self.meshes { - if !self.mesh_entity_visible(handle) - || !self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - }) - { - continue; - } - let [ax, ay, bx, by] = set.world_aabb; - let [az, bz] = set.z_aabb; - let lo = glam::Vec3::new(ax, ay, az); - let hi = glam::Vec3::new(bx, by, bz); - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - return if any { Some((min, max)) } else { None }; - } - } - } - - // Fallback: tessellate (first call or paper-space context). - // wire.key_vertices live in offset-rel coords (world_offset - // already subtracted at tessellation time). Add it back so the - // result matches Path 1 above and the caller's expectation — - // callers (auto_fit_viewport) write the centroid directly to - // `Viewport.view_target`, which is a WCS field; storing - // offset-rel coords there silently double-subtracts world_offset - // inside `camera_for_viewport` and points the viewport at the - // wrong location on UTM-scale drawings. - for entity in self.document.entities() { - let c = entity.common(); - if c.owner_handle != model_block - || c.invisible - || self.entity_temporarily_hidden(c.handle) - { - continue; - } - for wire in self.tessellate_one(entity) { - for &[x, y, z] in &wire.key_vertices { - let v = glam::Vec3::new(x as f32, y as f32, z as f32); - // Check finiteness *after* the f32 cast: a ray/xline endpoint - // is a huge-but-finite f64 that overflows to inf in f32, which - // the f64 `is_finite` test would have let through. - if v.is_finite() { - min = min.min(v); - max = max.max(v); - any = true; - } - } - } - } - // Same mesh inclusion for the tessellate fallback path. - for (&handle, set) in &self.meshes { + // Reuse the full resident source in every layout. The kernel bounds + // its world-space key vertices; camera fitting never tessellates a + // second copy or reads the current paper sheet's wire cache. + let scale = crate::scene::annotative::scale_handle_by_name( + &self.document, + &self.document.header.current_annotation_scale, + ); + let wires = self.resident_wires_for( + model_block, + Some(self.annotation_scale), + scale, + None, + None, + ); + let wire_points = wires.iter().flat_map(|wire| wire.key_vertices.iter().copied()); + let mesh_points = self.meshes.iter().filter_map(|(&handle, set)| { + let entity = self.document.get_entity(handle)?; if !self.mesh_entity_visible(handle) - || !self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - }) + || !self.belongs_to_visible_block(handle, entity.common().owner_handle, model_block) { - continue; + return None; } let [ax, ay, bx, by] = set.world_aabb; let [az, bz] = set.z_aabb; - let lo = glam::Vec3::new(ax, ay, az); - let hi = glam::Vec3::new(bx, by, bz); - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - if any { - return Some((min, max)); + Some([ + [ax as f64, ay as f64, az as f64], + [bx as f64, by as f64, bz as f64], + ]) + }).flatten(); + let points = wire_points.chain(mesh_points).filter(|point| { + point.iter().all(|coordinate| (*coordinate as f32).is_finite()) + }); + if let Some(bounds) = cadkernel::brep::bounds::Aabb::around(points) { + return Some(( + glam::Vec3::from_array(bounds.min.map(|v| v as f32)), + glam::Vec3::from_array(bounds.max.map(|v| v as f32)), + )); } // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; @@ -10397,6 +10459,58 @@ mod journal_tests { // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. + #[test] + fn block_member_index_matches_the_full_scan() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + fn line(a: (f64, f64), b: (f64, f64)) -> EntityType { + EntityType::Line(Line::from_points( + Vector3::new(a.0, a.1, 0.0), + Vector3::new(b.0, b.1, 0.0), + )) + } + + // What the membership index reports for `block`, in order. + fn indexed(s: &Scene, block: Handle) -> Vec { + let members = s.block_members(); + let (_, by_block) = &*members; + by_block.get(&block).cloned().unwrap_or_default() + } + + // What testing every entity the old way reports, in document order. + fn scanned(s: &Scene, block: Handle) -> Vec { + s.document + .entities() + .filter(|entity| { + let common = entity.common(); + s.belongs_to_visible_block(common.handle, common.owner_handle, block) + }) + .map(|entity| entity.common().handle) + .collect() + } + + let mut s = Scene::new(); + let block = s.model_space_block_handle(); + let h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0))); + let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0))); + let _ = (h1, h2); + + // Order matters: it is the wire pass's draw order. + assert_eq!(indexed(&s, block), scanned(&s, block), "after adds"); + + let h3 = s.add_entity(line((100.0, 100.0), (140.0, 90.0))); + assert_eq!(indexed(&s, block), scanned(&s, block), "after another add"); + + // A modify must not reorder the block's contents either. + { + let mut moved = line((900.0, 900.0), (950.0, 970.0)); + moved.common_mut().handle = h3; + s.update_entity(moved); + } + assert_eq!(indexed(&s, block), scanned(&s, block), "after modify"); + } + #[test] fn entity_index_incremental_matches_full_rebuild() { use acadrust::entities::Line; @@ -11022,3 +11136,83 @@ mod selection_arc_tests { assert!(true); } } + +#[cfg(test)] +mod layout_cache_tests { + use super::*; + #[test] + fn memoized_plain_line_matches_fresh_background() { + let mut s = Scene::new(); + s.bg_color = [0.1, 0.1, 0.1, 1.0]; + let handle = s.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))); + let build = |s: &Scene| { + s.wires_for_block_culled( + s.model_space_block_handle(), + None, + None, + None, + None, + None, + true, + None, + ) + .into_iter() + .find(|wire| wire.name == handle.value().to_string()) + .unwrap() + }; + let original = build(&s); + assert!(s.resident_tess_memo.borrow().contains_key(&handle)); + s.paper_bg_color = [1.0; 4]; + s.document.add_layout("Review").unwrap(); + s.set_current_layout("Review".to_string()); + let cached = build(&s); + s.resident_tess_memo.borrow_mut().clear(); + let fresh = build(&s); + assert_ne!( + original.color, fresh.color, + "fixture must depend on background" + ); + assert_eq!( + cached.color, fresh.color, + "memo hit should match fresh tessellation on a white background" + ); + } + + #[test] + fn model_extents_reuse_resident_geometry_in_every_layout() { + let mut s = Scene::new(); + s.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(1_000_000.0, 2_000_000.0, 30.0), + acadrust::types::Vector3::new(1_000_010.0, 2_000_020.0, 40.0), + ))); + s.document.add_layout("Review").unwrap(); + for layout in ["Model", "Review"] { + s.set_current_layout(layout.to_string()); + *s.model_extents_cache.borrow_mut() = None; + let scale = crate::scene::annotative::scale_handle_by_name( + &s.document, + &s.document.header.current_annotation_scale, + ); + let source = s.resident_wires_for( + s.model_space_block_handle(), + Some(s.annotation_scale), + scale, + None, + None, + ); + let generation = s.last_model_wire_gen.get(); + let bounds = s.model_space_extents().unwrap(); + assert_eq!(bounds.0, glam::Vec3::new(1_000_000.0, 2_000_000.0, 30.0)); + assert_eq!(bounds.1, glam::Vec3::new(1_000_010.0, 2_000_020.0, 40.0)); + assert_eq!( + s.last_model_wire_gen.get(), + generation, + "fitting must reuse the resident source" + ); + assert!(!source.is_empty()); + } + } +} diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index bc5549f46..a7938bfa3 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -140,6 +140,40 @@ impl PointMarker { /// /// Linetype is encoded as a GPU-side dash pattern so the CPU never needs to /// split wires into per-dash segments. `pattern_length = 0.0` means solid. +/// Everything needed to resolve a wire's display colour against a background, +/// kept so the resolution can be redone for another background without +/// re-tessellating. +/// +/// `None` — the default — means the wire did not record its inputs and a +/// resolution pass must leave it alone. Only `Batches::finalize` fills it in. +pub type BgAdapt = Option>; + +/// The inputs `Batches::finalize` consumes when it resolves a colour. +/// +/// The flags are independent — a wire can paint the background itself while +/// its glyphs still adapt — so this mirrors that code's branches rather than +/// collapsing them into one enum. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BgAdaptInputs { + /// Colour before adaptation. Resolution always recomputes from here rather + /// than from `WireModel::color`, so it is idempotent and safe over a set + /// mixing memo hits with freshly tessellated wires. `adapt_to_bg` is + /// **not** re-appliable: a near-white colour snaps to pure black on a + /// light background, and pure black comes back pure *white* on a dark one, + /// losing the tint. + pub raw_color: [f32; 4], + /// Glyph colours before adaptation, parallel to `WireModel::text_verts`. + /// Empty when the wire has no glyphs or their colour never adapts. + pub text_raw_colors: Vec<[f32; 4]>, + /// Adapt against this instead of the live background: an MTEXT carrying + /// its own background fill. + pub contrast_bg: Option<[f32; 4]>, + /// The wire paints the background; its colour *is* the live background. + pub canvas_color: bool, + /// Authored colour. The background must not touch it, glyphs included. + pub preserve_color: bool, +} + #[derive(Clone, Debug)] pub struct WireModel { /// Unique identifier — the handle value as a decimal string. @@ -156,6 +190,9 @@ pub struct WireModel { pub point_marker: Option, /// RGBA colour in [0, 1]. pub color: [f32; 4], + /// How `color` and `text_verts` follow the background, or `None` when this + /// wire's colour must not be re-resolved. + pub bg_adapt: BgAdapt, /// Whether this wire is currently selected. #[allow(dead_code)] pub selected: bool, @@ -315,6 +352,7 @@ impl WireModel { /// Create a solid wire (no dash pattern, 1px weight). pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self { Self { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -688,6 +726,7 @@ impl Default for WireModel { points: Vec::new(), points_low: Vec::new(), color: Self::WHITE, + bg_adapt: None, selected: false, pattern_length: 0.0, pattern: [0.0; 8], diff --git a/src/scene/paper.rs b/src/scene/paper.rs index d7a1d9285..450074122 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -69,7 +69,36 @@ impl Scene { (layout_block, sheet, content) } + /// Could any viewport in this layout draw a grid? + /// + /// Read from the same fields `active_viewports` copies into + /// `ViewportInstance::grid_on`, and deliberately without the visibility + /// filters that function also applies: answering "maybe" only costs the + /// work that would have happened anyway, while answering "no" wrongly + /// would drop a grid the user asked for. + fn any_viewport_grid_on(&self) -> bool { + if self.current_layout == "Model" { + return self.model_tiles.borrow().iter().any(|tile| tile.grid_on); + } + let (_, sheet, content) = self.paper_viewport_handles(); + let grid_on = |handle: Handle| { + matches!( + self.document.get_entity(handle), + Some(EntityType::Viewport(vp)) if vp.status.grid_on + ) + }; + grid_on(sheet) || content.iter().copied().any(grid_on) + } + pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> { + // `active_viewports` resolves a camera for every viewport, and + // `camera_for_viewport` reaches `model_space_extents`, which outside the + // Model layout tessellates the whole drawing. Filtering on `grid_on` + // afterwards threw all of that away: on the reproducer the first paper + // frame spent 7.8 s here to produce `n=0` views. Ask first. + if !self.any_viewport_grid_on() { + return Vec::new(); + } self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D) .into_iter() .filter(|inst| inst.grid_on) diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 40786c911..26f0b2982 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -296,6 +296,7 @@ pub fn frame_wire( lo.push([lx, ly, 0.0]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 232393f6c..90064d106 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -16,6 +16,9 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() { .expect("GPU device"); let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + // Definition geometry is shared across viewport slots, so it lives on the + // MultiPipeline; a single-slot test supplies its own. + let mut block_geometry = wire_gpu::BlockGeometryCache::default(); let block = |handle: u64, x: f64| WireModel { name: handle.to_string(), points: vec![[x as f32, 0.0, 0.0], [x as f32 + 1.0, 0.0, 0.0]], @@ -28,24 +31,25 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() { let first = block(1, 10.0); let second = block(2, 20.0); let depth = rustc_hash::FxHashMap::from_iter([(1, [0.1, 0.01]), (2, [0.2, 0.01])]); - let original = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); - let unchanged = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); - assert_eq!(original[0].vertex_buffer, unchanged[0].vertex_buffer); + let original = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry); + let unchanged = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry); + assert_eq!(original[0].geometry_id(), unchanged[0].geometry_id()); assert_eq!(original[0].instance_count, 2); - let removed = pipeline.upload_block_wires(&device, &queue, &[&second], &depth); + let removed = pipeline.upload_block_wires(&device, &queue, &[&second], &depth, &mut block_geometry); assert_ne!( - original[0].vertex_buffer, removed[0].vertex_buffer, + original[0].geometry_id(), + removed[0].geometry_id(), "removing the base instance must replace vertices baked at its old position" ); let moved = block(2, 30.0); - let moved_gpu = pipeline.upload_block_wires(&device, &queue, &[&moved], &depth); - assert_ne!(removed[0].vertex_buffer, moved_gpu[0].vertex_buffer); - let restored = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); - assert_ne!(moved_gpu[0].vertex_buffer, restored[0].vertex_buffer); + let moved_gpu = pipeline.upload_block_wires(&device, &queue, &[&moved], &depth, &mut block_geometry); + assert_ne!(removed[0].geometry_id(), moved_gpu[0].geometry_id()); + let restored = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry); + assert_ne!(moved_gpu[0].geometry_id(), restored[0].geometry_id()); assert_eq!(restored[0].instance_count, 2); assert_eq!( - pipeline.block_geometry.len(), + block_geometry.len(), 1, "discard obsolete geometry" ); @@ -264,3 +268,143 @@ fn bounded_gpu_uploads_preserve_geometry_and_instancing() { "GPU validation failed" ); } + +/// The shadow map is 16 MiB and every slot used to hold one whether or not its +/// visual style enabled shadows. Now it is allocated on the transition, which +/// means the frame bind group is rebuilt while the device is watching — the one +/// path the other GPU tests never take. +#[test] +#[ignore = "requires a GPU adapter"] +fn toggling_shadows_reallocates_without_upsetting_the_device() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + + assert!( + pipeline.shadow_full.is_none(), + "a fresh slot must not hold a shadow map" + ); + let camera = crate::scene::view::camera::Camera::default(); + let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(64.0, 64.0)); + let with_shadows = |on: bool| { + let mut u = Uniforms::new(&camera, bounds, false); + u.shadow_params[0] = if on { 1.0 } else { 0.0 }; + u + }; + + // Off → on → off → on, so both transitions are exercised twice and the + // second `on` proves the release did not leave the bind group stale. + for expected in [true, false, true] { + pipeline.upload_uniforms(&device, &queue, &with_shadows(expected)); + assert_eq!( + pipeline.shadow_full.is_some(), + expected, + "shadow target should follow shadow_params[0]" + ); + } + // Releasing a cold slot gives it back and must rebuild the bind group too. + pipeline.release_heavy_resources(&device); + assert!(pipeline.shadow_full.is_none(), "a released slot holds none"); + + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn urgent_release_preserves_visible_panes_and_frees_cold_slots() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .unwrap(); + let mut pipeline = ::new( + &device, + &queue, + wgpu::TextureFormat::Bgra8UnormSrgb, + ); + let first = pipeline.resolve_slots(&device, &queue, &[10])[0]; + pipeline.inners[first].slot_id = 10; + pipeline.inners[first].ensure_depth_texture(&device, Size::new(512, 512)); + let second = pipeline.resolve_slots(&device, &queue, &[20])[0]; + pipeline.inners[second].slot_id = 20; + pipeline.inners[second].ensure_depth_texture(&device, Size::new(512, 512)); + pipeline.release_idle_slots(&device, &[second], true); + assert_eq!( + pipeline.inners[first].alloc_size, + Size::new(512, 512), + "a sibling pane prepared immediately before this pane is still visible" + ); + + pipeline + .frame_rendered + .store(true, std::sync::atomic::Ordering::Relaxed); + let next = pipeline.resolve_slots(&device, &queue, &[20]); + assert_eq!( + pipeline.release_idle_slots(&device, &next, true), + 0, + "a previous-frame pane may still be prepared later in this frame" + ); + pipeline + .frame_rendered + .store(true, std::sync::atomic::Ordering::Relaxed); + let next = pipeline.resolve_slots(&device, &queue, &[20]); + assert_eq!(pipeline.release_idle_slots(&device, &next, true), 1); + assert_eq!(pipeline.inners[first].alloc_size, Size::new(0, 0)); + assert_eq!(pipeline.inners[second].alloc_size, Size::new(512, 512)); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn late_device_errors_invalidate_uploaded_content_and_targets() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .unwrap(); + let mut pipeline = ::new( + &device, + &queue, + wgpu::TextureFormat::Bgra8UnormSrgb, + ); + let inner = &mut pipeline.inners[0]; + inner.cached_wire_id = 7; + inner.wire_arena_id = 7; + inner.ensure_depth_texture(&device, Size::new(512, 512)); + let before = gpu_errors_seen(); + let _rejected = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("rejected test allocation"), + size: device.limits().max_buffer_size + 4, + usage: wgpu::BufferUsages::VERTEX, + mapped_at_creation: false, + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + assert!(gpu_errors_seen() > before); + inner.sync_gpu_error_epoch(); + assert_eq!(inner.cached_wire_id, u64::MAX); + assert_eq!(inner.wire_arena_id, u64::MAX); + assert_eq!(inner.alloc_size, Size::new(0, 0)); + assert_eq!(inner.background_source_id, usize::MAX); + inner.cached_wire_id = 8; + inner.sync_gpu_error_epoch(); + assert_eq!( + inner.cached_wire_id, 8, + "the same error must not invalidate twice" + ); +} diff --git a/src/scene/pipeline/hatch_gpu/mod.rs b/src/scene/pipeline/hatch_gpu/mod.rs index 8731bfd9c..ea5db5789 100644 --- a/src/scene/pipeline/hatch_gpu/mod.rs +++ b/src/scene/pipeline/hatch_gpu/mod.rs @@ -159,6 +159,19 @@ impl HatchGpu { } } + pub(super) fn clear(&mut self) { + match &mut self.backend { + HatchBackend::Storage { resident, preview } => { + resident.clear(); + preview.clear(); + } + HatchBackend::Texture { resident, preview } => { + resident.clear(); + preview.clear(); + } + } + } + pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, hatches: &[HatchModel]) { match &mut self.backend { HatchBackend::Storage { resident, .. } => { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index df3e32a4d..908fece79 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -74,6 +74,7 @@ struct MeshHighlightDraw { } pub struct Pipeline { + gpu_error_epoch: usize, background_pipeline: wgpu::RenderPipeline, shadow_pipeline: wgpu::RenderPipeline, shadow_plain_pipeline: wgpu::RenderPipeline, @@ -94,6 +95,10 @@ pub struct Pipeline { /// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`. pub(crate) wire_const_bgl: Option, block_wire_const_bgl: wgpu::BindGroupLayout, + /// Which block-wire pipeline was built. Decides whether a definition's + /// segments go to a vertex buffer six times over or to a storage buffer + /// once. + block_wire_mode: wire_gpu::WirePipelineMode, wipeout_pipeline: wgpu::RenderPipeline, /// Capability-selected hatch renderer. Storage and texture transports are /// private backends behind one upload/LOD/draw lifecycle. @@ -146,8 +151,16 @@ pub struct Pipeline { background_sampler: wgpu::Sampler, background_source_id: usize, environment_source_id: usize, - _shadow_texture: wgpu::Texture, - shadow_view: wgpu::TextureView, + /// The shadow depth target, allocated only while this viewport actually + /// casts shadows. `SHADOW_MAP_SIZE` squared at `Depth32Float` is 16 MiB, + /// and every slot used to hold one whether or not its visual style enabled + /// shadows — 128 MiB across eight viewports, none of it ever sampled. + shadow_full: Option<(wgpu::Texture, wgpu::TextureView)>, + /// Bound at the frame group's shadow slot while `shadow_full` is `None`. + /// The binding must be filled for the layout to be satisfied, but nothing + /// samples it when shadows are off, so 1x1 is the whole requirement. + _shadow_fallback_texture: wgpu::Texture, + shadow_fallback_view: wgpu::TextureView, shadow_sampler: wgpu::Sampler, shadow_enabled: bool, wipeout_bgl1: wgpu::BindGroupLayout, @@ -213,9 +226,6 @@ pub struct Pipeline { /// shared resident buffer. pub(crate) gpu_wires: std::sync::Arc>, pub(crate) gpu_block_wires: std::sync::Arc>, - /// Block geometry that survives an edit, keyed by the definition it - /// expands. See `wire_gpu::BlockGeometryCache`. - pub(crate) block_geometry: wire_gpu::BlockGeometryCache, /// Persistent per-entity wire instance arena (capability-selected format). /// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an /// edit patches one entity's slab in place instead of rebuilding every wire. @@ -488,21 +498,9 @@ impl Pipeline { mipmap_filter: wgpu::MipmapFilterMode::Linear, ..Default::default() }); - let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("shadow.texture"), - size: wgpu::Extent3d { - width: SHADOW_MAP_SIZE, - height: SHADOW_MAP_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let shadow_fallback_texture = create_shadow_texture(device, 1); + let shadow_fallback_view = + shadow_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default()); let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("shadow.sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -541,7 +539,7 @@ impl Pipeline { }, wgpu::BindGroupEntry { binding: 5, - resource: wgpu::BindingResource::TextureView(&shadow_view), + resource: wgpu::BindingResource::TextureView(&shadow_fallback_view), }, wgpu::BindGroupEntry { binding: 6, @@ -581,7 +579,8 @@ impl Pipeline { bind_group_layouts: &wire_bgls, immediate_size: 0, }); - let block_wire_const_bgl = wire_gpu::block_const_bind_group_layout(device); + let block_wire_const_bgl = + wire_gpu::block_const_bind_group_layout(device, wire_mode.uses_storage()); let block_wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("block_wire.pipeline_layout"), bind_group_layouts: &[Some(&frame_bgl), Some(&block_wire_const_bgl)], @@ -602,9 +601,13 @@ impl Pipeline { }); let block_wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_wire.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/block_wire.wgsl" - ))), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed( + if wire_mode.uses_storage() { + include_str!("../../shaders/block_wire_storage.wgsl") + } else { + include_str!("../../shaders/block_wire.wgsl") + }, + )), }); // Stencil test shared by every paper content pipeline: draw only where @@ -880,6 +883,14 @@ impl Pipeline { multiview_mask: None, cache: None, }); + let block_wire_buffers: &[wgpu::VertexBufferLayout<'_>] = if wire_mode.uses_storage() { + &[wire_gpu::BlockWireInstance::layout()] + } else { + &[ + wire_gpu::BlockWireVertex::layout(), + wire_gpu::BlockWireInstance::layout(), + ] + }; let make_block_wire_pipeline = | label: &'static str, fragment: &'static str, @@ -892,10 +903,10 @@ impl Pipeline { vertex: wgpu::VertexState { module: &block_wire_shader, entry_point: Some("vs_main"), - buffers: &[ - wire_gpu::BlockWireVertex::layout(), - wire_gpu::BlockWireInstance::layout(), - ], + // Storage mode has no geometry vertex buffer: the + // instances become slot 0 and the segments arrive through + // the bind group. + buffers: block_wire_buffers, compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { @@ -2215,6 +2226,8 @@ impl Pipeline { block_wire_xray_pipeline, wire_const_bgl, block_wire_const_bgl, + block_wire_mode: wire_mode, + gpu_error_epoch: gpu_errors_seen(), wipeout_pipeline, hatch_gpu, image_pipeline, @@ -2261,8 +2274,9 @@ impl Pipeline { background_sampler, background_source_id: 0, environment_source_id: 0, - _shadow_texture: shadow_texture, - shadow_view, + shadow_full: None, + _shadow_fallback_texture: shadow_fallback_texture, + shadow_fallback_view, shadow_sampler, shadow_enabled: false, wipeout_bgl1, @@ -2282,7 +2296,6 @@ impl Pipeline { surface_format: format, gpu_wires: std::sync::Arc::new(vec![]), gpu_block_wires: std::sync::Arc::new(vec![]), - block_geometry: Default::default(), wire_arena: None, wire_arena_mesh: None, wire_arena_fallback: std::sync::Arc::new(Vec::new()), @@ -2342,6 +2355,7 @@ impl Pipeline { queue: &wgpu::Queue, wires: &[&WireModel], depth_map: &rustc_hash::FxHashMap, + block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> Vec { BlockWireGpu::from_wires( device, @@ -2349,8 +2363,11 @@ impl Pipeline { wires, depth_map, None, - &self.block_wire_const_bgl, - Some(&mut self.block_geometry), + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, + Some(block_geometry), ) } @@ -2361,6 +2378,7 @@ impl Pipeline { queue: &wgpu::Queue, wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, + block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> ( std::sync::Arc>, std::sync::Arc>, @@ -2415,8 +2433,11 @@ impl Pipeline { &block_wires, depth_map, None, - &self.block_wire_const_bgl, - Some(&mut self.block_geometry), + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, + Some(block_geometry), ); // Parse handles in parallel, preserving wire order for hover slot lists. @@ -2554,7 +2575,10 @@ impl Pipeline { &selected_blocks, depth_map, Some(tint), - &self.block_wire_const_bgl, + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, None, ) } else { @@ -2566,7 +2590,10 @@ impl Pipeline { &hover_blocks, depth_map, Some(WireModel::HOVER), - &self.block_wire_const_bgl, + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, None, )); self.gpu_selected_block_wires = block_gpu; @@ -3466,9 +3493,98 @@ impl Pipeline { } } - pub fn upload_uniforms(&mut self, queue: &wgpu::Queue, uniforms: &Uniforms) { + /// Whatever is bound at the frame group's shadow slot: the real depth + /// target when this viewport casts shadows, the 1x1 placeholder otherwise. + fn shadow_view(&self) -> &wgpu::TextureView { + match &self.shadow_full { + Some((_, view)) => view, + None => &self.shadow_fallback_view, + } + } + + /// Rebuild the frame bind group from whatever this slot currently holds. + /// + /// Three things can change what belongs in it — a background image, an + /// environment image, and whether the shadow target exists — and it used to + /// be assembled inline at each. One builder means a shadow transition + /// cannot forget the background the slot already had. + fn rebuild_frame_bind_group(&mut self, device: &wgpu::Device) { + let background_view = self + .background_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let environment_view = self + .environment_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("viewer.bind_group"), + layout: &self.frame_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&background_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.background_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&environment_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.background_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(self.shadow_view()), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&self.shadow_sampler), + }, + ], + }); + } + + /// Allocate or drop the shadow target to match `shadow_enabled`. + /// + /// Only on a transition: the frame bind group has to be rebuilt with the + /// new view, and a viewport that keeps its shadow setting should not pay + /// for that every frame. + fn sync_shadow_target(&mut self, device: &wgpu::Device) { + match (self.shadow_enabled, self.shadow_full.is_some()) { + (true, false) => { + let errors_before = gpu_errors_seen(); + let texture = create_shadow_texture(device, SHADOW_MAP_SIZE); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + if gpu_errors_seen() == errors_before { + self.shadow_full = Some((texture, view)); + } + } + (false, true) => self.shadow_full = None, + _ => return, + } + self.rebuild_frame_bind_group(device); + } + + pub fn upload_uniforms( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + uniforms: &Uniforms, + ) { self.shadow_enabled = uniforms.shadow_params[0] > 0.5; - queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms)); + self.sync_shadow_target(device); + let mut uniforms = *uniforms; + if self.shadow_full.is_none() { + uniforms.shadow_params[0] = 0.0; + } + queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); } pub fn upload_background_images( @@ -3534,46 +3650,7 @@ impl Pipeline { environment, [128, 128, 128, 255], ); - let background_view = self - .background_texture - .create_view(&wgpu::TextureViewDescriptor::default()); - let environment_view = self - .environment_texture - .create_view(&wgpu::TextureViewDescriptor::default()); - self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("viewer.bind_group"), - layout: &self.frame_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.uniform_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&background_view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&self.background_sampler), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: wgpu::BindingResource::TextureView(&environment_view), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: wgpu::BindingResource::Sampler(&self.background_sampler), - }, - wgpu::BindGroupEntry { - binding: 5, - resource: wgpu::BindingResource::TextureView(&self.shadow_view), - }, - wgpu::BindGroupEntry { - binding: 6, - resource: wgpu::BindingResource::Sampler(&self.shadow_sampler), - }, - ], - }); + self.rebuild_frame_bind_group(device); self.background_source_id = background_id; self.environment_source_id = environment_id; } @@ -3642,12 +3719,18 @@ impl Pipeline { // render rectangle does the clipping). let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 }; - if self.shadow_enabled && !self.skip_geometry && !mesh_wireframe { + // Render shadows only when a full target was allocated. + if let Some(shadow_target) = self + .shadow_full + .as_ref() + .map(|(_, view)| view) + .filter(|_| self.shadow_enabled && !self.skip_geometry && !mesh_wireframe) + { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("shadow.render_pass"), color_attachments: &[], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.shadow_view, + view: shadow_target, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, @@ -4288,10 +4371,7 @@ impl Pipeline { }); block_black_active = use_black; } - pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); - pass.set_vertex_buffer(0, wire.vertex_buffer.slice(..)); - pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); - pass.draw(0..wire.vertex_count, 0..wire.instance_count); + bind_and_draw_block_wire(&mut pass, wire); } // Live overlay wires (command preview / interim / grip drag) always // on top: the xray pipeline (depth_compare=Always, no depth write) @@ -4480,10 +4560,7 @@ impl Pipeline { if wire.instance_count == 0 { continue; } - pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); - pass.set_vertex_buffer(0, wire.vertex_buffer.slice(..)); - pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); - pass.draw(0..wire.vertex_count, 0..wire.instance_count); + bind_and_draw_block_wire(&mut pass, wire); } } if let Some(atlas) = &self.text_atlas_gpu { @@ -4571,6 +4648,117 @@ impl Pipeline { } } + /// Forget every cache key that describes this slot's uploaded content. + /// + /// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two + /// cannot disagree about what "this slot holds nothing you can trust" + /// means. Keys only — the buffers themselves are dropped by the caller that + /// wants the memory back. + pub(crate) fn forget_cached_keys(&mut self) { + self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX); + self.cached_wire_id = u64::MAX; + self.cached_selection = (u64::MAX, u64::MAX); + self.cached_highlight_key = (u64::MAX, u64::MAX); + self.cached_mesh_content_id = u64::MAX; + self.cached_face3d_key = (u64::MAX, false, false, u64::MAX); + self.cached_solid_visibility = (u64::MAX, [u32::MAX; 3], u64::MAX); + self.cached_hatch_source = None; + self.cached_preview_hatch_source = None; + self.cached_wipeout_source = None; + self.cached_image_source = None; + self.cached_text_source = None; + self.cached_mesh_source = None; + self.cached_face3d_source = None; + self.cached_face3d_depth_source = None; + self.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); + self.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false); + self.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false); + self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false); + self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX); + self.render_sig = u64::MAX; + } + + /// Catch errors delivered after the previous upload/submit completed. + pub(crate) fn sync_gpu_error_epoch(&mut self) { + let errors = gpu_errors_seen(); + if errors == self.gpu_error_epoch { + return; + } + self.gpu_error_epoch = errors; + self.forget_cached_keys(); + self.wire_arena = None; + self.wire_arena_mesh = None; + self.wire_arena_id = u64::MAX; + self.alloc_size = Size::new(0, 0); + self.shadow_full = None; + self.background_source_id = usize::MAX; + self.environment_source_id = usize::MAX; + } + + /// Give this slot's device memory back and forget what it held. + /// + /// A slot nobody is drawing still owns its wire arena, its render targets, + /// its text atlas and every category buffer — tens of megabytes each. Held + /// while another viewport is asking for an allocation, that is the + /// difference between a degraded frame and a session that cannot recover. + /// + /// Everything released here is derived state with a rebuild path guarded by + /// a cache key, and every one of those keys is reset, so the next frame that + /// needs this slot builds it again. The cost of releasing a slot that turns + /// out to be needed is one slow frame. + pub(crate) fn release_heavy_resources(&mut self, device: &wgpu::Device) { + self.forget_cached_keys(); + + self.gpu_wires = std::sync::Arc::new(Vec::new()); + self.gpu_block_wires = std::sync::Arc::new(Vec::new()); + self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default()); + self.wire_arena = None; + self.wire_arena_mesh = None; + self.wire_arena_fallback = std::sync::Arc::new(Vec::new()); + self.wire_arena_fallback_kind = None; + self.wire_arena_fallback_handles.clear(); + self.wire_arena_id = u64::MAX; + + self.gpu_selected_wires.clear(); + self.gpu_selected_block_wires.clear(); + self.gpu_preview_wires.clear(); + self.hatch_gpu.clear(); + self.gpu_wipeouts.clear(); + self.wipeout_skip_flags.clear(); + self.gpu_images.clear(); + self.gpu_mesh_batch.clear(); + self.gpu_mesh_dynamic.clear(); + self.mesh_disabled_chunks.clear(); + self.mesh_dynamic_handles.clear(); + self.mesh_ranges_by_handle.clear(); + self.mesh_highlight_draws.clear(); + self.silhouette_chunks.clear(); + self.silhouette_source_groups.clear(); + self.clip_boundary = None; + + // A cold slot's shadow target goes back too. `shadow_enabled` is left + // alone, so `sync_shadow_target` reallocates on the next frame that + // still wants shadows. + if self.shadow_full.take().is_some() { + self.rebuild_frame_bind_group(device); + } + + self.text_atlas_gpu = None; + self.text_gpu.clear(); + self.block_text_gpu.clear(); + self.block_text_highlight_gpu.clear(); + self.text_highlight_gpu.clear(); + self.text_preview_gpu.clear(); + + // Back to the smallest allocation the rounding allows (128x128, about + // 0.6 MiB against the ~70 MiB a full-canvas slot holds). Going through + // `ensure_depth_texture` rather than reaching for the textures directly + // keeps the blit bind group consistent with the views it samples. + self.ensure_depth_texture(device, Size::new(1, 1)); + // ...and make the next real size a mismatch, so it reallocates. + self.alloc_size = Size::new(0, 0); + } + pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size) { // Record the requested render size every frame (the blit UV scale reads // it); only reallocate the textures when the *rounded* size changes. @@ -4705,6 +4893,167 @@ fn aabb_below_pixel( (max_px - min_px).max(max_py - min_py) < threshold_px } +/// Device bytes a renderer is holding, counted from the sizes it allocated +/// rather than from process RSS — the allocator and the driver both keep +/// high-water memory that RSS cannot distinguish from live resources. +/// +/// Partial by construction: it covers the categories large enough to decide +/// where the next fix goes, and says so rather than pretending to be a total. +#[derive(Default, Clone, Copy, PartialEq)] +pub(crate) struct GpuLiveBytes { + pub slots: usize, + pub shadow: u64, + pub render_targets: u64, + pub text_atlas: u64, + pub wire_arena: u64, + pub block_geometry: u64, +} + +impl GpuLiveBytes { + pub(crate) fn total(&self) -> u64 { + self.shadow + self.render_targets + self.text_atlas + self.wire_arena + self.block_geometry + } +} + +impl Pipeline { + /// This slot's share. `alloc_size` is the real allocation, which is rounded + /// up from the requested size and grows only. + fn gpu_live_bytes(&self) -> GpuLiveBytes { + // 4x MSAA colour (4 B/sample) + 4x MSAA depth-stencil (4 B/sample) + + // one single-sample resolve target. + const BYTES_PER_PIXEL: u64 = (MSAA_SAMPLES as u64) * 4 + (MSAA_SAMPLES as u64) * 4 + 4; + let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height); + GpuLiveBytes { + slots: 1, + // Only while this viewport casts shadows; the placeholder that + // stands in otherwise is 4 bytes. + shadow: match &self.shadow_full { + Some(_) => u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4, + None => 4, + }, + render_targets: pixels * BYTES_PER_PIXEL, + text_atlas: self + .text_atlas_gpu + .as_ref() + .map(text_gpu::TextAtlasGpu::gpu_bytes) + .unwrap_or(0), + wire_arena: self + .wire_arena + .as_ref() + .map(wire_arena::PersistentWireArena::gpu_bytes) + .unwrap_or(0) + + self + .wire_arena_mesh + .as_ref() + .map(wire_arena::PersistentWireArena::gpu_bytes) + .unwrap_or(0), + block_geometry: 0, + } + } +} + +impl MultiPipeline { + /// Release cold slots after 64 frames, or two frames under memory pressure. + /// Current and previous frame slots remain reserved for sibling widgets. + pub(crate) fn release_idle_slots( + &mut self, + device: &wgpu::Device, + reserved: &[usize], + urgent: bool, + ) -> usize { + const IDLE_FRAMES: u64 = 64; + // Other shader widgets may prepare later in this frame. Keep every + // slot used in this or the previous frame, even under memory pressure. + let idle_frames = if urgent { 2 } else { IDLE_FRAMES }; + let mut released = 0; + for index in 0..self.inners.len() { + if reserved.contains(&index) { + continue; + } + let idle = self.slot_clock.saturating_sub(self.slot_last_used[index]); + if idle < idle_frames { + continue; + } + // Nothing to give back — releasing again would only churn the + // render targets it just rebuilt at their smallest size. + let held = self.inners[index].gpu_live_bytes(); + if held.wire_arena == 0 + && held.shadow <= 4 + && self.inners[index].alloc_size == Size::new(0, 0) + { + continue; + } + self.inners[index].release_heavy_resources(device); + released += 1; + } + if released > 0 { + self.wire_buffer_cache.retain(|_, entry| { + std::sync::Arc::strong_count(&entry.0) > 1 + || std::sync::Arc::strong_count(&entry.1) > 1 + }); + self.block_geometry.retain(|_, chunks| { + chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1) + }); + } + released + } + + /// Sum across slots, plus the caches held once for all of them. + pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes { + let mut total = GpuLiveBytes { + block_geometry: wire_gpu::block_geometry_bytes(&self.block_geometry), + ..Default::default() + }; + for inner in &self.inners { + let slot = inner.gpu_live_bytes(); + total.slots += slot.slots; + total.shadow += slot.shadow; + total.render_targets += slot.render_targets; + total.text_atlas += slot.text_atlas; + total.wire_arena += slot.wire_arena; + } + total + } +} + +/// Bind one block-wire batch and draw it. +/// +/// The two pipeline modes lay the vertex slots out differently — packed puts +/// the six-per-segment geometry in slot 0 and the placements in slot 1, while +/// storage has no geometry vertex buffer at all and the placements become slot +/// 0. Both draw loops go through here so they cannot drift apart. +fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::BlockWireGpu) { + pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); + match &wire.vertex_buffer { + Some(vertices) => { + pass.set_vertex_buffer(0, vertices.slice(..)); + pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); + } + None => pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)), + } + pass.draw(0..wire.vertex_count, 0..wire.instance_count); +} + +/// A square `Depth32Float` render target for the shadow pass. `side` is 1 for +/// the placeholder that keeps the frame bind group satisfied while shadows are +/// off, and `SHADOW_MAP_SIZE` for the real thing. +fn create_shadow_texture(device: &wgpu::Device, side: u32) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("shadow.texture"), + size: wgpu::Extent3d { + width: side, + height: side, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) +} + fn create_depth_texture(device: &wgpu::Device, size: Size) -> wgpu::Texture { device.create_texture(&wgpu::TextureDescriptor { label: Some("viewer.depth_texture"), @@ -4780,14 +5129,11 @@ pub struct MultiPipeline { pub(crate) slot_by_instance: rustc_hash::FxHashMap, slot_last_used: Vec, slot_clock: u64, - /// The resident wire batches, keyed by `wire_content_id` and shared across - /// every slot (and every pane — one `MultiPipeline` backs all of them) that - /// renders the same content. `prepare` builds an entry once on a cache miss - /// then hands `Arc` clones to each slot, so N paper viewports / Model tiles - /// showing one identical resident set upload the wire vertex buffers exactly - /// once between them instead of once per slot. Kept trim by dropping entries - /// no slot still references (`Arc::strong_count == 1`) once it grows past a - /// small bound. + pub(crate) frame_rendered: std::sync::atomic::AtomicBool, + pub(crate) gpu_error_epoch: usize, + /// Definition geometry shared across viewport slots. + pub(crate) block_geometry: wire_gpu::BlockGeometryCache, + /// Shared resident batches, keyed by wire content identity. pub(crate) wire_buffer_cache: rustc_hash::FxHashMap< u64, ( @@ -4799,11 +5145,7 @@ pub struct MultiPipeline { } impl MultiPipeline { - /// Ensure at least `n` (≥1) inner pipelines exist, creating any missing - /// ones. Grow-only: extra pipelines are NOT dropped, because per-pane Model - /// shader widgets share this storage and each only touches its own slot — - /// truncating mid-frame would destroy another pane's slot. Stale inners (a - /// closed pane, or fewer paper viewports) are harmless idle GPU resources. + /// Grow slot storage without disturbing sibling shader widgets. pub(crate) fn ensure_len( &mut self, device: &wgpu::Device, @@ -4817,12 +5159,7 @@ impl MultiPipeline { } } - /// Resolve stable slots for the viewport identities in one primitive. - /// Thirty-two hot slots cover ordinary tiled/paper drawings. A cold slot - /// is recycled only after several other prepare calls, which prevents - /// sibling Model panes prepared in the same frame from evicting each - /// other. If every slot is still hot, growing is safer than a visible - /// rebuild hitch. + /// Preserve viewport slots across frames; recycle only cold slots above the limit. pub(crate) fn resolve_slots( &mut self, device: &wgpu::Device, @@ -4832,7 +5169,11 @@ impl MultiPipeline { const SOFT_LIMIT: usize = 32; const HOT_WINDOW: u64 = 8; - self.slot_clock = self.slot_clock.wrapping_add(1).max(1); + if self.frame_rendered.swap(false, std::sync::atomic::Ordering::Relaxed) + || self.slot_clock == 0 + { + self.slot_clock = self.slot_clock.wrapping_add(1).max(1); + } let now = self.slot_clock; let reserved: rustc_hash::FxHashSet = instance_ids.iter().copied().collect(); // `inner.slot_id` is updated later in `prepare`, after this whole @@ -4914,12 +5255,25 @@ impl MultiPipeline { /// `Pipeline::new`, which runs again for every pane `ensure_len` adds. If iced /// ever rebuilds the device it builds a new `MultiPipeline` too, so the fresh /// device is covered (and the throttle restarts with it). +static GPU_ERRORS_SEEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many uncaptured device errors have been reported since this device was +/// created. +/// +/// Callers snapshot it around an allocation to find out whether what they just +/// built is usable. Best effort: wgpu surfaces some errors asynchronously, so a +/// move means "something failed", while no move is not proof that nothing did. +/// That is the right asymmetry here — the cost of believing a bad buffer is a +/// frozen viewport, the cost of rebuilding a good one is a frame. +pub(crate) fn gpu_errors_seen() -> usize { + GPU_ERRORS_SEEN.load(std::sync::atomic::Ordering::Relaxed) +} + fn install_gpu_error_handler(device: &wgpu::Device) { - use std::sync::atomic::{AtomicUsize, Ordering}; - static SEEN: AtomicUsize = AtomicUsize::new(0); - SEEN.store(0, Ordering::Relaxed); + use std::sync::atomic::Ordering; + GPU_ERRORS_SEEN.store(0, Ordering::Relaxed); device.on_uncaptured_error(std::sync::Arc::new(|e: wgpu::Error| { - let n = SEEN.fetch_add(1, Ordering::Relaxed) + 1; + let n = GPU_ERRORS_SEEN.fetch_add(1, Ordering::Relaxed) + 1; if n.is_power_of_two() { eprintln!("[gpu] uncaptured wgpu error #{n} (frame degraded, session kept alive): {e}"); } @@ -4930,11 +5284,14 @@ impl iced::widget::shader::Pipeline for MultiPipeline { fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { install_gpu_error_handler(device); Self { + block_geometry: Default::default(), inners: vec![Pipeline::new(device, queue, format)], format, slot_by_instance: rustc_hash::FxHashMap::default(), slot_last_used: vec![0], slot_clock: 0, + frame_rendered: std::sync::atomic::AtomicBool::new(false), + gpu_error_epoch: gpu_errors_seen(), wire_buffer_cache: rustc_hash::FxHashMap::default(), } } diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index cb3e1e482..136e0945b 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -221,6 +221,11 @@ pub struct TextAtlasGpu { } impl TextAtlasGpu { + /// Device bytes this atlas holds. `R8`, so one byte per texel. + pub fn gpu_bytes(&self) -> u64 { + u64::from(self._texture.width()) * u64::from(self._texture.height()) + } + /// Bind-group layout for group 1: `{ R8 atlas texture, linear sampler }`. pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 8a98b41a5..4fe85fab0 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -1369,6 +1369,18 @@ impl PersistentWireArena { } } + /// Device bytes this arena holds. The instance buffer is reserved with + /// headroom, so this is the allocation, not the occupied prefix — which is + /// what a memory budget has to answer for. + pub fn gpu_bytes(&self) -> u64 { + match &self.inner { + PersistentWireArenaKind::Indexed(arena) => { + arena.inst_buf.size() + arena.const_buf.size() + } + PersistentWireArenaKind::Packed(arena) => arena.inst_buf.size(), + } + } + pub fn wire_gpus_visible( &self, view_rot: glam::Mat4, diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 01e382fb2..ab1fd455b 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -334,7 +334,9 @@ impl BlockWireInstance { #[derive(Clone)] pub struct BlockWireGpu { - pub vertex_buffer: wgpu::Buffer, + /// Packed mode only; `None` in storage mode, where the segments reach the + /// shader through `const_bind_group` instead. + pub vertex_buffer: Option, pub instance_buffer: wgpu::Buffer, pub vertex_count: u32, pub instance_count: u32, @@ -342,19 +344,38 @@ pub struct BlockWireGpu { pub const_bind_group: std::sync::Arc, } -pub fn block_const_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("block_wire_const.bgl"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, +/// Binding 0 is the definition's constants. In storage mode binding 1 carries +/// that chunk's segments, which is why the bind group is per-chunk there and +/// shared across chunks in packed mode. +pub fn block_const_bind_group_layout( + device: &wgpu::Device, + uses_storage: bool, +) -> wgpu::BindGroupLayout { + let mut entries = vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }]; + if uses_storage { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 1, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, + ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None, }, count: None, - }], + }); + } + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("block_wire_const.bgl"), + entries: &entries, }) } @@ -669,23 +690,76 @@ pub(crate) fn wire_draw_depth( /// Vertices use the first instance's world coordinates, so changing that base /// must invalidate the geometry as well as rebuild the relative placements. pub type BlockGeometryCache = rustc_hash::FxHashMap< - (u64, bool, [u32; 4], [u64; 3]), - ( - std::sync::Arc>, - std::sync::Arc, - ), + BlockGeometryKey, + std::sync::Arc>, >; +/// How this device draws block wires. The layout and the mode are decided +/// together when the pipeline is built and are never chosen independently, so +/// they travel as one argument. +#[derive(Clone, Copy)] +pub struct BlockWireTarget<'a> { + pub const_bgl: &'a wgpu::BindGroupLayout, + pub mode: WirePipelineMode, +} + +/// Device bytes held by a whole block-geometry cache. +pub fn block_geometry_bytes(cache: &BlockGeometryCache) -> u64 { + cache + .values() + .flat_map(|chunks| chunks.iter()) + .map(|chunk| chunk.gpu_bytes) + .sum() +} + +/// `(source_id, mesh_edge, colour bits, base translation bits)`. +pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]); + +/// One drawable slice of a block definition's geometry. +/// +/// The two pipeline modes put the segments in different places, so the chunk +/// carries whichever the mode produced: +/// +/// * packed — `vertex_buffer` holds six copies of each segment, one per corner +/// of its quad, and `bind_group` is the definition's constants alone; +/// * storage — the segments are in a read-only storage buffer inside +/// `bind_group`, one copy each, and `vertex_buffer` is `None`. +/// +/// `vertex_count` is six per segment either way: in storage mode the shader +/// divides `vertex_index` by six to find its segment. +pub struct BlockGeometryChunk { + pub vertex_buffer: Option, + pub vertex_count: u32, + pub bind_group: std::sync::Arc, + /// Device bytes this chunk holds. Recorded at build because in storage mode + /// the buffer is owned by `bind_group` and cannot be measured from here. + pub gpu_bytes: u64, +} + impl BlockWireGpu { + /// Identity of the geometry this batch draws, comparable across pipeline + /// modes. + /// + /// Packed mode keeps the segments in `vertex_buffer`; storage mode keeps + /// them inside `const_bind_group`, which is per-chunk there, and leaves + /// `vertex_buffer` `None`. Both are built on the same cache miss and + /// reused together on a hit, so the pair answers "is this the same + /// geometry as before?" in either mode — which `vertex_buffer` alone + /// cannot once it is always `None`. + pub fn geometry_id(&self) -> (Option<&wgpu::Buffer>, &wgpu::BindGroup) { + (self.vertex_buffer.as_ref(), self.const_bind_group.as_ref()) + } + pub fn from_wires( device: &wgpu::Device, queue: &wgpu::Queue, wires: &[&WireModel], depth_map: &rustc_hash::FxHashMap, color_override: Option<[f32; 4]>, - const_bgl: &wgpu::BindGroupLayout, + target: BlockWireTarget<'_>, mut cache: Option<&mut BlockGeometryCache>, ) -> Vec { + let BlockWireTarget { const_bgl, mode } = target; let mut slots: rustc_hash::FxHashMap<(u64, bool), usize> = rustc_hash::FxHashMap::default(); let mut groups: Vec<(bool, Vec<&WireModel>)> = Vec::new(); @@ -710,7 +784,14 @@ impl BlockWireGpu { } let mut out = Vec::new(); - let mut live_keys = rustc_hash::FxHashSet::default(); + let mut live_keys: rustc_hash::FxHashSet = + rustc_hash::FxHashSet::default(); + // Geometry bytes actually sent to the device this call, so the storage + // path's saving is visible rather than assumed. + let mut uploaded_bytes = 0usize; + let mut uploaded_segments = 0usize; + // Definitions whose upload the device rejected, so they were not cached. + let mut poisoned = 0usize; for (mesh_edge, group) in groups { let Some(&source) = group.first() else { continue; @@ -727,56 +808,35 @@ impl BlockWireGpu { ); live_keys.insert(cache_key); // Reuse this definition's geometry when it is already on the GPU. - if let Some((chunks, bind_group)) = cache + if let Some(chunks) = cache .as_deref() .and_then(|c| c.get(&cache_key)) - .map(|(v, b)| (std::sync::Arc::clone(v), std::sync::Arc::clone(b))) + .map(std::sync::Arc::clone) { let instances = block_instances(&group, base, mesh_edge, depth_map); - push_block_batches( - &mut out, - device, - queue, - &chunks, - &instances, - mesh_edge, - &bind_group, - ); + push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); continue; } + // A failed allocation still produces a `Buffer`; it is simply + // invalid, and every later use of it reports again. Cached, it + // freezes the viewport for the rest of the session because the key + // keeps hitting and nothing ever rebuilds. Watch the device's error + // count across the build and refuse to cache what it condemns. + let errors_before = super::gpu_errors_seen(); let (segments, mut constant) = emit_wire_native(source, 0, color, 0.0); if segments.is_empty() { continue; } constant.draw_depth = 0.0; - let mut vertices = Vec::with_capacity(segments.len() * 6); - for segment in segments { - let vertex = BlockWireVertex { + let records: Vec = segments + .iter() + .map(|segment| BlockWireVertex { pos_a: segment.pos_a, pos_a_low: segment.pos_a_low, pos_b: segment.pos_b, pos_b_low: segment.pos_b_low, distances: [segment.distance_a, segment.distance_b], taper_ratio: segment.taper_ratio, - }; - vertices.extend_from_slice(&[vertex; 6]); - } - // Bound uploads without splitting a segment's six vertices. - let max_verts = - super::gpu_budget::max_elements_grouped::(device, 6); - let vertex_chunks: Vec<(wgpu::Buffer, u32)> = vertices - .chunks(max_verts) - .map(|chunk| { - ( - super::gpu_upload::upload_buffer( - device, - queue, - "block_wire.vertices", - chunk, - wgpu::BufferUsages::VERTEX, - ), - chunk.len() as u32, - ) }) .collect(); // Queue uploads avoid mapping a failed allocation. @@ -787,41 +847,118 @@ impl BlockWireGpu { std::slice::from_ref(&constant), wgpu::BufferUsages::UNIFORM, ); - let const_bind_group = std::sync::Arc::new(device.create_bind_group( - &wgpu::BindGroupDescriptor { - label: Some("block_wire.const.bg"), - layout: const_bgl, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: const_buffer.as_entire_binding(), - }], - }, - )); - let vertex_chunks = std::sync::Arc::new(vertex_chunks); - if let Some(cache) = cache.as_deref_mut() { - cache.insert( - cache_key, - ( - std::sync::Arc::clone(&vertex_chunks), - std::sync::Arc::clone(&const_bind_group), - ), - ); + // Bound uploads so a block-heavy drawing cannot ask a low-VRAM + // device for one multi-hundred-MB buffer. + let chunks: Vec = if mode.uses_storage() { + // One record per segment. A storage chunk is bounded by the + // binding size as well as the buffer budget, and needs no + // grouping: the shader indexes segments directly. + let max_records = + super::gpu_budget::max_storage_elements::(device); + records + .chunks(max_records) + .map(|chunk| { + let segments = super::gpu_upload::upload_buffer( + device, + queue, + "block_wire.segments", + chunk, + wgpu::BufferUsages::STORAGE, + ); + BlockGeometryChunk { + vertex_buffer: None, + vertex_count: chunk.len() as u32 * 6, + gpu_bytes: std::mem::size_of_val(chunk) as u64, + bind_group: std::sync::Arc::new(device.create_bind_group( + &wgpu::BindGroupDescriptor { + label: Some("block_wire.const.bg"), + layout: const_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: const_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: segments.as_entire_binding(), + }, + ], + }, + )), + } + }) + .collect() + } else { + // Six copies per segment, one per corner of its quad, without + // splitting a segment's six vertices across two buffers. + let mut vertices = Vec::with_capacity(records.len() * 6); + for record in &records { + vertices.extend_from_slice(&[*record; 6]); + } + let max_verts = + super::gpu_budget::max_elements_grouped::(device, 6); + let shared = std::sync::Arc::new(device.create_bind_group( + &wgpu::BindGroupDescriptor { + label: Some("block_wire.const.bg"), + layout: const_bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: const_buffer.as_entire_binding(), + }], + }, + )); + vertices + .chunks(max_verts) + .map(|chunk| BlockGeometryChunk { + gpu_bytes: std::mem::size_of_val(chunk) as u64, + vertex_buffer: Some(super::gpu_upload::upload_buffer( + device, + queue, + "block_wire.vertices", + chunk, + wgpu::BufferUsages::VERTEX, + )), + vertex_count: chunk.len() as u32, + bind_group: std::sync::Arc::clone(&shared), + }) + .collect() + }; + uploaded_segments += records.len(); + uploaded_bytes += records.len() + * std::mem::size_of::() + * if mode.uses_storage() { 1 } else { 6 }; + let chunks = std::sync::Arc::new(chunks); + let built_clean = super::gpu_errors_seen() == errors_before; + if built_clean { + if let Some(cache) = cache.as_deref_mut() { + cache.insert(cache_key, std::sync::Arc::clone(&chunks)); + } + } else { + // Draw this frame with what we have — a degraded frame beats a + // blank one — but leave the cache clean so the next frame tries + // again. `live_keys` still holds the key, so the retain below + // does not evict a good entry from an earlier frame. + poisoned += 1; } let instances = block_instances(&group, base, mesh_edge, depth_map); - push_block_batches( - &mut out, - device, - queue, - &vertex_chunks, - &instances, - mesh_edge, - &const_bind_group, - ); + push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); } // Release geometry that this build no longer uses. if let Some(cache) = cache { cache.retain(|key, _| live_keys.contains(key)); } + if crate::perf::enabled() && uploaded_segments > 0 { + crate::perf_record!( + "[perf] block-wire-geometry mode={} definitions={} segments={} bytes={} \ +bytes_if_packed={} poisoned={}", + if mode.uses_storage() { "storage" } else { "packed" }, + live_keys.len(), + uploaded_segments, + uploaded_bytes, + uploaded_segments * std::mem::size_of::() * 6, + poisoned, + ); + } out } } @@ -863,16 +1000,15 @@ fn block_instances( .collect() } -/// One batch per (vertex chunk × instance chunk). Instance buffers are built -/// once and shared across vertex chunks rather than re-uploaded per chunk. +/// One batch per (geometry chunk × instance chunk). Instance buffers are built +/// once and shared across geometry chunks rather than re-uploaded per chunk. fn push_block_batches( out: &mut Vec, device: &wgpu::Device, queue: &wgpu::Queue, - vertex_chunks: &[(wgpu::Buffer, u32)], + chunks: &[BlockGeometryChunk], instances: &[BlockWireInstance], mesh_edge: bool, - const_bind_group: &std::sync::Arc, ) { let max_instances = super::gpu_budget::max_elements::(device); let instance_chunks: Vec<(wgpu::Buffer, u32)> = instances @@ -884,15 +1020,15 @@ fn push_block_batches( ) }) .collect(); - for (vertex_buffer, vertex_count) in vertex_chunks { + for chunk in chunks { for (instance_buffer, instance_count) in &instance_chunks { out.push(BlockWireGpu { - vertex_buffer: vertex_buffer.clone(), + vertex_buffer: chunk.vertex_buffer.clone(), instance_buffer: instance_buffer.clone(), - vertex_count: *vertex_count, + vertex_count: chunk.vertex_count, instance_count: *instance_count, is_3d_mesh_edge: mesh_edge, - const_bind_group: const_bind_group.clone(), + const_bind_group: std::sync::Arc::clone(&chunk.bind_group), }); } } @@ -1271,3 +1407,60 @@ impl WireGpu { .collect() } } + +#[cfg(test)] +mod block_wire_storage_tests { + use super::BlockWireVertex; + + fn module(source: &str) -> naga::Module { + naga::front::wgsl::parse_str(source).expect("shader parses") + } + + fn validate(module: &naga::Module) -> naga::valid::ModuleInfo { + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(module) + .expect("shader validates") + } + + /// A layout mismatch here is invisible until a block draws as garbage on a + /// real device, so the stride is checked against the Rust record instead. + #[test] + fn segment_struct_matches_the_rust_record() { + let module = module(include_str!("../../shaders/block_wire_storage.wgsl")); + validate(&module); + let mut layouter = naga::proc::Layouter::default(); + layouter + .update(module.to_ctx()) + .expect("layouts are computable"); + let (handle, _) = module + .types + .iter() + .find(|(_, ty)| ty.name.as_deref() == Some("Segment")) + .expect("Segment is declared"); + assert_eq!( + layouter[handle].size as usize, + std::mem::size_of::(), + "WGSL Segment and Rust BlockWireVertex must agree byte for byte; \ + a vec3 member would pad to 16 and break this", + ); + assert_eq!(layouter[handle].alignment * 1u32, 4); + } + + /// The two shaders must stay interchangeable: same entry points, same + /// fragment behaviour. Only the geometry source differs. + #[test] + fn both_block_wire_shaders_expose_the_same_entry_points() { + let packed = module(include_str!("../../shaders/block_wire.wgsl")); + let storage = module(include_str!("../../shaders/block_wire_storage.wgsl")); + validate(&packed); + let names = |m: &naga::Module| { + let mut n: Vec = m.entry_points.iter().map(|e| e.name.clone()).collect(); + n.sort(); + n + }; + assert_eq!(names(&packed), names(&storage)); + } +} diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 1cb8c7101..0cac9c0f2 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -309,6 +309,7 @@ pub fn apply_along( .into_iter() .filter(|s| s.len() >= 2) .map(|pts| WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -357,6 +358,7 @@ pub fn apply_along( xy = xy.max(y); } out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 768f20ce4..72fd2b92d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -364,12 +364,21 @@ impl shader::Primitive for Primitive { viewport: &Viewport, ) { let nav_prepare_started = iced::time::Instant::now(); + let errors_at_entry = crate::scene::pipeline::gpu_errors_seen(); + let recovering = pipeline.gpu_error_epoch != errors_at_entry; + if recovering { + pipeline.wire_buffer_cache.clear(); + pipeline.block_geometry.clear(); + pipeline.gpu_error_epoch = errors_at_entry; + } let scale = viewport.scale_factor() as f32; let instance_ids: Vec = self.viewports.iter().map(|vp| vp.instance_id).collect(); let slots = pipeline.resolve_slots(device, queue, &instance_ids); - for (i, vp) in self.viewports.iter().enumerate() { let inner = &mut pipeline.inners[slots[i]]; + inner.sync_gpu_error_epoch(); + // Do not latch rejected uploads as current content. + let slot_errors_before = crate::scene::pipeline::gpu_errors_seen(); // Pipeline slots are addressed by list index, but off-canvas // viewports are dropped from the list — so a slot can be reused by a // DIFFERENT viewport across frames (e.g. the first viewport scrolls @@ -381,26 +390,7 @@ impl shader::Primitive for Primitive { // the surviving viewport's text/geometry vanish. if inner.slot_id != vp.instance_id { inner.slot_id = vp.instance_id; - inner.cached_epoch = (u64::MAX, u64::MAX, u64::MAX); - inner.cached_wire_id = u64::MAX; - inner.cached_selection = (u64::MAX, u64::MAX); - inner.cached_mesh_content_id = u64::MAX; - inner.cached_face3d_key = (u64::MAX, false, false, u64::MAX); - inner.cached_solid_visibility = - (u64::MAX, [u32::MAX; 3], u64::MAX); - inner.cached_hatch_source = None; - inner.cached_preview_hatch_source = None; - inner.cached_wipeout_source = None; - inner.cached_image_source = None; - inner.cached_text_source = None; - inner.cached_mesh_source = None; - inner.cached_face3d_source = None; - inner.cached_face3d_depth_source = None; - inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); - inner.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false); - inner.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false); - inner.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false); - inner.render_sig = u64::MAX; + inner.forget_cached_keys(); } // The MSAA / depth / resolve textures are always sized to the // FULL viewport rectangle (not the on-canvas-visible portion) @@ -434,7 +424,7 @@ impl shader::Primitive for Primitive { vp.background_image.as_ref(), vp.environment_image.as_ref(), ); - inner.upload_uniforms(queue, &vp.uniforms); + inner.upload_uniforms(device, queue, &vp.uniforms); // ── Scene-render cache ──────────────────────────────────────── // A pure cursor move — or any frame where the view, geometry, @@ -714,6 +704,9 @@ impl shader::Primitive for Primitive { }) }) }; + // Snapshot before any arena allocation, so a rejected + // upload cannot claim this content id below. + let arena_errors_before = crate::scene::pipeline::gpu_errors_seen(); let reg_ok = base_ok && if let Some(arena) = inner.wire_arena.as_mut() { let patch = patch.unwrap(); @@ -857,6 +850,7 @@ impl shader::Primitive for Primitive { queue, &instanced, &draw_depths, + &mut pipeline.block_geometry, ), ); if _patched { @@ -868,7 +862,15 @@ impl shader::Primitive for Primitive { inner.wire_handle_index = wire_arena::build_handle_index(&vp_wires[..]); } - inner.wire_arena_id = vp.wire_content_id; + // Only claim the content id when the device accepted + // the upload; otherwise the slot believes it holds this + // content and never rebuilds it. + inner.wire_arena_id = + if crate::scene::pipeline::gpu_errors_seen() == arena_errors_before { + vp.wire_content_id + } else { + u64::MAX + }; arena_served = true; } else { inner.wire_arena = None; @@ -922,8 +924,16 @@ impl shader::Primitive for Primitive { ); } let t_upload = _perf.then(iced::time::Instant::now); + let errors_before = + crate::scene::pipeline::gpu_errors_seen(); let entry = - inner.build_wire_buffers(device, queue, &vp_wires[..], &draw_depths); + inner.build_wire_buffers( + device, + queue, + &vp_wires[..], + &draw_depths, + &mut pipeline.block_geometry, + ); if let Some(start) = t_upload { crate::perf_record!( "[perf] wire-upload {:.1}ms wires={} content_id={}", @@ -933,9 +943,16 @@ impl shader::Primitive for Primitive { ); } - pipeline - .wire_buffer_cache - .insert(vp.wire_content_id, entry.clone()); + // Buffers the device rejected are still `Buffer`s. + // Cached under a content id that keeps matching, + // they render nothing for the rest of the session + // and nothing ever rebuilds them. Draw the degraded + // frame, but let the next one try again. + if crate::scene::pipeline::gpu_errors_seen() == errors_before { + pipeline + .wire_buffer_cache + .insert(vp.wire_content_id, entry.clone()); + } entry } }; @@ -1169,6 +1186,15 @@ impl shader::Primitive for Primitive { self.viewcube_text_color, ); } + + // A rejected allocation leaves buffers that are invalid but + // indistinguishable from good ones at every cache key. Forget + // them all, so the next frame rebuilds this slot instead of + // drawing nothing for the rest of the session — which is what + // sent a user to REGENALL to get the model back. + if crate::scene::pipeline::gpu_errors_seen() != slot_errors_before { + inner.forget_cached_keys(); + } } let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0; if let Some(sample) = self.nav_perf { @@ -1190,6 +1216,17 @@ impl shader::Primitive for Primitive { self.viewports.len(), ); } + // Memory pressure shortens cold-slot retention without evicting siblings. + let errors_now = crate::scene::pipeline::gpu_errors_seen(); + let urgent = recovering || errors_now != errors_at_entry; + let released = pipeline.release_idle_slots(device, &slots, urgent); + if released > 0 && crate::perf::enabled() { + crate::perf_record!( + "[perf] slots-released n={released} urgent={urgent} slots={}", + pipeline.inners.len(), + ); + } + report_gpu_live(pipeline); } fn render( @@ -1200,6 +1237,7 @@ impl shader::Primitive for Primitive { clip: &Rectangle, ) { let nav_render_started = iced::time::Instant::now(); + pipeline.frame_rendered.store(true, std::sync::atomic::Ordering::Relaxed); let cw = clip.width as f32; let ch = clip.height as f32; let clip_right = clip.x + clip.width; @@ -1541,6 +1579,42 @@ fn solar_direction( // ── Render-style helpers (impl Scene) ──────────────────────────────────── +/// Report what the renderer is holding on the device, when it changes. +/// +/// Only on change: `prepare` runs thousands of times a session and these +/// numbers move a handful of times. RSS is deliberately not used — the +/// allocator and the driver both retain high-water memory that RSS cannot tell +/// apart from live resources. +fn report_gpu_live(pipeline: &crate::scene::pipeline::MultiPipeline) { + use std::cell::Cell; + if !crate::perf::enabled() { + return; + } + thread_local! { + static LAST: Cell> = const { Cell::new(None) }; + } + let now = pipeline.gpu_live_bytes(); + LAST.with(|last| { + if last.get() == Some(now) { + return; + } + last.set(Some(now)); + const MIB: f64 = 1024.0 * 1024.0; + let mib = |bytes: u64| bytes as f64 / MIB; + crate::perf_record!( + "[perf] gpu-live slots={} total={:.1}MiB shadow={:.1} targets={:.1} \ +text_atlas={:.1} wire_arena={:.1} block_geometry={:.1}", + now.slots, + mib(now.total()), + mib(now.shadow), + mib(now.render_targets), + mib(now.text_atlas), + mib(now.wire_arena), + mib(now.block_geometry), + ); + }); +} + impl Scene { fn model_tile_vport(&self, index: usize) -> Option<&acadrust::tables::VPort> { let rect = self.model_tiles.borrow().get(index)?.rect; @@ -3145,6 +3219,43 @@ pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { } } +/// Re-resolve display colours for `bg`, for wires that recorded what their +/// resolution consumed. +/// +/// This is what lets a set tessellated under one background be shown under +/// another without rebuilding it: the geometry never depended on the +/// background, only the colours did — see the `let _ = bg_color;` in +/// `block_cache::local_wires_for`. +/// +/// Every colour is recomputed from `raw_color`, never from the current +/// `color`, so the pass is idempotent and safe over a set that mixes memoized +/// wires with freshly tessellated ones. Recomputing from `color` would not be: +/// `adapt_to_bg` maps a near-white colour to pure black, and pure black back +/// to pure *white*, so a second application loses the original tint. +/// +/// Wires with `bg_adapt: None` — everything not built by `Batches::finalize` — +/// are left alone. +pub(crate) fn resolve_colors_for_bg(wires: &mut [WireModel], bg: [f32; 4]) { + for wire in wires { + let Some(adapt) = wire.bg_adapt.as_deref() else { + continue; + }; + let contrast = adapt.contrast_bg.unwrap_or(bg); + wire.color = if adapt.canvas_color { + bg + } else if adapt.preserve_color { + adapt.raw_color + } else { + adapt_to_bg(adapt.raw_color, contrast) + }; + if !adapt.preserve_color { + for (vertex, raw) in wire.text_verts.iter_mut().zip(&adapt.text_raw_colors) { + vertex.color = adapt_to_bg(*raw, contrast); + } + } + } +} + // ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ── impl Scene { diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl new file mode 100644 index 000000000..98cda4857 --- /dev/null +++ b/src/shaders/block_wire_storage.wgsl @@ -0,0 +1,323 @@ +// Block-wire vertex shader, storage-buffer variant. +// +// `block_wire.wgsl` carries the segment endpoints as vertex attributes, and a +// segment must reach the shader once per corner of its quad, so every record +// is written to VRAM six times: 360 bytes a segment. On a block-heavy drawing +// that is the buffer that runs a 2 GB card out of memory — the OOM names +// `block_wire.vertices` directly. +// +// Here the segments live in a read-only storage buffer indexed by +// `vertex_index / 6`, so each record is stored once: 60 bytes a segment. The +// two shaders are otherwise identical, and must stay so. +// +// `Segment` is declared with scalar fields rather than `vec3`, which +// would align to 16 bytes in storage and reintroduce padding. All-f32 members +// give it align 4 and stride 60, matching `BlockWireVertex` byte for byte. +// +// Requires storage buffers in the vertex stage; callers select +// `block_wire.wgsl` when `DeviceCapabilities::supports_wire_storage` is false. + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + linetype_scale: f32, + lineweight_scale: f32, + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +struct WireConst { + color: vec4, + pat0: vec4, + pat1: vec4, + half_width: f32, + pattern_length: f32, + draw_depth: f32, + align_end: f32, + align_total: f32, + world_half_width: f32, + _pad1: f32, + _pad2: f32, + marker_origin_high: vec4, + marker_origin_low: vec4, + marker_normal_scale: vec4, +} +@group(1) @binding(0) var wire_const: WireConst; + +struct Segment { + pos_a_x: f32, pos_a_y: f32, pos_a_z: f32, + pos_a_low_x: f32, pos_a_low_y: f32, pos_a_low_z: f32, + pos_b_x: f32, pos_b_y: f32, pos_b_z: f32, + pos_b_low_x: f32, pos_b_low_y: f32, pos_b_low_z: f32, + distance_a: f32, distance_b: f32, + // Two `u16` unorms packed into one word, as `Unorm16x2` delivered them. + taper_ratio: u32, +} +@group(1) @binding(1) var segments: array; + +struct VertexIn { + @location(6) translation: vec3, + @location(7) translation_low: vec3, + @location(8) depth: vec2, +} + +const DRAW_ORDER_BIAS: f32 = 0.001; +const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; +const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, + @location(1) distance: f32, + @location(2) pattern_length: f32, + @location(3) pat0: vec4, + @location(4) pat1: vec4, + @location(5) @interpolate(flat) min_elem: f32, + @location(6) @interpolate(flat) align_end: f32, + @location(7) @interpolate(flat) align_total: f32, + @location(8) cap: vec2, + @location(9) @interpolate(flat) cap_ends: vec3, +} + +fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 { + if taper_ratio > 0.0 { + return max((taper_ratio * world_hw) / u.world_per_pixel, 0.5); + } + if world_hw > 0.0 { + return max(world_hw / u.world_per_pixel, 0.5); + } + var display_hw = max(px_hw * u.lineweight_scale, 0.5); + if u.lineweight_scale < 0.0 { + let scale = -u.lineweight_scale; + let base_hw = select( + min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5), + 0.5, + px_hw <= 0.5, + ); + display_hw = max(base_hw * scale, 0.5); + } + return select(0.5, display_hw, u.lwdisplay_enable > 0.5); +} + +fn marker_relative( + position_high: vec3, + position_low: vec3, + translation: vec3, + translation_low: vec3, +) -> vec3 { + if wire_const.marker_normal_scale.w <= 0.0 { + return (position_high + translation - u.eye_high) + + (position_low + translation_low - u.eye_low); + } + let origin_high = wire_const.marker_origin_high.xyz; + let origin_low = wire_const.marker_origin_low.xyz; + let origin_relative = (origin_high + translation - u.eye_high) + + (origin_low + translation_low - u.eye_low); + let delta = (position_high - origin_high) + (position_low - origin_low); + let normal = normalize(wire_const.marker_normal_scale.xyz); + let axial = normal * dot(delta, normal); + let planar = delta - axial; + let origin_clip = u.view_rot * vec4(origin_relative, 1.0); + let projection_scale = max(length(vec3( + u.view_rot[0].y, + u.view_rot[1].y, + u.view_rot[2].y, + )), 1e-12); + let view_height = 2.0 * max(abs(origin_clip.w), 1e-6) / projection_scale; + let world_size = wire_const.marker_normal_scale.w * 0.01 * view_height; + return origin_relative + axial + planar * world_size; +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut { + let corner = vertex_index % 6u; + let seg = segments[vertex_index / 6u]; + let seg_pos_a = vec3(seg.pos_a_x, seg.pos_a_y, seg.pos_a_z); + let seg_pos_a_low = vec3(seg.pos_a_low_x, seg.pos_a_low_y, seg.pos_a_low_z); + let seg_pos_b = vec3(seg.pos_b_x, seg.pos_b_y, seg.pos_b_z); + let seg_pos_b_low = vec3(seg.pos_b_low_x, seg.pos_b_low_y, seg.pos_b_low_z); + let seg_distances = vec2(seg.distance_a, seg.distance_b); + let seg_taper_ratio = unpack2x16unorm(seg.taper_ratio); + let which_end_arr = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); + let side_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let which_end = which_end_arr[corner]; + let side = side_arr[corner]; + + let rel_a = marker_relative(seg_pos_a, seg_pos_a_low, in.translation, in.translation_low); + let rel_b = marker_relative(seg_pos_b, seg_pos_b_low, in.translation, in.translation_low); + let clip_a = u.view_rot * vec4(rel_a, 1.0); + let clip_b = u.view_rot * vec4(rel_b, 1.0); + let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5; + let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5; + let segment = screen_b - screen_a; + let segment_length = length(segment); + var direction = vec2(1.0, 0.0); + if segment_length > 1e-4 { + direction = segment / segment_length; + } + let perpendicular = vec2(-direction.y, direction.x); + let clip_position = mix(clip_a, clip_b, which_end); + let half_width_a = resolve_hw( + seg_taper_ratio.x, + wire_const.world_half_width, + wire_const.half_width, + ); + let half_width_b = resolve_hw( + seg_taper_ratio.y, + wire_const.world_half_width, + wire_const.half_width, + ); + let half_width = mix(half_width_a, half_width_b, which_end); + let extension = which_end * 2.0 - 1.0; + let offset_px = perpendicular * half_width * side + + direction * half_width * extension; + let ndc_offset = offset_px / (u.viewport_size * 0.5); + let final_clip = clip_position + + vec4(ndc_offset * clip_position.w, 0.0, 0.0); + + let scale = u.linetype_scale; + var min_element = wire_const.pattern_length * scale; + let elements = array( + wire_const.pat0.x * scale, + wire_const.pat0.y * scale, + wire_const.pat0.z * scale, + wire_const.pat0.w * scale, + wire_const.pat1.x * scale, + wire_const.pat1.y * scale, + wire_const.pat1.z * scale, + wire_const.pat1.w * scale, + ); + for (var i = 0u; i < 8u; i++) { + let value = abs(elements[i]); + if value > 0.0 && value < min_element { + min_element = value; + } + } + + var out: VertexOut; + out.clip_pos = final_clip; + out.clip_pos.z -= in.depth.x * DRAW_ORDER_BIAS * out.clip_pos.w; + out.color = wire_const.color; + out.distance = mix(seg_distances.x, seg_distances.y, which_end) + + extension * half_width * u.world_per_pixel; + out.pattern_length = wire_const.pattern_length * scale; + out.pat0 = wire_const.pat0 * scale; + out.pat1 = wire_const.pat1 * scale; + out.min_elem = min_element; + out.align_end = wire_const.align_end * scale; + out.align_total = wire_const.align_total; + out.cap = vec2( + which_end * segment_length + extension * half_width, + half_width * side, + ); + out.cap_ends = vec3(segment_length, half_width_a, half_width_b); + return out; +} + +fn in_dash( + distance: f32, + pattern_length: f32, + pat0: vec4, + pat1: vec4, + align_end: f32, + align_total: f32, +) -> bool { + let elements = array( + pat0.x, pat0.y, pat0.z, pat0.w, + pat1.x, pat1.y, pat1.z, pat1.w, + ); + var count = 0u; + for (var i = 0u; i < 8u; i++) { + if elements[i] != 0.0 { + count = i + 1u; + } + } + var value: f32; + if align_total > 0.0 { + if distance <= align_end || distance >= align_total - align_end { + return true; + } + var first_dash = 0.0; + for (var i = 0u; i < count; i++) { + if elements[i] > 0.0 { + first_dash = elements[i]; + break; + } + } + value = ((distance - align_end + first_dash) % pattern_length + + pattern_length) % pattern_length; + } else { + value = ((distance % pattern_length) + pattern_length) % pattern_length; + } + var position = 0.0; + let dot_half = u.world_per_pixel * 0.75; + for (var i = 0u; i < count; i++) { + let element = elements[i]; + if element == 0.0 { + let delta = abs(value - position); + if min(delta, pattern_length - delta) <= dot_half { + return true; + } + } else if element > 0.0 { + if value >= position && value < position + element { + return true; + } + position += element; + } else { + position -= element; + } + } + return false; +} + +fn clipped_cap(cap: vec2, ends: vec3) -> bool { + if cap.x < 0.0 { + return length(cap) > ends.y; + } + if cap.x > ends.x { + return length(vec2(cap.x - ends.x, cap.y)) > ends.z; + } + return false; +} + +fn visible_fragment(in: VertexOut) -> bool { + if clipped_cap(in.cap, in.cap_ends) { + return false; + } + if in.pattern_length > 0.0 && in.min_elem >= u.world_per_pixel { + return in_dash( + in.distance, + in.pattern_length, + in.pat0, + in.pat1, + in.align_end, + in.align_total, + ); + } + return true; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + if !visible_fragment(in) { + discard; + } + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); + return vec4(in.color.rgb, alpha); +} + +@fragment +fn fs_black(in: VertexOut) -> @location(0) vec4 { + if !visible_fragment(in) { + discard; + } + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); + return vec4(0.0, 0.0, 0.0, alpha); +}