Merge branch 'HakanSeven12:main' into main

This commit is contained in:
MarcelP102 authored and GitHub committed 2026-09-05 19:39:19 +02:00
commit cd7c3102f5
100 files changed
+4321 -537

No files matched your search

+3 -2
View File
@@ -7,6 +7,7 @@
<a href="docs/readme/README.fr.md">Français</a> ·
<a href="docs/readme/README.fi.md">Suomi</a> ·
<a href="docs/readme/README.de.md">Deutsch</a> ·
<a href="docs/readme/README.el.md">Ελληνικά</a> ·
<a href="docs/readme/README.hu.md">Magyar</a> ·
<a href="docs/readme/README.it.md">Italiano</a> ·
<a href="docs/readme/README.ja.md">日本語</a> ·
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Box">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M4 10 15 4 28 10 17 16Z" fill="#DCE7F0"/>
<path d="M4 10 17 16V28L4 22Z" fill="#A9BFCE"/>
<path d="M17 16 28 10V22L17 28Z" fill="#718C9E"/>
</g>
<path d="m5 10 12 5.5 10-5.5" fill="none" stroke="#F4F8FC" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Solid chamfer">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M5 12 14 6H21L28 13V21L19 27H5Z" fill="#DCE7F0"/>
<path d="M19 19 28 13V21L19 27Z" fill="#718C9E"/>
<path d="M5 12H12L19 19V27H5Z" fill="#A9BFCE"/>
<path d="M12 12 21 6 28 13 19 19Z" fill="#6DB7ED"/>
</g>
<path d="m12 12 7 7" fill="none" stroke="#F4F8FC" stroke-width="1.2"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Cone">
<defs><linearGradient id="body"><stop stop-color="#DCE7F0"/><stop offset=".5" stop-color="#C5D5E1"/><stop offset="1" stop-color="#718C9E"/></linearGradient></defs>
<path d="M16 3 4.5 23C4.5 29 27.5 29 27.5 23Z" fill="url(#body)" stroke="#526B80" stroke-width="1.1" stroke-linejoin="round"/>
<path d="M16 4C17 12 20 21 22 26" fill="none" stroke="#526B80" stroke-opacity=".35" stroke-width="1"/>
<path d="M6 24Q14 28 23 25.5" fill="none" stroke="#F4F8FC" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 577 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Cylinder">
<defs><linearGradient id="body"><stop stop-color="#A9BFCE"/><stop offset=".4" stop-color="#DCE7F0"/><stop offset="1" stop-color="#718C9E"/></linearGradient></defs>
<g stroke="#526B80" stroke-width="1.1">
<path d="M5.5 8.5V23.5C5.5 29.5 26.5 29.5 26.5 23.5V8.5Z" fill="url(#body)"/>
<ellipse cx="16" cy="8.5" rx="10.5" ry="4.5" fill="#DCE7F0"/>
</g>
<path d="M7 10Q16 16 25 10" fill="none" stroke="#F4F8FC" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Extrude">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M4 13 13 8 23 13 14 18Z" fill="#6DB7ED"/>
<path d="M4 13 14 18V28L4 23Z" fill="#A9BFCE"/>
<path d="M14 18 23 13V23L14 28Z" fill="#718C9E"/>
</g>
<path d="m4 23 10 5 9-5" fill="none" stroke="#6DB7ED" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M26 13V3m-3 3 3-3 3 3" fill="none" stroke="#6DB7ED" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Solid fillet">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M5 12 14 6H21Q28 6 28 13V21L19 27H5Z" fill="#DCE7F0"/>
<path d="M19 19 28 13V21L19 27Z" fill="#718C9E"/>
<path d="M5 12H12Q19 12 19 19V27H5Z" fill="#A9BFCE"/>
<path d="M12 12 21 6Q28 6 28 13L19 19Q19 12 12 12Z" fill="#6DB7ED"/>
</g>
<path d="M12 12Q19 12 19 19" fill="none" stroke="#F4F8FC" stroke-width="1.2"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 32 32"
role="img"
aria-label="Intersect"
version="1.1"
id="svg8"
sodipodi:docname="intersect.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview8"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="22.53125"
inkscape:cx="15.977809"
inkscape:cy="16"
inkscape:window-width="1920"
inkscape:window-height="939"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" />
<g
stroke="#526B80"
stroke-width="1.1"
stroke-linejoin="round"
id="g7">
<path
d="M9 12 18 16.5V25.5L9 21Z"
fill="#6DB7ED"
id="path5" />
<path
d="M18 16.5 23 14V23L18 25.5Z"
fill="#1565C0"
id="path6" />
<path
d="M9 12 14 9.5 23 14 18 16.5Z"
fill="#6DB7ED"
id="path7" />
</g>
<g
fill="none"
stroke="#B4B6B9"
stroke-opacity=".55"
stroke-width="1"
stroke-linejoin="round"
id="g4"
inkscape:label="g6">
<path
d="M9 12 17 8 29 14V23L21 27 9 21Z"
id="path3" />
<path
d="m9 12 12 6 8-4m-8 4v9"
id="path4" />
</g>
<g
fill="none"
stroke="#b4b6b9"
stroke-opacity="0.55"
stroke-width="1"
stroke-linejoin="round"
id="g4-6"
inkscape:label="g5">
<path
d="m 3,12 8,-4 12,6 v 9 L 15,27 3,21 Z"
id="path1-7" />
<path
d="m 3,12 12,6 8,-4 m -8,4 v 9"
id="path2-5" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Loft">
<defs><linearGradient id="body"><stop stop-color="#DCE7F0"/><stop offset=".55" stop-color="#A9BFCE"/><stop offset="1" stop-color="#718C9E"/></linearGradient></defs>
<path d="M10 7C10 14 6 16 3 23L15 29 29 22C24 16 23 14 23 7Z" fill="url(#body)" stroke="#526B80" stroke-width="1.1" stroke-linejoin="round"/>
<path d="M3 23 15 29 29 22 17 17Z" fill="#6DB7ED" fill-opacity=".2" stroke="#6DB7ED" stroke-width="1.4" stroke-linejoin="round"/>
<path d="M16.5 10C16 17 14 19 15 29M7 16c4 4 14 5 19 0" fill="none" stroke="#526B80" stroke-opacity=".65" stroke-width="1"/>
<ellipse cx="16.5" cy="7" rx="6.5" ry="3" fill="#6DB7ED" stroke="#526B80" stroke-width="1.1"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Polysolid">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M4 10 9 7 17 11 24 7 28 9 17 16Z" fill="#DCE7F0"/>
<path d="M4 10 17 16V28L4 22Z" fill="#A9BFCE"/>
<path d="M17 16 28 9V21L17 28Z" fill="#718C9E"/>
<path d="M9 7V19L17 23V11Z" fill="#718C9E"/>
<path d="M17 11V23L24 19V7Z" fill="#A9BFCE"/>
<path d="M4 10 9 7 17 11 24 7 28 9 17 16Z" fill="#DCE7F0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 507 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Presspull">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M3 19 14 13 29 20 18 26Z" fill="#DCE7F0"/>
<path d="M3 19 18 26V30L3 23Z" fill="#A9BFCE"/>
<path d="M18 26 29 20V24L18 30Z" fill="#718C9E"/>
<path d="M9 13 16 9 23 13 16 17Z" fill="#6DB7ED"/>
<path d="M9 13 16 17V23L9 19Z" fill="#A9BFCE"/>
<path d="M16 17 23 13V19L16 23Z" fill="#718C9E"/>
</g>
<path d="M16 12V2m-3 3 3-3 3 3" fill="none" stroke="#6DB7ED" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 627 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Pyramid">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M15 3 3 22 16 28Z" fill="#DCE7F0"/>
<path d="M15 3 16 28 29 21Z" fill="#A9BFCE"/>
<path d="M15 3 29 21 24 12Z" fill="#718C9E"/>
</g>
<path d="m15 4 1 23" stroke="#F4F8FC" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Revolve">
<defs><linearGradient id="body"><stop stop-color="#718C9E"/><stop offset=".4" stop-color="#DCE7F0"/><stop offset="1" stop-color="#A9BFCE"/></linearGradient></defs>
<path d="M10 7c0 6-5 7-5 13 0 7 18 7 18 0 0-6-5-7-5-13Z" fill="url(#body)" stroke="#526B80" stroke-width="1.1"/>
<ellipse cx="14" cy="7" rx="4" ry="2" fill="#DCE7F0" stroke="#526B80" stroke-width="1.1"/>
<path d="M14 2v26" fill="none" stroke="#526B80" stroke-width="1" stroke-dasharray="2 2"/>
<path d="M25 13c7 6 2 16-9 16M20 26l-4 3 5 1" fill="none" stroke="#6DB7ED" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 7c0 6 5 7 5 13" fill="none" stroke="#6DB7ED" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Sphere">
<defs><radialGradient id="body" cx=".3" cy=".25" r=".8"><stop stop-color="#F4F8FC"/><stop offset=".4" stop-color="#DCE7F0"/><stop offset=".75" stop-color="#A9BFCE"/><stop offset="1" stop-color="#718C9E"/></radialGradient></defs>
<circle cx="16" cy="16" r="12" fill="url(#body)" stroke="#526B80" stroke-width="1.1"/>
<path d="M5 14C9 21 23 24 27 18M13 4.5C8 10 13 24 22 26" fill="none" stroke="#526B80" stroke-opacity=".5" stroke-width="1"/>
<path d="M8 11a9 9 0 0 1 6-5" fill="none" stroke="#F4F8FC" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Subtract">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M10 16 16 13V17L10 20Z" fill="#1565C0"/>
<path d="M16 13 22 16V20L16 17Z" fill="#6DB7ED"/>
<path d="M10 20 16 17 22 20 16 23Z" fill="#6DB7ED"/>
<path d="M4 13 10 16V20L16 23V27L4 21Z" fill="#6DB7ED"/>
<path d="M16 23 22 20V16L28 13V21L16 27Z" fill="#1565C0"/>
<path d="M4 13 16 7 28 13 22 16 16 13 10 16Z" fill="#6DB7ED"/>
</g>
<path d="m5 13 5 2.5 6-3 6 3 5-2.5" fill="none" stroke="#F4F8FC" stroke-width=".8" stroke-opacity=".65"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Sweep">
<defs><linearGradient id="body" x2="1" y2="1"><stop stop-color="#DCE7F0"/><stop offset=".55" stop-color="#A9BFCE"/><stop offset="1" stop-color="#718C9E"/></linearGradient></defs>
<path d="M5 24C5 10 12 5 25 5V13C17 13 13 17 13 24Z" fill="url(#body)" stroke="#526B80" stroke-width="1.1"/>
<path d="M9 24C9 12 15 9 25 9" fill="none" stroke="#F4F8FC" stroke-width="1.2"/>
<ellipse cx="25" cy="9" rx="2.5" ry="4" fill="#718C9E" stroke="#526B80" stroke-width="1.1"/>
<ellipse cx="9" cy="24" rx="4" ry="2.7" fill="#6DB7ED" stroke="#526B80" stroke-width="1.1"/>
<path d="M17 26c0-7 4-9 11-9m-3-3 3 3-3 3" fill="none" stroke="#6DB7ED" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 805 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Torus">
<defs><linearGradient id="body" x2="0" y2="1"><stop stop-color="#F4F8FC"/><stop offset=".45" stop-color="#C5D5E1"/><stop offset="1" stop-color="#718C9E"/></linearGradient></defs>
<path d="M29 17c0 6-6 10-13 10S3 23 3 17 9 7 16 7s13 4 13 10ZM22 15c0-2-3-3-6-3s-6 1-6 3 3 3 6 3 6-1 6-3Z" fill="url(#body)" fill-rule="evenodd" stroke="#526B80" stroke-width="1.1"/>
<path d="M10 15c1-4 11-4 12 0M4 16c2 10 22 10 24 0" fill="none" stroke="#526B80" stroke-width="1" stroke-opacity=".55"/>
<path d="M6 13c2-3 5-4 8-4" fill="none" stroke="#F4F8FC" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 690 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Union">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M3 12 13 17V26L3 21Z" fill="#6DB7ED"/>
<path d="M13 17 17 14.5V23.5L13 26Z" fill="#1565C0"/>
<path d="M17 14.5 23 17.5V26.5L17 23.5Z" fill="#6DB7ED"/>
<path d="M23 17.5 29 13.5V22.5L23 26.5Z" fill="#1565C0"/>
<path d="M3 12 9 8 15 11 19 8.5 29 13.5 23 17.5 17 14.5 13 17Z" fill="#6DB7ED"/>
</g>
<path d="m4 12 9 4.5 4-2.5 6 3 5-3.5" fill="none" stroke="#F4F8FC" stroke-width=".8" stroke-opacity=".65"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Wedge">
<g stroke="#526B80" stroke-width="1.1" stroke-linejoin="round">
<path d="M4 11 14 5 28 21 18 27Z" fill="#DCE7F0"/>
<path d="M4 11 18 27 4 23Z" fill="#A9BFCE"/>
</g>
<path d="m5 11 13 15" stroke="#F4F8FC" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

+62 -4
View File
@@ -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
```
+182
View File
@@ -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()
+33 -2
View File
@@ -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",
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="شعار Open CAD Studio"></p>
@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## اللغات
يمكن لـ Open CAD Studio اتباع لغة النظام أو استخدام إحدى لغات الواجهة العشرين التالية:
يمكن لـ Open CAD Studio اتباع لغة النظام أو استخدام إحدى لغات الواجهة التالية البالغ عددها 21 لغة:
> العربية · البرتغالية البرازيلية · البلغارية · التشيكية · الهولندية · الإنجليزية · الفنلندية · الفرنسية · الألمانية · الهندية · المجرية · الإيطالية · اليابانية · الكورية · البولندية · الروسية · الصينية المبسطة · الإسبانية · الصينية التقليدية · التركية
> العربية · البرتغالية البرازيلية · البلغارية · التشيكية · الهولندية · الإنجليزية · الفنلندية · الفرنسية · الألمانية · اليونانية · الهندية · المجرية · الإيطالية · اليابانية · الكورية · البولندية · الروسية · الصينية المبسطة · الإسبانية · الصينية التقليدية · التركية
غيّر اللغة من إعدادات التطبيق. عند اختيار **النظام**، يستخدم إصدار المتصفح أيضاً الإعدادات المحلية المفضلة للمتصفح.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Лого на Open CAD Studio"></p>
@@ -93,9 +93,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## Езици
Open CAD Studio може да следва системния език или да използва един от следните 20 езика на интерфейса:
Open CAD Studio може да следва системния език или да използва един от следните 21 езика на интерфейса:
> Арабски · Бразилски португалски · Български · Чешки · Нидерландски · Английски · Фински · Френски · Немски · Хинди · Унгарски · Италиански · Японски · Корейски · Полски · Руски · Опростен китайски · Испански · Традиционен китайски · Турски
> Арабски · Бразилски португалски · Български · Чешки · Нидерландски · Английски · Фински · Френски · Немски · Гръцки · Хинди · Унгарски · Италиански · Японски · Корейски · Полски · Руски · Опростен китайски · Испански · Традиционен китайски · Турски
Променете езика от настройките на приложението. Когато е избрано **Системен език**, браузърната версия също използва предпочитания локал на браузъра.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Logo Open CAD Studio"></p>
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open-CAD-Studio-Logo"></p>
@@ -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.
+210
View File
@@ -0,0 +1,210 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Λογότυπο του Open CAD Studio"></p>
<h1 align="center">Open CAD Studio</h1>
<p align="center">Εφαρμογή ανοιχτού κώδικα για δισδιάστατη σχεδίαση και τρισδιάστατη μοντελοποίηση, για υπολογιστές και τον ιστό, γραμμένη σε Rust.</p>
<p align="center">
<a href="https://github.com/HakanSeven12/OpenCADStudio/releases/latest"><img alt="Τελευταία έκδοση" src="https://img.shields.io/github/v/release/HakanSeven12/OpenCADStudio"></a>
<a href="https://github.com/HakanSeven12/OpenCADStudio/releases"><img alt="Λήψεις εκδόσεων" src="https://img.shields.io/github/downloads/HakanSeven12/OpenCADStudio/total"></a>
<a href="https://github.com/HakanSeven12/OpenCADStudio/stargazers"><img alt="Αστέρια στο GitHub" src="https://img.shields.io/github/stars/HakanSeven12/OpenCADStudio"></a>
<a href="../../LICENSE"><img alt="Άδεια GPL-3.0" src="https://img.shields.io/github/license/HakanSeven12/OpenCADStudio"></a>
</p>
<p align="center">
<a href="https://www.opencadstudio.com"><strong>Άνοιγμα της διαδικτυακής εφαρμογής</strong></a> ·
<a href="https://github.com/HakanSeven12/OpenCADStudio/releases/latest"><strong>Λήψη της εφαρμογής για υπολογιστές</strong></a> ·
<a href="https://github.com/HakanSeven12/OpenCADStudio/discussions"><strong>Συμμετοχή στη συζήτηση</strong></a>
</p>
<p align="center"><img src="../../site/workspace.png" alt="Χώρος εργασίας του Open CAD Studio" width="100%"></p>
## Επισκόπηση
Το Open CAD Studio είναι μια εφαρμογή για τεχνική σχεδίαση, διάταξη φύλλων και μοντελοποίηση στερεών, η οποία λειτουργεί σε διαφορετικές πλατφόρμες. Διαβάζει και γράφει απευθείας σχέδια DWG και DXF, με κοινό πυρήνα επεξεργασίας στις εκδόσεις για υπολογιστές και προγράμματα περιήγησης.
Το έργο βρίσκεται υπό ενεργή ανάπτυξη. Διατηρείτε αντίγραφα ασφαλείας των σημαντικών σχεδίων σας και αναφέρετε προβλήματα που μπορούν να αναπαραχθούν μέσω των [GitHub Issues](https://github.com/HakanSeven12/OpenCADStudio/issues).
## Κύρια χαρακτηριστικά
- **Άμεση επεξεργασία σχεδίων** — άνοιγμα, επεξεργασία, ανάκτηση και αποθήκευση αρχείων DWG και DXF χωρίς υπηρεσία μετατροπής.
- **Ακριβής δισδιάστατη σχεδίαση** — γραμμές, πολυγραμμές, καμπύλες, splines, διαγραμμίσεις, έλξεις αντικειμένων, παρακολούθηση, στρώσεις, μπλοκ και εξωτερικές αναφορές.
- **Εργαλεία τεκμηρίωσης** — κείμενο, διαστάσεις, γραμμές υπόδειξης, ανοχές, πίνακες, χώρος μοντέλου, χώρος χαρτιού, παράθυρα προβολής και στυλ εκτύπωσης.
- **Τρισδιάστατη μοντελοποίηση με γεωμετρικό πυρήνα** — βασικά στερεά, εξώθηση, περιστροφή, σάρωση, loft, πράξεις Boolean και ψηφίδωση οντοτήτων ACIS.
- **Απόδοση μέσω GPU** — επιταχυνόμενες δισδιάστατες και τρισδιάστατες προβολές μέσω του `wgpu`, με ορθογραφικές και προοπτικές κάμερες.
- **Επεκτάσιμες ροές εργασίας** — εγγενή πρόσθετα, δέσμες εντολών, μετατροπή χωρίς γραφικό περιβάλλον και API αυτοματισμού JSON με ένα αντικείμενο ανά γραμμή.
<p align="center"><img src="../../site/modeling.png" alt="Τρισδιάστατο μοντέλο στο Open CAD Studio" width="100%"></p>
## Μορφές αρχείων και εργασίες
| Μορφή ή εργασία | Υποστήριξη |
| --- | --- |
| 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).
## Ανάπτυξη του έργου
### Αστέρια
<a href="https://github.com/HakanSeven12/OpenCADStudio/stargazers">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.opencadstudio.com/star-history-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://www.opencadstudio.com/star-history-light.svg">
<img alt="Ιστορικό αστεριών του Open CAD Studio" src="https://www.opencadstudio.com/star-history-light.svg">
</picture>
</a>
### Λήψεις εκδόσεων
<a href="https://github.com/HakanSeven12/OpenCADStudio/releases">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.opencadstudio.com/download-history-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://www.opencadstudio.com/download-history-light.svg">
<img alt="Ιστορικό λήψεων εκδόσεων του Open CAD Studio" src="https://www.opencadstudio.com/download-history-light.svg">
</picture>
</a>
## Υποστήριξη του έργου
Αν το Open CAD Studio σας βοηθά στην εργασία σας, υποστηρίξτε τη συνεχή ανάπτυξή του μέσω του [GitHub Sponsors](https://github.com/sponsors/HakanSeven12) ή του [Patreon](https://www.patreon.com/HakanSeven12).
## Άδεια χρήσης
Το Open CAD Studio διανέμεται υπό τη [Γενική Άδεια Δημόσιας Χρήσης GNU v3.0](../../LICENSE).
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Logotipo de Open CAD Studio"></p>
@@ -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**.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studion logo"></p>
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Logo Open CAD Studio"></p>
@@ -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é.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studio लोगो"></p>
@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## भाषाएँ
Open CAD Studio सिस्टम भाषा का अनुसरण कर सकता है या इन 20 इंटरफ़ेस भाषाओं में से किसी एक का उपयोग कर सकता है:
Open CAD Studio सिस्टम भाषा का अनुसरण कर सकता है या इन 21 इंटरफ़ेस भाषाओं में से किसी एक का उपयोग कर सकता है:
> अरबी · ब्राज़ीलियाई पुर्तगाली · बुल्गारियाई · चेक · डच · अंग्रेज़ी · फ़िनिश · फ़्रेंच · जर्मन · हिन्दी · हंगेरियन · इतालवी · जापानी · कोरियाई · पोलिश · रूसी · सरलीकृत चीनी · स्पैनिश · पारंपरिक चीनी · तुर्की
> अरबी · ब्राज़ीलियाई पुर्तगाली · बुल्गारियाई · चेक · डच · अंग्रेज़ी · फ़िनिश · फ़्रेंच · जर्मन · यूनानी · हिन्दी · हंगेरियन · इतालवी · जापानी · कोरियाई · पोलिश · रूसी · सरलीकृत चीनी · स्पैनिश · पारंपरिक चीनी · तुर्की
अनुप्रयोग सेटिंग में भाषा बदलें। **सिस्टम** चुने जाने पर ब्राउज़र संस्करण भी ब्राउज़र की पसंदीदा लोकेल का उपयोग करता है।
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studio logó"></p>
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Logo di Open CAD Studio"></p>
@@ -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**.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studio ロゴ"></p>
@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## 言語
Open CAD Studio はシステム言語に従うか、次の 20 のインターフェース言語を使用できます。
Open CAD Studio はシステム言語に従うか、次の 21 のインターフェース言語を使用できます。
> アラビア語 · ブラジルポルトガル語 · ブルガリア語 · チェコ語 · オランダ語 · 英語 · フィンランド語 · フランス語 · ドイツ語 · ヒンディー語 · ハンガリー語 · イタリア語 · 日本語 · 韓国語 · ポーランド語 · ロシア語 · 簡体字中国語 · スペイン語 · 繁体字中国語 · トルコ語
> アラビア語 · ブラジルポルトガル語 · ブルガリア語 · チェコ語 · オランダ語 · 英語 · フィンランド語 · フランス語 · ドイツ語 · ギリシャ語 · ヒンディー語 · ハンガリー語 · イタリア語 · 日本語 · 韓国語 · ポーランド語 · ロシア語 · 簡体字中国語 · スペイン語 · 繁体字中国語 · トルコ語
言語はアプリケーション設定から変更できます。**システム** を選ぶと、ブラウザ版もブラウザの優先ロケールを使用します。
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studio 로고"></p>
@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## 언어
Open CAD Studio는 시스템 언어를 따르거나 다음 19개 인터페이스 언어 중 하나를 사용할 수 있습니다.
Open CAD Studio는 시스템 언어를 따르거나 다음 21개 인터페이스 언어 중 하나를 사용할 수 있습니다.
> 아랍어 · 브라질 포르투갈어 · 불가리아어 · 체코어 · 네덜란드어 · 영어 · 핀란드어 · 프랑스어 · 독일어 · 힌디어 · 헝가리어 · 이탈리아어 · 일본어 · 한국어 · 폴란드어 · 러시아어 · 중국어 간체 · 스페인어 · 중국어 번체 · 터키어
> 아랍어 · 브라질 포르투갈어 · 불가리아어 · 체코어 · 네덜란드어 · 영어 · 핀란드어 · 프랑스어 · 독일어 · 그리스어 · 힌디어 · 헝가리어 · 이탈리아어 · 일본어 · 한국어 · 폴란드어 · 러시아어 · 중국어 간체 · 스페인어 · 중국어 번체 · 터키어
애플리케이션 설정에서 언어를 변경할 수 있습니다. **시스템**을 선택하면 브라우저 버전도 브라우저의 기본 로캘을 사용합니다.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Logo van Open CAD Studio"></p>
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Logo Open CAD Studio"></p>
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Logotipo do Open CAD Studio"></p>
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Логотип Open CAD Studio"></p>
@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## Языки
Open CAD Studio может следовать языку системы или использовать один из 20 языков интерфейса:
Open CAD Studio может следовать языку системы или использовать любой из следующих языков интерфейса (всего 21):
> Арабский · Бразильский португальский · Болгарский · Чешский · Нидерландский · Английский · Финский · Французский · Немецкий · Хинди · Венгерский · Итальянский · Японский · Корейский · Польский · Русский · Упрощённый китайский · Испанский · Традиционный китайский · Турецкий
> Арабский · Бразильский португальский · Болгарский · Чешский · Нидерландский · Английский · Финский · Французский · Немецкий · Греческий · Хинди · Венгерский · Итальянский · Японский · Корейский · Польский · Русский · Упрощённый китайский · Испанский · Традиционный китайский · Турецкий
Язык меняется в настройках приложения. При выборе **Система** версия для браузера также использует предпочтительную локаль браузера.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studio logosu"></p>
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studio 标志"></p>
@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## 语言
Open CAD Studio 可以跟随系统语言,也可以使用以下 20 种界面语言:
Open CAD Studio 可以跟随系统语言,也可以使用以下 21 种界面语言:
> 阿拉伯语 · 巴西葡萄牙语 · 保加利亚语 · 捷克语 · 荷兰语 · 英语 · 芬兰语 · 法语 · 德语 · 印地语 · 匈牙利语 · 意大利语 · 日语 · 韩语 · 波兰语 · 俄语 · 简体中文 · 西班牙语 · 繁体中文 · 土耳其语
> 阿拉伯语 · 巴西葡萄牙语 · 保加利亚语 · 捷克语 · 荷兰语 · 英语 · 芬兰语 · 法语 · 德语 · 希腊语 · 印地语 · 匈牙利语 · 意大利语 · 日语 · 韩语 · 波兰语 · 俄语 · 简体中文 · 西班牙语 · 繁体中文 · 土耳其语
可在应用设置中更改语言。选择**系统**时,浏览器版也会使用浏览器的首选区域设置。
+3 -3
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
<a href="../../README.md">English</a> · <a href="README.bg.md">Български</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.cs.md">Čeština</a> · <a href="README.nl.md">Nederlands</a> · <a href="README.fr.md">Français</a> · <a href="README.fi.md">Suomi</a> · <a href="README.de.md">Deutsch</a> · <a href="README.el.md">Ελληνικά</a> · <a href="README.hu.md">Magyar</a> · <a href="README.it.md">Italiano</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.pl.md">Polski</a> · <a href="README.ru.md">Русский</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.es.md">Español</a> · <a href="README.zh-TW.md">繁體中文</a> · <a href="README.tr.md">Türkçe</a> · <a href="README.hi.md">हिन्दी</a> · <a href="README.ar.md">العربية</a>
</p>
<p align="center"><img src="../../assets/logo.svg" width="112" alt="Open CAD Studio 標誌"></p>
@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage
## 語言
Open CAD Studio 可以跟隨系統語言,也可以使用以下 20 種介面語言:
Open CAD Studio 可以跟隨系統語言,也可以使用以下 21 種介面語言:
> 阿拉伯文 · 巴西葡萄牙文 · 保加利亞文 · 捷克文 · 荷蘭文 · 英文 · 芬蘭文 · 法文 · 德文 · 北印度文 · 匈牙利文 · 義大利文 · 日文 · 韓文 · 波蘭文 · 俄文 · 簡體中文 · 西班牙文 · 繁體中文 · 土耳其文
> 阿拉伯文 · 巴西葡萄牙文 · 保加利亞文 · 捷克文 · 荷蘭文 · 英文 · 芬蘭文 · 法文 · 德文 · 希臘文 · 北印度文 · 匈牙利文 · 義大利文 · 日文 · 韓文 · 波蘭文 · 俄文 · 簡體中文 · 西班牙文 · 繁體中文 · 土耳其文
可在應用程式設定中變更語言。選擇**系統**時,瀏覽器版也會使用瀏覽器的偏好地區設定。
+1
View File
@@ -167,6 +167,7 @@ clipboard =
.paste-to-original-coordinates = Επικόλληση στις αρχικές συντεταγμένες
viewport =
.preparing-layout = Προετοιμασία διάταξης…
.split-vertical = Κατακόρυφος διαχωρισμός θύρας προβολής
.split-horizontal = Οριζόντιος διαχωρισμός θύρας προβολής
.move = Μετακίνηση θύρας προβολής
+1
View File
@@ -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
+1
View File
@@ -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şı
+210 -27
View File
@@ -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<acadrust::Handle> {
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>>) -> 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::<Vec<_>>()
});
}
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<std::collections::HashSet<u64>> = 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<Value> = 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();
+379 -20
View File
@@ -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<glam::DVec3, Value> {
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::<Vec<_>>(),
"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::<Vec<_>>(),
"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<String> = 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<Value, String> {
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"}));
+8
View File
@@ -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<OpenProgress>,
/// 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<crate::io::recovery::RecoveryReport>,
@@ -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(),
+5
View File
@@ -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
+3
View File
@@ -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={}",
+109 -14
View File
@@ -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<Vec<(&'static str, f64)>> =
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<Element<'_, Message>> = if is_paper || tab.is_start {
mark("model_input");
let model_input_layer: Option<Element<'_, Message>> = 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<crate::ui::overlay::GridParams> = 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<crate::ui::overlay::GridParams> = 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,
+7
View File
@@ -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(),
+2
View File
@@ -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(),
+5
View File
@@ -2068,6 +2068,7 @@ impl MultiLeaderTess for MultiLeader {
// WireModels so the renderer respects per-piece coloring.
let mut wires: Vec<WireModel> = 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(),
+1
View File
@@ -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(),
+823 -26
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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(),
+1
View File
@@ -1132,6 +1132,7 @@ fn nan() -> DVec3 {
fn preview_wire(points: Vec<DVec3>) -> WireModel {
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -63,6 +63,7 @@ impl DataLinkPlaceCommand {
);
}
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
@@ -442,6 +442,7 @@ fn dvec(point: Vector3) -> DVec3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -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(),
+1
View File
@@ -467,6 +467,7 @@ fn ocs_point(point: [f64; 2], elevation: f64, normal: Vector3) -> DVec3 {
fn preview_wire(points: Vec<DVec3>) -> WireModel {
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -270,6 +270,7 @@ fn is_x_type(feature: DVec3, leader: DVec3) -> bool {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -277,6 +277,7 @@ fn dvec(point: Vector3) -> DVec3 {
fn preview_wire(points: Vec<Vec3>) -> WireModel {
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -212,6 +212,7 @@ impl TableCommand {
);
}
WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -81,6 +81,7 @@ impl CadCommand for ToleranceCommand {
}));
}
Some(WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+2
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+18 -18
View File
@@ -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 {
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+1
View File
@@ -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(),
+136
View File
@@ -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",
);
}
}
+4
View File
@@ -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(),
+19
View File
@@ -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(),
+326 -132
View File
@@ -1495,6 +1495,9 @@ pub enum ViewportRefreshScope {
All,
}
/// Entity membership in document order, keyed by geometry epoch.
type BlockMembers = (u64, HashMap<Handle, Vec<Handle>>);
pub struct Scene {
pub camera: Rc<RefCell<Camera>>,
/// 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<Option<(u64, HashMap<Handle, Handle>)>>,
/// Candidate handles per block, in document order and keyed by geometry epoch.
block_members_cache: RefCell<Option<BlockMembers>>,
/// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index.
unindexable_cache: RefCell<Option<(u64, Vec<Handle>)>>,
/// 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<u64>,
/// 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<HashMap<Handle, Arc<Vec<WireModel>>>>,
/// Guard hash for `resident_tess_memo` (anno-scale / bg only).
/// Annotation, background, viewport and glyph-atlas signature.
resident_tess_guard: std::cell::Cell<u64>,
/// 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<WireModel> = 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<Handle>)> {
{
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<Handle> = 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<Handle, Vec<Handle>> = 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<WireModel> {
let bg = if self.current_layout == "Model" {
/// Resolve the active layout and annotation context for tessellation.
fn tessellation_context(
&self,
) -> ([f32; 4], f32, Option<Handle>, Arc<cache::block_cache::BlockCache>, 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<WireModel> {
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<Handle> {
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<Handle> {
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());
}
}
}
+39
View File
@@ -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<Box<BgAdaptInputs>>;
/// 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<PointMarker>,
/// 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],
+29
View File
@@ -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)
+1
View File
@@ -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(),
+154 -10
View File
@@ -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 = <MultiPipeline as iced::widget::shader::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 = <MultiPipeline as iced::widget::shader::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"
);
}
+13
View File
@@ -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, .. } => {
+471 -114
View File
@@ -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<wgpu::BindGroupLayout>,
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<Vec<WireGpu>>,
pub(crate) gpu_block_wires: std::sync::Arc<Vec<BlockWireGpu>>,
/// 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<u64, [f32; 2]>,
block_geometry: &mut wire_gpu::BlockGeometryCache,
) -> Vec<BlockWireGpu> {
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<u64, [f32; 2]>,
block_geometry: &mut wire_gpu::BlockGeometryCache,
) -> (
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<Vec<BlockWireGpu>>,
@@ -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<u32>) {
// 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<u32>) -> 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<u64, usize>,
slot_last_used: Vec<u64>,
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<u64> = 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(),
}
}
+5
View File
@@ -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 {
+12
View File
@@ -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,
+277 -84
View File
@@ -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<wgpu::Buffer>,
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<wgpu::BindGroup>,
}
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<Vec<(wgpu::Buffer, u32)>>,
std::sync::Arc<wgpu::BindGroup>,
),
BlockGeometryKey,
std::sync::Arc<Vec<BlockGeometryChunk>>,
>;
/// 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<wgpu::Buffer>,
pub vertex_count: u32,
pub bind_group: std::sync::Arc<wgpu::BindGroup>,
/// 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<u64, [f32; 2]>,
color_override: Option<[f32; 4]>,
const_bgl: &wgpu::BindGroupLayout,
target: BlockWireTarget<'_>,
mut cache: Option<&mut BlockGeometryCache>,
) -> Vec<Self> {
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<BlockGeometryKey> =
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<BlockWireVertex> = 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::<BlockWireVertex>(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<BlockGeometryChunk> = 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::<BlockWireVertex>(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::<BlockWireVertex>(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::<BlockWireVertex>()
* 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::<BlockWireVertex>() * 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<BlockWireGpu>,
device: &wgpu::Device,
queue: &wgpu::Queue,
vertex_chunks: &[(wgpu::Buffer, u32)],
chunks: &[BlockGeometryChunk],
instances: &[BlockWireInstance],
mesh_edge: bool,
const_bind_group: &std::sync::Arc<wgpu::BindGroup>,
) {
let max_instances = super::gpu_budget::max_elements::<BlockWireInstance>(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::<BlockWireVertex>(),
"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<String> = m.entry_points.iter().map(|e| e.name.clone()).collect();
n.sort();
n
};
assert_eq!(names(&packed), names(&storage));
}
}
+2
View File
@@ -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(),
+138 -27
View File
@@ -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<u64> = 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<u32>,
) {
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<Option<crate::scene::pipeline::GpuLiveBytes>> = 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 {
+323
View File
@@ -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<f32>`, 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<f32>,
world_per_pixel: f32,
lwdisplay_enable: f32,
flat_shade: f32,
transparency_enable: f32,
linetype_scale: f32,
lineweight_scale: f32,
view_rot: mat4x4<f32>,
eye_high: vec3<f32>,
_pad_eh: f32,
eye_low: vec3<f32>,
_pad_el: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
struct WireConst {
color: vec4<f32>,
pat0: vec4<f32>,
pat1: vec4<f32>,
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<f32>,
marker_origin_low: vec4<f32>,
marker_normal_scale: vec4<f32>,
}
@group(1) @binding(0) var<uniform> 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<storage, read> segments: array<Segment>;
struct VertexIn {
@location(6) translation: vec3<f32>,
@location(7) translation_low: vec3<f32>,
@location(8) depth: vec2<f32>,
}
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<f32>,
@location(0) color: vec4<f32>,
@location(1) distance: f32,
@location(2) pattern_length: f32,
@location(3) pat0: vec4<f32>,
@location(4) pat1: vec4<f32>,
@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<f32>,
@location(9) @interpolate(flat) cap_ends: vec3<f32>,
}
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<f32>,
position_low: vec3<f32>,
translation: vec3<f32>,
translation_low: vec3<f32>,
) -> vec3<f32> {
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<f32>(origin_relative, 1.0);
let projection_scale = max(length(vec3<f32>(
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<f32>(seg.pos_a_x, seg.pos_a_y, seg.pos_a_z);
let seg_pos_a_low = vec3<f32>(seg.pos_a_low_x, seg.pos_a_low_y, seg.pos_a_low_z);
let seg_pos_b = vec3<f32>(seg.pos_b_x, seg.pos_b_y, seg.pos_b_z);
let seg_pos_b_low = vec3<f32>(seg.pos_b_low_x, seg.pos_b_low_y, seg.pos_b_low_z);
let seg_distances = vec2<f32>(seg.distance_a, seg.distance_b);
let seg_taper_ratio = unpack2x16unorm(seg.taper_ratio);
let which_end_arr = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
let side_arr = array<f32, 6>(-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<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(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<f32>(1.0, 0.0);
if segment_length > 1e-4 {
direction = segment / segment_length;
}
let perpendicular = vec2<f32>(-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<f32>(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<f32, 8>(
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<f32>(
which_end * segment_length + extension * half_width,
half_width * side,
);
out.cap_ends = vec3<f32>(segment_length, half_width_a, half_width_b);
return out;
}
fn in_dash(
distance: f32,
pattern_length: f32,
pat0: vec4<f32>,
pat1: vec4<f32>,
align_end: f32,
align_total: f32,
) -> bool {
let elements = array<f32, 8>(
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<f32>, ends: vec3<f32>) -> bool {
if cap.x < 0.0 {
return length(cap) > ends.y;
}
if cap.x > ends.x {
return length(vec2<f32>(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<f32> {
if !visible_fragment(in) {
discard;
}
let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5);
return vec4<f32>(in.color.rgb, alpha);
}
@fragment
fn fs_black(in: VertexOut) -> @location(0) vec4<f32> {
if !visible_fragment(in) {
discard;
}
let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5);
return vec4<f32>(0.0, 0.0, 0.0, alpha);
}