mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa1fe28ddf | |||
| 8f1b456275 | |||
| b596bfc730 | |||
| d11d268597 | |||
| 4b0ba62c69 | |||
| 313d2bdb41 | |||
| 079f6e61e6 | |||
| 4a781b8cd8 | |||
| 5b0eb08473 | |||
| 87206f20c8 | |||
| 5a24d4095a | |||
| 9df8837d43 | |||
| 409ab1ed45 | |||
| 5e03fe2f4b | |||
| 37a586dd61 | |||
| a79f40a711 | |||
| b5728f284b | |||
| 5965439a15 | |||
| 586a358ed4 | |||
| 7770f8982d | |||
| 72ad6383ef | |||
| 0537623a75 | |||
| 65d288227e | |||
| fedb3bcdb1 | |||
| 097b8b7550 | |||
| af7ca3a8a2 | |||
| 27ddb725c7 | |||
| a06bdf5cfc | |||
| 33fdcdb3a0 | |||
| c07ab5f8f9 | |||
| fea32f8aac | |||
| 4927214976 | |||
| b621819c0c | |||
| ec552a5300 | |||
| e0cc740cc3 | |||
| 5de48e916d | |||
| 80af7bbe3e | |||
| 191e983423 | |||
| 2b9445b049 | |||
| 7ddf48ca16 | |||
| d2f6848377 | |||
| 0b6d6fecd8 | |||
| fa691707c2 | |||
| b9a55c74e2 | |||
| 2bb9c81069 | |||
| cedb51b8ea | |||
| 66d13059dd | |||
| 800ea40c0d | |||
| 20442eecd3 | |||
| 22d1cd7032 | |||
| 6a0dc18655 | |||
| e006dbf1af | |||
| 8241a750fa | |||
| 41779cf7bb | |||
| b01b9689c8 | |||
| 4426da6f5d | |||
| 5ead82e627 | |||
| c29b0d2f13 | |||
| 480fd8d517 | |||
| 4c09fa5355 |
@@ -610,6 +610,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/sketch-recent-snap.json | SolidCraft contributors | Original preference messages for the recent centre and midpoint snap | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
Generated
+17
-17
@@ -4210,7 +4210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4246,11 +4246,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4262,7 +4262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4271,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4309,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4318,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4338,7 +4338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4362,7 +4362,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4382,7 +4382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4391,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4405,7 +4405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
@@ -4418,14 +4418,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4455,7 +4455,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5998,7 +5998,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.27.0"
|
||||
version = "0.27.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -720,11 +720,8 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
|
||||
}
|
||||
}
|
||||
// How a headless session treats calls that break a command's parameter contract.
|
||||
// How calls that break a command's parameter contract are treated, headless or bridged.
|
||||
let param_check = parse_param_check("mcp", args)?;
|
||||
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
|
||||
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
|
||||
}
|
||||
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => {
|
||||
@@ -732,7 +729,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
|
||||
};
|
||||
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
|
||||
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
|
||||
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
|
||||
if let Err(e) = remote.try_connect() {
|
||||
eprintln!(
|
||||
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
|
||||
@@ -752,7 +749,8 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
let auto = !args.iter().any(|a| a == "--headless");
|
||||
if auto && let Some(port) = discover_app() {
|
||||
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
|
||||
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
|
||||
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
|
||||
return serve_mcp(Box::new(remote), scope);
|
||||
}
|
||||
let mut headless = solidcraft_mcp::Headless::default();
|
||||
headless.session.param_check = param_check;
|
||||
|
||||
@@ -1979,6 +1979,8 @@
|
||||
"prefs.sketch_exit_view.previous": "العودة إلى العرض السابق",
|
||||
"prefs.sketch_exit_view.unless_rotated": "العودة ما لم يُدوَّر العرض",
|
||||
"prefs.sketch_exit_view_note": "أين ينتقل العرض بعد إنهاء الرسم أو الإلغاء أو الضغط على Esc مرتين.",
|
||||
"prefs.sketch_recent_snap": "الالتقاط التلقائي إلى المراكز ونقاط المنتصف الأخيرة",
|
||||
"prefs.sketch_recent_snap_note": "أثناء الرسم، يلتقط المؤشر الموضع الذي يتقاطع فيه دليلا آخر مركزي دائرتين أو نقطتي منتصف حافتين مستقيمتين. إيقاف: تبقى الأدلة للعرض فقط ولا يتحرك المؤشر.",
|
||||
"prefs.sketch_selection": "الرسم والتحديد",
|
||||
"prefs.sketch_selection_heading": "الرسم والتحديد",
|
||||
"prefs.sketch_selection_note": "كيف تلتقط الرسومات وكيف تحدد النقرات.",
|
||||
|
||||
@@ -1881,6 +1881,8 @@
|
||||
"prefs.sketch_align_points_note": "Beim Zeichnen richtet sich ein Punkt zugleich waagerecht an einem Skizzenpunkt und senkrecht an einem anderen aus, mit einer gepunkteten Hilfslinie; der Klick behält beide Ausrichtungen. Aus: nur am vorherigen Punkt, jeweils eine Achse.",
|
||||
"prefs.sketch_conflict_colour": "Widersprüchliche Geometrie in der Konfliktfarbe",
|
||||
"prefs.sketch_conflict_colour_note": "Solange die Bedingungen einer Skizze im Konflikt stehen, werden auch die Kurven und Punkte, die eine nicht erfüllte Bedingung hält, in der Konfliktfarbe gezeichnet, nicht nur ihr Symbol oder ihre Bemaßung. Aus: Sie behalten ihre üblichen Farben.",
|
||||
"prefs.sketch_recent_snap": "Automatisch an zuletzt verwendete Mittelpunkte fangen",
|
||||
"prefs.sketch_recent_snap_note": "Fängt den Zeiger beim Zeichnen dort ein, wo sich die Hilfslinien der beiden zuletzt verwendeten Kreismittelpunkte oder Kantenmitten kreuzen. Aus: die Hilfslinien bleiben sichtbar und der Zeiger bewegt sich nicht.",
|
||||
"prefs.sketch_state_icons": "Skizzensymbole in der Farbe ihres Zustands",
|
||||
"prefs.sketch_state_icons_note": "Im Verlauf und im Browser nimmt das Symbol einer Skizze die Farbe ihres Zustands an (unterbestimmt, vollständig bestimmt oder im Konflikt), und ihr Tooltip nennt den Zustand. Aus: Alle Skizzen haben dasselbe Symbol.",
|
||||
"prefs.sketch_tab_always": "Registerkarte Skizze immer zeigen",
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"prefs.megabytes": "MB",
|
||||
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour",
|
||||
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.",
|
||||
"prefs.sketch_recent_snap": "Auto snap to recent centres and midpoints",
|
||||
"prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centres or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.",
|
||||
"prefs.sketch_state_icons": "Sketch icons in the colour of their state",
|
||||
"prefs.sketch_state_icons_note": "In History and the browser, a sketch's icon takes the colour of its state (under constrained, fully constrained or conflicting) and its tooltip names the state. Off: every sketch has the same icon.",
|
||||
"prefs.sketch_tracking_guides": "Centre and midpoint alignment guides",
|
||||
|
||||
@@ -1979,6 +1979,8 @@
|
||||
"prefs.sketch_exit_view.previous": "Return to the previous view",
|
||||
"prefs.sketch_exit_view.unless_rotated": "Return unless rotated",
|
||||
"prefs.sketch_exit_view_note": "Where the view goes after Finish Sketch, Cancel or a double Esc.",
|
||||
"prefs.sketch_recent_snap": "Auto snap to recent centers and midpoints",
|
||||
"prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centers or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.",
|
||||
"prefs.sketch_selection": "Sketch & selection",
|
||||
"prefs.sketch_selection_heading": "Sketch and selection",
|
||||
"prefs.sketch_selection_note": "How sketches snap and how clicks select.",
|
||||
|
||||
@@ -1979,6 +1979,8 @@
|
||||
"prefs.sketch_exit_view.previous": "חזרה לתצוגה הקודמת",
|
||||
"prefs.sketch_exit_view.unless_rotated": "חזרה אלא אם התצוגה סובבה",
|
||||
"prefs.sketch_exit_view_note": "לאן התצוגה עוברת אחרי סיום סקיצה, ביטול או Esc כפול.",
|
||||
"prefs.sketch_recent_snap": "הצמדה אוטומטית למרכזים ולנקודות אמצע אחרונים",
|
||||
"prefs.sketch_recent_snap_note": "בזמן שרטוט, הסמן נצמד למקום שבו נחתכים קווי העזר של שני מרכזי המעגלים או נקודות האמצע של קצוות ישרים האחרונים. כבוי: קווי העזר נשארים לתצוגה בלבד והסמן אינו זז.",
|
||||
"prefs.sketch_selection": "סקיצה ובחירה",
|
||||
"prefs.sketch_selection_heading": "סקיצה ובחירה",
|
||||
"prefs.sketch_selection_note": "איך סקיצות נצמדות ואיך לחיצות בוחרות.",
|
||||
|
||||
@@ -1979,6 +1979,8 @@
|
||||
"prefs.sketch_exit_view.previous": "前のビューに戻る",
|
||||
"prefs.sketch_exit_view.unless_rotated": "回転していなければ戻る",
|
||||
"prefs.sketch_exit_view_note": "「スケッチを終了」、キャンセル、Esc の 2 回押しの後にビューが移る先です。",
|
||||
"prefs.sketch_recent_snap": "最近使った中心と中点へ自動スナップ",
|
||||
"prefs.sketch_recent_snap_note": "作図中、直前の2つの円の中心または直線辺の中点のガイドが交わる位置にカーソルをスナップします。オフ: ガイドは表示のみで、カーソルは移動しません。",
|
||||
"prefs.sketch_selection": "スケッチと選択",
|
||||
"prefs.sketch_selection_heading": "スケッチと選択",
|
||||
"prefs.sketch_selection_note": "スケッチのスナップとクリックによる選択。",
|
||||
|
||||
@@ -1620,6 +1620,8 @@
|
||||
"prefs.sketch_align_points_note": "그리는 동안 점이 한 스케치 점과 수평으로, 다른 점과 수직으로 동시에 맞춰지고 점선 안내선이 표시됩니다. 클릭하면 두 정렬이 모두 유지됩니다. 끄기: 이전 점에만, 한 번에 한 축씩 맞춥니다.",
|
||||
"prefs.sketch_conflict_colour": "충돌하는 기하를 충돌 색상으로 표시",
|
||||
"prefs.sketch_conflict_colour_note": "스케치의 구속 조건이 충돌하는 동안 실패한 구속 조건의 글리프나 치수뿐 아니라 그 구속 조건이 잡고 있는 곡선과 점도 충돌 색상으로 그립니다. 끄기: 평소 색상을 유지합니다.",
|
||||
"prefs.sketch_recent_snap": "최근 중심과 중점에 자동 스냅",
|
||||
"prefs.sketch_recent_snap_note": "그리는 동안 최근에 사용한 두 원의 중심 또는 직선 모서리 중점의 안내선이 만나는 위치로 커서를 스냅합니다. 끄기: 안내선은 보기만 하고 커서는 움직이지 않습니다.",
|
||||
"prefs.sketch_selection": "스케치 및 선택",
|
||||
"prefs.sketch_selection_heading": "스케치 및 선택",
|
||||
"prefs.sketch_selection_note": "스케치가 스냅되는 방식과 클릭으로 선택하는 방식입니다.",
|
||||
|
||||
@@ -1979,6 +1979,8 @@
|
||||
"prefs.sketch_exit_view.previous": "Trở về góc nhìn trước đó",
|
||||
"prefs.sketch_exit_view.unless_rotated": "Trở về góc nhìn trước trừ khi đã xoay",
|
||||
"prefs.sketch_exit_view_note": "Góc nhìn sẽ chuyển đến đâu sau khi bấm Hoàn tất phác thảo, Hủy bỏ hoặc bấm phím Esc hai lần.",
|
||||
"prefs.sketch_recent_snap": "Tự động bắt điểm vào tâm và trung điểm gần đây",
|
||||
"prefs.sketch_recent_snap_note": "Khi vẽ, con trỏ được bắt vào nơi các đường dẫn của hai tâm đường tròn hoặc trung điểm cạnh thẳng gần nhất cắt nhau. Tắt: các đường dẫn chỉ để xem và con trỏ không di chuyển.",
|
||||
"prefs.sketch_selection": "Phác thảo & chọn đối tượng",
|
||||
"prefs.sketch_selection_heading": "Phác thảo và lựa chọn",
|
||||
"prefs.sketch_selection_note": "Cách phác thảo bắt dính và cách nhấp chuột chọn đối tượng.",
|
||||
|
||||
@@ -1979,6 +1979,8 @@
|
||||
"prefs.sketch_exit_view.previous": "返回之前的视图",
|
||||
"prefs.sketch_exit_view.unless_rotated": "未旋转时返回",
|
||||
"prefs.sketch_exit_view_note": "“完成草图”、取消或连按两次 Esc 后视图的去向。",
|
||||
"prefs.sketch_recent_snap": "自动吸附到最近的圆心和中点",
|
||||
"prefs.sketch_recent_snap_note": "绘图时,将光标吸附到最近两个圆心或直线边中点的辅助线相交处。关闭:辅助线仅作显示,光标不会移动。",
|
||||
"prefs.sketch_selection": "草图与选择",
|
||||
"prefs.sketch_selection_heading": "草图和选择",
|
||||
"prefs.sketch_selection_note": "草图如何捕捉,以及单击如何选择。",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::schema::ParamCheck;
|
||||
use crate::{EngineError, find_command};
|
||||
use crate::{CommandSpec, EngineError, find_command};
|
||||
|
||||
/// The first step a batch would refuse at its turn.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -61,15 +61,31 @@ pub fn check(steps: &[Value], level: ParamCheck, host: &[&str]) -> Option<Refusa
|
||||
return Some(Refusal { index, command: Some(id.to_string()), error: EngineError::UnknownCommand(id.to_string()).to_string() });
|
||||
};
|
||||
if level == ParamCheck::Strict
|
||||
&& let Some(contract) = spec.contract
|
||||
&& let Some(first) = contract.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()
|
||||
&& let Some(refusal) = contract_refusal(index, id, spec, step)
|
||||
{
|
||||
return Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() });
|
||||
return Some(refusal);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The first of `steps` whose parameters break its command's typed contract, for a runner in
|
||||
/// another process that checks the rest itself (the MCP server bridged to an app at
|
||||
/// [`ParamCheck::Strict`]): a step naming no command, or one this registry lacks (the app may be
|
||||
/// another version), is left to it.
|
||||
pub fn contracts(steps: &[Value]) -> Option<Refusal> {
|
||||
steps.iter().enumerate().find_map(|(index, step)| {
|
||||
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str)?;
|
||||
contract_refusal(index, id, find_command(id)?, step)
|
||||
})
|
||||
}
|
||||
|
||||
/// Step `index`'s refusal when its parameters break `spec`'s typed contract.
|
||||
fn contract_refusal(index: usize, id: &str, spec: &CommandSpec, step: &Value) -> Option<Refusal> {
|
||||
let first = spec.contract?.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()?;
|
||||
Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -130,5 +146,9 @@ mod tests {
|
||||
let mut s = crate::Session { param_check: ParamCheck::Strict, ..crate::Session::default() };
|
||||
let at_turn = s.execute("solid.box", &steps[1]["params"]).unwrap_err().to_string();
|
||||
assert_eq!(r.error, at_turn);
|
||||
// Another process's runner gets the same refusal, and keeps what it checks itself.
|
||||
assert_eq!(contracts(&steps), Some(r));
|
||||
let theirs = vec![json!({"params": {}}), step("app.only", json!({"x": 1})), step("view.pick", json!({}))];
|
||||
assert_eq!(contracts(&theirs), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +480,60 @@ fn compiled_out_raytrace_is_rejected_before_scene_capture() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_and_start_reject_excessive_pixel_work_without_reserving_a_job() {
|
||||
let (mut s, host) = session();
|
||||
for _ in 0..8 {
|
||||
s.execute("solid.box", &json!({"length":20,"width":20,"height":20,"corner":[-10,-10,-10]})).unwrap();
|
||||
}
|
||||
let mut camera = solidcraft_render::Camera { distance: 1.0, fov: 0.0, ..Default::default() };
|
||||
camera.set_view(solidcraft_render::StandardView::Front);
|
||||
let params = json!({"settings":{"width":1024,"height":1024,"camera":camera}});
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len());
|
||||
for command in ["render.preflight", "render.start"] {
|
||||
let error = s.execute(command, ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("128000000 pixel tests"), "{error}");
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len()));
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
{
|
||||
let raytrace_host = CpuHost::for_platform().unwrap();
|
||||
s.set_render_job_host(Some(raytrace_host.clone())).unwrap();
|
||||
let params = json!({"settings":{"width":1024,"height":1024,"camera":camera},
|
||||
"integrator":"raytrace","transparent_background":false});
|
||||
let preflight = s.execute("render.preflight", ¶ms).unwrap();
|
||||
assert_eq!(preflight["resolved"]["executor"], "cpu-raytrace");
|
||||
assert!(preflight["admission"].get("projected_pixel_tests").is_none());
|
||||
assert_eq!(raytrace_host.admitted_bytes(), 0);
|
||||
let started = s.execute("render.start", ¶ms).unwrap();
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
s.execute("render.cancel", &json!({"job":id})).unwrap();
|
||||
let status = s.render_job_handle(id).unwrap().wait_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Cancelled);
|
||||
// The worker drops its reservation just after the job turns terminal.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while raytrace_host.admitted_bytes() != 0 {
|
||||
assert!(std::time::Instant::now() < deadline, "worker did not release the cancelled job's reservation");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.is_dirty(), s.undo.len(), s.redo.len()));
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
}
|
||||
|
||||
let params = json!({"settings":{"width":16,"height":16,"camera":camera}});
|
||||
let preflight = s.execute("render.preflight", ¶ms).unwrap();
|
||||
assert!(preflight["admission"]["projected_pixel_tests"].as_u64().unwrap() > 0);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
let id = s.execute("render.start", ¶ms).unwrap()["job"].clone();
|
||||
finish(&host);
|
||||
assert_eq!(s.execute("render.status", &json!({"job":id})).unwrap()["progress"]["phase"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identities_are_isolated_across_documents_scratch_and_new() {
|
||||
let (mut a, host) = session();
|
||||
|
||||
@@ -591,6 +591,39 @@ fn admission_rejects_hostile_parameters_and_transparency() {
|
||||
assert!(scene::flatten(&scene, Vec3::ZERO, &CancelToken::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_ignores_the_cpu_raster_projected_pixel_limit() {
|
||||
let mesh = Mesh {
|
||||
positions: vec![Vec3::new(-10.0, -10.0, 0.0), Vec3::new(10.0, -10.0, 0.0), Vec3::new(10.0, 10.0, 0.0)],
|
||||
normals: vec![Vec3::Z; 3],
|
||||
triangles: vec![[0, 1, 2]; 3201],
|
||||
tri_face: vec![0; 3201],
|
||||
..Default::default()
|
||||
};
|
||||
let scene = ProductScene {
|
||||
bounds: mesh.bounds(),
|
||||
meshes: vec![ProductMesh {
|
||||
name: "overlapping triangles".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [255; 3],
|
||||
opacity: 1.0,
|
||||
surface: Some(white()),
|
||||
transmission: None,
|
||||
faces: Vec::new(),
|
||||
}],
|
||||
};
|
||||
let mut r = request(100);
|
||||
r.camera.distance = 1.0;
|
||||
r.camera.fov = 0.0;
|
||||
let raster_error = solidcraft_render::job::admit(&scene, &r).unwrap_err();
|
||||
assert_eq!(raster_error.code, ErrorCode::LimitExceeded);
|
||||
assert!(raster_error.message.contains("128000000 pixel tests"), "{raster_error:?}");
|
||||
// Exercise the actual GPU admission path without requiring a Vulkan device.
|
||||
let gpu = admission(&scene, &r, JobOptions::default()).unwrap();
|
||||
assert_eq!(gpu.projected_pixel_tests, None);
|
||||
assert!(gpu.estimated_total_bytes > gpu.geometry_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_applies_the_gpu_estimate_instead_of_the_cpu_raster_budget() {
|
||||
// A library-bounded source (not an ordinary UI scene): about 100 MiB of face appearances
|
||||
|
||||
+100
-12
@@ -8,6 +8,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_engine::render::StandardView;
|
||||
use solidcraft_engine::schema::ParamCheck;
|
||||
use solidcraft_engine::view::Guides;
|
||||
|
||||
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
|
||||
@@ -33,10 +34,10 @@ pub trait Backend {
|
||||
fn notice(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// How this side's session checks commands' typed contracts (`--param-check`), when this
|
||||
/// process holds it. A running app checks its own (`engine.batch`), so `Remote` says off.
|
||||
fn param_check(&self) -> solidcraft_engine::schema::ParamCheck {
|
||||
solidcraft_engine::schema::ParamCheck::Off
|
||||
/// How calls are checked against their commands' typed contracts (`--param-check`): by the
|
||||
/// headless session, or by `Remote` before it sends them to the app.
|
||||
fn param_check(&self) -> ParamCheck {
|
||||
ParamCheck::Off
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +136,8 @@ pub struct Remote {
|
||||
/// An outstanding request's end, once a later call waited for it (for `pending`).
|
||||
settled: Option<Settled>,
|
||||
waits: Waits,
|
||||
/// How calls are checked before they are sent (`--param-check`).
|
||||
param_check: ParamCheck,
|
||||
}
|
||||
|
||||
/// How a tool error starts when no app listens at the bridged address. The agent reads it, and
|
||||
@@ -160,7 +163,17 @@ impl Remote {
|
||||
pub fn new(addr: &str, token: Option<&str>) -> Self {
|
||||
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
|
||||
let explicit_token = token.map(str::to_string);
|
||||
Self { addr: addr.to_string(), port, conn: None, next_id: 1, explicit_token, outstanding: None, settled: None, waits: WAITS }
|
||||
Self {
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
conn: None,
|
||||
next_id: 1,
|
||||
explicit_token,
|
||||
outstanding: None,
|
||||
settled: None,
|
||||
waits: WAITS,
|
||||
param_check: ParamCheck::Off,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorter waits for requests still running (tests).
|
||||
@@ -444,12 +457,85 @@ impl Remote {
|
||||
}
|
||||
}
|
||||
|
||||
/// The issues `params` raise against the typed contract of `id` in this server's registry; none
|
||||
/// when it has no contract for `id` (the app may have commands this server lacks).
|
||||
fn contract_issues(id: &str, params: &Value) -> Vec<String> {
|
||||
let contract = solidcraft_engine::find_command(id).and_then(|c| c.contract);
|
||||
contract.map(|c| c.check(params).into_iter().map(|i| i.message).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `reply` with `issues` as its `param_warnings`, as a headless session at the warn level adds
|
||||
/// them; a reply that has its own (an app that checked the call itself) keeps those.
|
||||
fn with_param_warnings(mut reply: Value, issues: Vec<String>) -> Value {
|
||||
if !issues.is_empty()
|
||||
&& let Some(o) = reply.as_object_mut()
|
||||
{
|
||||
o.entry("param_warnings").or_insert_with(|| json!(issues));
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Check calls against their commands' contracts at `level` before sending them
|
||||
/// (`--param-check`): the app's own session checks nothing.
|
||||
pub fn with_param_check(mut self, level: ParamCheck) -> Self {
|
||||
self.param_check = level;
|
||||
self
|
||||
}
|
||||
|
||||
/// `engine.execute` checked here: `strict` refuses a call that breaks its command's contract
|
||||
/// without sending it, and `warn` sends it and adds the issues to the app's reply.
|
||||
fn execute_checked(&mut self, params: Value) -> Result<Value, String> {
|
||||
let id = params.get("command").and_then(Value::as_str).unwrap_or_default();
|
||||
let issues = contract_issues(id, params.get("params").unwrap_or(&Value::Null));
|
||||
if self.param_check == ParamCheck::Strict
|
||||
&& let Some(first) = issues.first()
|
||||
{
|
||||
return Err(solidcraft_engine::params::bad(id, first.as_str()).to_string());
|
||||
}
|
||||
let reply = self.call_once("engine.execute", params, true)?;
|
||||
Ok(with_param_warnings(reply, issues))
|
||||
}
|
||||
|
||||
/// `engine.batch` checked here: `strict` refuses a batch with a step that breaks its command's
|
||||
/// contract before anything is sent, as the app would refuse it unrun, and `warn` adds each
|
||||
/// step's issues to its result.
|
||||
fn batch_checked(&mut self, params: Value) -> Result<Value, String> {
|
||||
let steps = params.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
if self.param_check == ParamCheck::Strict {
|
||||
if let Some(refusal) = solidcraft_engine::batch_preflight::contracts(steps) {
|
||||
return Ok(refusal.reply(steps.len()));
|
||||
}
|
||||
return self.call_once("engine.batch", params, true);
|
||||
}
|
||||
let issues: Vec<Vec<String>> = steps
|
||||
.iter()
|
||||
.map(|step| {
|
||||
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or_default();
|
||||
contract_issues(id, step.get("params").unwrap_or(&Value::Null))
|
||||
})
|
||||
.collect();
|
||||
let mut reply = self.call_once("engine.batch", params, true)?;
|
||||
if let Some(results) = reply.get_mut("results").and_then(Value::as_array_mut) {
|
||||
for (result, issues) in results.iter_mut().zip(issues) {
|
||||
*result = with_param_warnings(std::mem::take(result), issues);
|
||||
}
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
if method == "control.pending" {
|
||||
return self.pending_status();
|
||||
match method {
|
||||
"control.pending" => self.pending_status(),
|
||||
"engine.execute" if self.param_check != ParamCheck::Off => self.execute_checked(params),
|
||||
"engine.batch" if self.param_check != ParamCheck::Off => self.batch_checked(params),
|
||||
_ => self.call_once(method, params, true),
|
||||
}
|
||||
self.call_once(method, params, true)
|
||||
}
|
||||
fn param_check(&self) -> ParamCheck {
|
||||
self.param_check
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
@@ -576,7 +662,7 @@ impl Backend for Headless {
|
||||
fn in_process(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn param_check(&self) -> solidcraft_engine::schema::ParamCheck {
|
||||
fn param_check(&self) -> ParamCheck {
|
||||
self.session.param_check
|
||||
}
|
||||
}
|
||||
@@ -605,7 +691,8 @@ impl Auto {
|
||||
}
|
||||
if let Some(port) = (self.discover)() {
|
||||
eprintln!("solidcraft mcp: the SolidCraft app is running now; bridged to 127.0.0.1:{port}");
|
||||
self.remote = Some(Remote::new(&format!("127.0.0.1:{port}"), self.token.as_deref()));
|
||||
let level = self.param_check();
|
||||
self.remote = Some(Remote::new(&format!("127.0.0.1:{port}"), self.token.as_deref()).with_param_check(level));
|
||||
self.headless = None;
|
||||
}
|
||||
}
|
||||
@@ -642,10 +729,11 @@ impl Backend for Auto {
|
||||
fn in_process(&self) -> bool {
|
||||
self.remote.is_none()
|
||||
}
|
||||
fn param_check(&self) -> solidcraft_engine::schema::ParamCheck {
|
||||
fn param_check(&self) -> ParamCheck {
|
||||
match (&self.remote, &self.headless) {
|
||||
(Some(r), _) => r.param_check(),
|
||||
(None, Some(h)) => h.param_check(),
|
||||
_ => solidcraft_engine::schema::ParamCheck::Off,
|
||||
(None, None) => ParamCheck::Off,
|
||||
}
|
||||
}
|
||||
fn notice(&self) -> Option<String> {
|
||||
|
||||
@@ -2322,6 +2322,58 @@ fn describe_command_and_strict_parameter_checking() {
|
||||
assert_eq!(m["body_count"], json!(0), "{m}");
|
||||
}
|
||||
|
||||
/// Bridged to an app (#9072), the server checks each call against its command's contract before
|
||||
/// sending it, as a headless session does: the app's own session checks nothing. `strict` sends
|
||||
/// nothing that breaks a contract, `warn` adds `param_warnings` to the app's reply, and a command
|
||||
/// this server has no contract for goes to the app unchecked.
|
||||
#[test]
|
||||
fn a_bridged_server_checks_calls_at_its_param_check_level() {
|
||||
use solidcraft_engine::schema::ParamCheck;
|
||||
let (addr, seen) = scripted_app(|req| match req["method"].as_str() {
|
||||
Some("engine.batch") => {
|
||||
let n = req["params"]["commands"].as_array().map_or(0, Vec::len);
|
||||
json!({"ok": true, "result": {"completed": n, "results": vec![json!({}); n], "undone": 0}})
|
||||
}
|
||||
_ => json!({"ok": true, "result": {"created_bodies": ["Box1"]}}),
|
||||
});
|
||||
let bridged = |level| {
|
||||
let remote = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap().with_param_check(level);
|
||||
Server::new(Box::new(remote))
|
||||
};
|
||||
let sent = |what: &str| seen.lock().unwrap().1.iter().filter(|w| w.as_str() == what).count();
|
||||
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||||
let misspelt = json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "operaton": "cut"}});
|
||||
let good = json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5}});
|
||||
let warning = "unknown key `operaton`; did you mean `operation`?";
|
||||
|
||||
let mut s = bridged(ParamCheck::Strict);
|
||||
let r = raw_tool(&mut s, "execute", misspelt.clone());
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||||
assert!(text(&r).contains(&format!("invalid parameters for `solid.box`: {warning}")), "{r}");
|
||||
assert_eq!(sent("engine.execute solid.box"), 0, "refused unsent");
|
||||
let r = raw_tool(&mut s, "execute", json!({"command": "solid.fillet", "params": {"nonsense": 1}}));
|
||||
assert_ne!(r["result"]["isError"], json!(true), "{r}");
|
||||
assert_eq!(sent("engine.execute solid.fillet"), 1, "no contract here: the app decides");
|
||||
let r = raw_tool(&mut s, "batch", json!({"commands": [good, misspelt]}));
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||||
let reply: Value = serde_json::from_str(&text(&r)).unwrap();
|
||||
assert_eq!((&reply["failed"]["index"], &reply["completed"], &reply["not_run"]), (&json!(1), &json!(0), &json!([0, 1])), "{reply}");
|
||||
assert!(reply["failed"]["error"].as_str().unwrap_or_default().contains(warning), "{reply}");
|
||||
assert_eq!(sent("engine.batch"), 0, "refused unsent");
|
||||
|
||||
let mut s = bridged(ParamCheck::Warn);
|
||||
let r = tool(&mut s, "execute", misspelt.clone());
|
||||
assert_eq!(r["param_warnings"], json!([warning]), "{r}");
|
||||
assert_eq!(sent("engine.execute solid.box"), 1);
|
||||
let r = tool(&mut s, "batch", json!({"commands": [good, misspelt]}));
|
||||
assert_eq!((r["results"][0].get("param_warnings"), &r["results"][1]["param_warnings"]), (None, &json!([warning])), "{r}");
|
||||
assert_eq!(sent("engine.batch"), 1);
|
||||
|
||||
let mut s = bridged(ParamCheck::Off);
|
||||
let r = tool(&mut s, "execute", misspelt);
|
||||
assert_eq!(r.get("param_warnings"), None, "{r}");
|
||||
}
|
||||
|
||||
/// Plain `mcp` with no app running: the session is headless, says so on every result, and bridges
|
||||
/// to the app once one runs, but only while it holds no work of its own.
|
||||
#[test]
|
||||
@@ -2345,6 +2397,13 @@ fn auto_session_bridges_to_a_late_app_only_while_empty() {
|
||||
assert!(!text(&r).contains("headless session because"), "{r}");
|
||||
assert!(s.backend().has_ui());
|
||||
assert!(s.backend().describe().contains("connected to the SolidCraft app"));
|
||||
// The headless session's `--param-check` level goes on checking the bridged calls.
|
||||
let mut headless = Headless::default();
|
||||
headless.session.param_check = solidcraft_engine::schema::ParamCheck::Strict;
|
||||
let mut strict = Server::new(Box::new(crate::Auto::new(headless, Box::new(|| Some(1)), None)));
|
||||
raw_tool(&mut strict, "inspect_design", json!({}));
|
||||
assert!(strict.backend().has_ui());
|
||||
assert_eq!(strict.backend().param_check(), solidcraft_engine::schema::ParamCheck::Strict);
|
||||
|
||||
// With work in the headless session, the app showing up changes nothing.
|
||||
port.set(None);
|
||||
|
||||
@@ -285,7 +285,7 @@ fn cancellation_while_encoding_cannot_publish_a_late_frame() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_preparation_fails_the_job_without_stopping_the_host() {
|
||||
fn malformed_geometry_is_rejected_without_stopping_the_host() {
|
||||
let host = CpuHost::cooperative();
|
||||
let scene = ProductScene {
|
||||
bounds: Aabb3::EMPTY,
|
||||
@@ -299,13 +299,9 @@ fn malformed_preparation_fails_the_job_without_stopping_the_host() {
|
||||
faces: Vec::new(),
|
||||
}],
|
||||
};
|
||||
let broken = host.submit(id(1, 1), 1, scene, request()).unwrap();
|
||||
drain(&host);
|
||||
let status = broken.status().unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Failed);
|
||||
assert_eq!(status.error.unwrap().code, ErrorCode::InvalidScene);
|
||||
assert!(status.frame.is_none(), "a failed preparation path has no cached PNG identity");
|
||||
assert!(broken.frame().unwrap().is_none());
|
||||
let error = host.submit(id(1, 1), 1, scene, request()).err().unwrap();
|
||||
assert_eq!(error.code, ErrorCode::InvalidScene);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert!(host.is_available());
|
||||
let healthy = host.submit(id(1, 2), 1, empty(), request()).unwrap();
|
||||
drain(&host);
|
||||
@@ -538,6 +534,7 @@ impl ImageExecutor for DropProbe {
|
||||
Ok(Admission {
|
||||
triangles: 0,
|
||||
face_appearances: 0,
|
||||
projected_pixel_tests: None,
|
||||
geometry_bytes: 0,
|
||||
pixel_scratch_bytes: self.estimated,
|
||||
preparation_bytes: 0,
|
||||
@@ -780,6 +777,7 @@ fn registered_large_pool_is_explicit_finite_and_accounted() {
|
||||
Ok(Admission {
|
||||
triangles: 0,
|
||||
face_appearances: 0,
|
||||
projected_pixel_tests: None,
|
||||
geometry_bytes: 0,
|
||||
pixel_scratch_bytes: 0,
|
||||
preparation_bytes: 0,
|
||||
|
||||
+153
-101
@@ -23,6 +23,9 @@ const SS: usize = 2;
|
||||
pub struct Admission {
|
||||
pub triangles: u64,
|
||||
pub face_appearances: u64,
|
||||
/// Supersampled raster work only; absent for ray tracing and registered executors.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub projected_pixel_tests: Option<u64>,
|
||||
pub geometry_bytes: u64,
|
||||
pub pixel_scratch_bytes: u64,
|
||||
pub preparation_bytes: u64,
|
||||
@@ -79,7 +82,9 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
|
||||
}
|
||||
|
||||
pub(crate) fn admit_base(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
|
||||
admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)
|
||||
let mut admission = admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)?;
|
||||
admission.projected_pixel_tests = Some(projected_pixel_tests(scene, request)?);
|
||||
Ok(admission)
|
||||
}
|
||||
|
||||
/// Common camera, dimension and source admission for registered executors, without any CPU
|
||||
@@ -231,6 +236,7 @@ fn admit_source_with_limits(
|
||||
admission: Admission {
|
||||
triangles,
|
||||
face_appearances: faces,
|
||||
projected_pixel_tests: None,
|
||||
geometry_bytes: geometry,
|
||||
pixel_scratch_bytes: 0,
|
||||
preparation_bytes: 0,
|
||||
@@ -266,6 +272,137 @@ fn admit_raster(source: Source) -> Result<Admission> {
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// The same clipping, supersampling and bounds govern admission and raster work.
|
||||
struct Projection {
|
||||
vp: Mat4,
|
||||
back: Vec3,
|
||||
target: Vec3,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
}
|
||||
|
||||
impl Projection {
|
||||
fn new(scene: &ProductScene, request: &ImageRequest) -> Result<Self> {
|
||||
let (sw, sh) = (request.width as usize * SS, request.height as usize * SS);
|
||||
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target);
|
||||
if !radius.is_finite() {
|
||||
return Err(invalid("invalid product bounds"));
|
||||
}
|
||||
Ok(Self { vp: request.camera.view_proj(sw as f64 / sh as f64, radius), back: request.camera.back(), target: request.camera.target, sw, sh })
|
||||
}
|
||||
|
||||
fn project(&self, body: &crate::ProductMesh, triangle: usize, look: Option<&FaceAppearance>) -> Result<Option<Triangle>> {
|
||||
let mesh = &body.mesh;
|
||||
let indices = mesh.triangles.get(triangle).ok_or_else(|| invalid("missing product triangle"))?;
|
||||
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
|
||||
let normals = indices.map(|i| mesh.normals.get(i as usize).copied());
|
||||
let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else {
|
||||
return Err(invalid("invalid triangle index or missing normal"));
|
||||
};
|
||||
let world = [a, b, c];
|
||||
let normals = [na, nb, nc];
|
||||
if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) {
|
||||
return Err(invalid("non-finite product triangle"));
|
||||
}
|
||||
// Transmission is physical optical data, never a request to change CPU preview.
|
||||
// Keep the existing scalar sorted-alpha shading for glass previews.
|
||||
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
|
||||
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
|
||||
});
|
||||
if surface.is_some() && opacity != 1.0 {
|
||||
return Err(invalid("opaque PBR requires opacity 1"));
|
||||
}
|
||||
let normals = if surface.is_some() {
|
||||
[crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)]
|
||||
.map(|n| n.map_err(|e| invalid(&e.to_string())))
|
||||
} else {
|
||||
[Ok(normals[0]), Ok(normals[1]), Ok(normals[2])]
|
||||
};
|
||||
let [na, nb, nc] = normals;
|
||||
let normals = [na?, nb?, nc?];
|
||||
if opacity <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let clip = world.map(|p| self.vp.apply(p));
|
||||
if clip.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(invalid("non-finite product projection"));
|
||||
}
|
||||
if clip.iter().any(|p| p[3] <= 1e-9) {
|
||||
return Ok(None);
|
||||
}
|
||||
let inv_w = clip.map(|p| 1.0 / p[3]);
|
||||
let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]);
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) {
|
||||
return Err(invalid("product projection exceeds pixel precision"));
|
||||
}
|
||||
let [a, b, c] = points;
|
||||
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0));
|
||||
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0));
|
||||
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !area.is_finite() {
|
||||
return Err(invalid("invalid projected area"));
|
||||
}
|
||||
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.target).dot(self.back);
|
||||
if !centre_depth.is_finite() {
|
||||
return Err(invalid("invalid triangle depth"));
|
||||
}
|
||||
Ok(Some(Triangle {
|
||||
points,
|
||||
world,
|
||||
normals,
|
||||
inv_w,
|
||||
colour: colour.map(decode),
|
||||
opacity,
|
||||
surface,
|
||||
centre_depth,
|
||||
area,
|
||||
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
|
||||
x: x0 as usize,
|
||||
y: y0 as usize,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn add_pixel_tests(sum: &mut u64, triangle: &Triangle) -> Result<()> {
|
||||
let [x0, x1, y0, y1] = triangle.bounds;
|
||||
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
|
||||
*sum = sum.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
|
||||
if *sum > MAX_PIXEL_TESTS {
|
||||
return Err(limit("projected product exceeds 128000000 pixel tests; reduce image dimensions or visible geometry"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_face(face: &FaceAppearance) -> Result<()> {
|
||||
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
|
||||
return Err(invalid("invalid face opacity"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn projected_pixel_tests(scene: &ProductScene, request: &ImageRequest) -> Result<u64> {
|
||||
let projection = Projection::new(scene, request)?;
|
||||
let mut tests = 0;
|
||||
for body in &scene.meshes {
|
||||
let mut looks = BTreeMap::new();
|
||||
for face in &body.faces {
|
||||
validate_face(face)?;
|
||||
// Match the document and task policy: the last assignment wins.
|
||||
looks.insert(face.face, face);
|
||||
}
|
||||
for index in 0..body.mesh.triangles.len() {
|
||||
let look = body.mesh.tri_face.get(index).and_then(|face| looks.get(face)).copied();
|
||||
if let Some(triangle) = projection.project(body, index, look)? {
|
||||
add_pixel_tests(&mut tests, &triangle)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(tests)
|
||||
}
|
||||
|
||||
fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve render work memory"))?;
|
||||
@@ -326,10 +463,7 @@ pub struct ImageTask {
|
||||
admission: Admission,
|
||||
cancel: CancelToken,
|
||||
stage: Stage,
|
||||
vp: Mat4,
|
||||
back: Vec3,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
projection: Projection,
|
||||
looks: Vec<BTreeMap<u32, FaceAppearance>>,
|
||||
body: usize,
|
||||
item: usize,
|
||||
@@ -361,13 +495,8 @@ impl ImageTask {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "raster task cannot execute a raytrace request"));
|
||||
}
|
||||
let admission = admit(&scene, &request)?;
|
||||
let (sw, sh) = (request.width as usize * SS, request.height as usize * SS);
|
||||
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target);
|
||||
if !radius.is_finite() {
|
||||
return Err(invalid("invalid product bounds"));
|
||||
}
|
||||
let vp = request.camera.view_proj(sw as f64 / sh as f64, radius);
|
||||
let back = request.camera.back();
|
||||
let projection = Projection::new(&scene, &request)?;
|
||||
let (sw, sh) = (projection.sw, projection.sh);
|
||||
let looks = reserve(scene.meshes.len())?;
|
||||
let (opaque, glass, scratch) =
|
||||
(reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?);
|
||||
@@ -378,10 +507,7 @@ impl ImageTask {
|
||||
admission,
|
||||
cancel,
|
||||
stage: Stage::Looks,
|
||||
vp,
|
||||
back,
|
||||
sw,
|
||||
sh,
|
||||
projection,
|
||||
looks,
|
||||
body: 0,
|
||||
item: 0,
|
||||
@@ -417,7 +543,9 @@ impl ImageTask {
|
||||
Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample => Progress {
|
||||
phase: Phase::Running,
|
||||
done: self.pixel_done,
|
||||
total: self.pixel_tests + (self.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height),
|
||||
total: self.pixel_tests
|
||||
+ (self.projection.sw * self.projection.sh) as u64
|
||||
+ u64::from(self.request.width) * u64::from(self.request.height),
|
||||
},
|
||||
Stage::Encode => Progress {
|
||||
phase: Phase::Encoding,
|
||||
@@ -466,9 +594,7 @@ impl ImageTask {
|
||||
self.looks.push(BTreeMap::new());
|
||||
}
|
||||
if let Some(face) = body.faces.get(self.item) {
|
||||
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
|
||||
return Err(invalid("invalid face opacity"));
|
||||
}
|
||||
validate_face(face)?;
|
||||
let looks = self.looks.get_mut(self.body).ok_or_else(|| invalid("missing body appearances"))?;
|
||||
// Match the document policy: the last assignment of a face wins.
|
||||
looks.insert(face.face, face.clone());
|
||||
@@ -483,78 +609,9 @@ impl ImageTask {
|
||||
|
||||
fn project(&self, draw: Draw) -> Result<Option<Triangle>> {
|
||||
let body = self.scene.meshes.get(draw.body as usize).ok_or_else(|| invalid("missing product body"))?;
|
||||
let mesh = &body.mesh;
|
||||
let indices = mesh.triangles.get(draw.triangle as usize).ok_or_else(|| invalid("missing product triangle"))?;
|
||||
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
|
||||
let normals = indices.map(|i| mesh.normals.get(i as usize).copied());
|
||||
let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else {
|
||||
return Err(invalid("invalid triangle index or missing normal"));
|
||||
};
|
||||
let world = [a, b, c];
|
||||
let normals = [na, nb, nc];
|
||||
if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) {
|
||||
return Err(invalid("non-finite product triangle"));
|
||||
}
|
||||
let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
|
||||
// Transmission is physical optical data, never a request to change CPU preview.
|
||||
// Keep the existing scalar sorted-alpha shading for glass previews.
|
||||
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
|
||||
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
|
||||
});
|
||||
if surface.is_some() && opacity != 1.0 {
|
||||
return Err(invalid("opaque PBR requires opacity 1"));
|
||||
}
|
||||
let normals = if surface.is_some() {
|
||||
[crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)]
|
||||
.map(|n| n.map_err(|e| invalid(&e.to_string())))
|
||||
} else {
|
||||
[Ok(normals[0]), Ok(normals[1]), Ok(normals[2])]
|
||||
};
|
||||
let [na, nb, nc] = normals;
|
||||
let normals = [na?, nb?, nc?];
|
||||
if opacity <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let clip = world.map(|p| self.vp.apply(p));
|
||||
if clip.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(invalid("non-finite product projection"));
|
||||
}
|
||||
if clip.iter().any(|p| p[3] <= 1e-9) {
|
||||
return Ok(None);
|
||||
}
|
||||
let inv_w = clip.map(|p| 1.0 / p[3]);
|
||||
let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]);
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) {
|
||||
return Err(invalid("product projection exceeds pixel precision"));
|
||||
}
|
||||
let [a, b, c] = points;
|
||||
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0));
|
||||
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0));
|
||||
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !area.is_finite() {
|
||||
return Err(invalid("invalid projected area"));
|
||||
}
|
||||
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.request.camera.target).dot(self.back);
|
||||
if !centre_depth.is_finite() {
|
||||
return Err(invalid("invalid triangle depth"));
|
||||
}
|
||||
Ok(Some(Triangle {
|
||||
points,
|
||||
world,
|
||||
normals,
|
||||
inv_w,
|
||||
colour: colour.map(decode),
|
||||
opacity,
|
||||
surface,
|
||||
centre_depth,
|
||||
area,
|
||||
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
|
||||
x: x0 as usize,
|
||||
y: y0 as usize,
|
||||
}))
|
||||
let look =
|
||||
self.looks.get(draw.body as usize).and_then(|looks| body.mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
|
||||
self.projection.project(body, draw.triangle as usize, look)
|
||||
}
|
||||
|
||||
fn prepare_step(&mut self) -> Result<()> {
|
||||
@@ -571,12 +628,7 @@ impl ImageTask {
|
||||
}
|
||||
let mut draw = Draw { body: self.body as u32, triangle: self.item as u32, depth: 0.0 };
|
||||
if let Some(triangle) = self.project(draw)? {
|
||||
let [x0, x1, y0, y1] = triangle.bounds;
|
||||
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
|
||||
self.pixel_tests = self.pixel_tests.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
|
||||
if self.pixel_tests > MAX_PIXEL_TESTS {
|
||||
return Err(limit("projected product exceeds 128000000 pixel tests"));
|
||||
}
|
||||
add_pixel_tests(&mut self.pixel_tests, &triangle)?;
|
||||
draw.depth = triangle.centre_depth;
|
||||
if triangle.opacity >= 1.0 {
|
||||
self.opaque.push(draw);
|
||||
@@ -637,7 +689,7 @@ impl ImageTask {
|
||||
}
|
||||
|
||||
fn clear_step(&mut self) {
|
||||
if self.pixels.len() >= self.sw * self.sh {
|
||||
if self.pixels.len() >= self.projection.sw * self.projection.sh {
|
||||
self.stage = Stage::Opaque;
|
||||
self.draw = 0;
|
||||
return;
|
||||
@@ -664,7 +716,7 @@ impl ImageTask {
|
||||
}
|
||||
let triangle = self.triangle.as_mut().ok_or_else(|| invalid("missing raster triangle"))?;
|
||||
let eye = (self.request.camera.fov > 0.0).then(|| self.request.camera.eye());
|
||||
raster_pixel(triangle, self.sw, self.back, eye, self.request.studio_weights, &mut self.pixels, &mut self.depth)?;
|
||||
raster_pixel(triangle, self.projection.sw, self.projection.back, eye, self.request.studio_weights, &mut self.pixels, &mut self.depth)?;
|
||||
triangle.x += 1;
|
||||
if triangle.x > triangle.bounds[1] {
|
||||
triangle.x = triangle.bounds[0];
|
||||
@@ -699,7 +751,7 @@ impl ImageTask {
|
||||
let (mut colour, mut alpha) = ([0.0; 3], 0.0);
|
||||
for dy in 0..SS {
|
||||
for dx in 0..SS {
|
||||
let p = self.pixels.get((y * SS + dy) * self.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
|
||||
let p = self.pixels.get((y * SS + dy) * self.projection.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
|
||||
let a = f64::from(p[3]) / 255.0;
|
||||
for (sum, channel) in colour.iter_mut().zip(p.iter().take(3)) {
|
||||
*sum += decode(*channel) * a;
|
||||
|
||||
@@ -167,12 +167,7 @@ fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
|
||||
} else {
|
||||
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None, transmission: None });
|
||||
}
|
||||
let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap();
|
||||
let error = loop {
|
||||
if let Err(error) = task.step(64) {
|
||||
break error;
|
||||
}
|
||||
};
|
||||
let error = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).err().unwrap();
|
||||
assert_eq!(error.code, ErrorCode::InvalidScene);
|
||||
}
|
||||
let mut body = body;
|
||||
@@ -202,3 +197,64 @@ fn registered_executor_geometry_admission_excludes_the_cpu_raster_estimate() {
|
||||
// The CPU host itself keeps rejecting the source.
|
||||
assert_eq!(admit(&source, &r).unwrap_err().code, ErrorCode::LimitExceeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_projected_pixel_work_before_starting_a_job() {
|
||||
let mut body = square("Overlapping glass", [100, 120, 140], 0.55, Vec3::ZERO);
|
||||
let mesh = Arc::make_mut(&mut body.mesh);
|
||||
mesh.triangles = vec![[0, 1, 2]; 1024];
|
||||
mesh.tri_face = vec![0; 1024];
|
||||
let scene = scene(vec![body]);
|
||||
let request = ImageRequest { width: 512, height: 512, ..request() };
|
||||
let error = admit(&scene, &request).expect_err("preflight must reject the same pixel-work limit as the rasterizer");
|
||||
assert_eq!(error.code, ErrorCode::LimitExceeded);
|
||||
assert!(error.message.contains("pixel tests"));
|
||||
let request = ImageRequest { width: 64, height: 64, ..request };
|
||||
let admission = admit(&scene, &request).unwrap();
|
||||
assert!(admission.projected_pixel_tests.unwrap() > 0);
|
||||
assert!(admission.projected_pixel_tests.unwrap() <= MAX_PIXEL_TESTS);
|
||||
let png = finish(ImageTask::new(scene, request, CancelToken::default()).unwrap());
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().width(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_work_admission_includes_the_limit_and_uses_clipped_sample_bounds() {
|
||||
let mut body = square("Full viewport", [100; 3], 1.0, Vec3::ZERO);
|
||||
let mesh = Arc::make_mut(&mut body.mesh);
|
||||
// Each triangle's bounds cover all 200 * 200 supersampled pixels.
|
||||
mesh.triangles = vec![[0, 1, 2]; 3200];
|
||||
mesh.tri_face = vec![0; 3200];
|
||||
let mut request = ImageRequest { width: 100, height: 100, ..request() };
|
||||
request.camera.distance = 1.0;
|
||||
request.camera.fov = 0.0;
|
||||
let mut scene = scene(vec![body]);
|
||||
assert_eq!(admit(&scene, &request).unwrap().projected_pixel_tests, Some(128_000_000));
|
||||
let mut task = ImageTask::new(scene.clone(), request.clone(), CancelToken::default()).unwrap();
|
||||
while task.stage != Stage::Clear {
|
||||
task.step(1).unwrap();
|
||||
}
|
||||
assert_eq!(task.pixel_tests, 128_000_000);
|
||||
Arc::make_mut(&mut scene.meshes[0].mesh).triangles.push([0, 1, 2]);
|
||||
assert_eq!(admit(&scene, &request).unwrap_err().code, ErrorCode::LimitExceeded);
|
||||
// Registered executors budget their own work, independently of the raster projection.
|
||||
let geometry = admit_geometry(&scene, &request, MAX_TRIANGLES).unwrap();
|
||||
assert_eq!(geometry.projected_pixel_tests, None);
|
||||
assert!(serde_json::to_value(geometry).unwrap().get("projected_pixel_tests").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_work_ignores_invisible_triangles_and_respects_the_last_face_override() {
|
||||
let mut body = square("Hidden", [100; 3], 0.0, Vec3::ZERO);
|
||||
let request = request();
|
||||
assert_eq!(admit(&scene(vec![body.clone()]), &request).unwrap().projected_pixel_tests, Some(0));
|
||||
body.faces.push(FaceAppearance { face: 0, color: [100; 3], opacity: 0.55, surface: None, transmission: None });
|
||||
assert!(admit(&scene(vec![body.clone()]), &request).unwrap().projected_pixel_tests.unwrap() > 0);
|
||||
body.faces.push(FaceAppearance { face: 0, color: [100; 3], opacity: 0.0, surface: None, transmission: None });
|
||||
assert_eq!(admit(&scene(vec![body]), &request).unwrap().projected_pixel_tests, Some(0));
|
||||
|
||||
let offscreen = square("Offscreen", [100; 3], 1.0, Vec3::new(10_000.0, 0.0, 0.0));
|
||||
assert_eq!(admit(&scene(vec![offscreen]), &request).unwrap().projected_pixel_tests, Some(0));
|
||||
let mut degenerate = square("Degenerate", [100; 3], 1.0, Vec3::ZERO);
|
||||
Arc::make_mut(&mut degenerate.mesh).triangles = vec![[0, 0, 0]];
|
||||
assert_eq!(admit(&scene(vec![degenerate]), &request).unwrap().projected_pixel_tests, Some(0));
|
||||
}
|
||||
|
||||
@@ -1063,6 +1063,26 @@ impl Sketch {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
// A face seen edge-on was once saved as two lines on the same two points, one each way;
|
||||
// it is one line now. Keep the first line (an offset or a constraint may name it) and
|
||||
// drop only the second, instead of rebuilding both under new ids.
|
||||
if let Some([LCurve::Line(0, 1), LCurve::Line(1, 0)]) = cur.as_deref()
|
||||
&& let [LCurve::Line(0, 1)] = new.as_slice()
|
||||
&& let [_, second] = curves[..]
|
||||
&& let [p0, p1] = points[..]
|
||||
&& lay.lone.is_empty()
|
||||
&& let Some(a) = order.first().and_then(|li| lay.pts.get(*li)).copied()
|
||||
&& let Some(b) = order.get(1).and_then(|li| lay.pts.get(*li)).copied()
|
||||
{
|
||||
if let Some(p) = self.points.get_mut(p0) {
|
||||
p.pos = a;
|
||||
}
|
||||
if let Some(p) = self.points.get_mut(p1) {
|
||||
p.pos = b;
|
||||
}
|
||||
self.remove_curves(&[second]);
|
||||
return Ok(true);
|
||||
}
|
||||
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
|
||||
self.drop_link_entities(id);
|
||||
self.build_link(id, &lay)?;
|
||||
|
||||
@@ -374,3 +374,52 @@ fn linked_conic_curves_take_the_n_prefix_not_the_constraints_k() {
|
||||
let ids: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
|
||||
assert_eq!(ids, vec!["l1".to_string(), "n1".to_string()], "linked curve ids");
|
||||
}
|
||||
|
||||
/// A face seen edge-on was saved as two lines on the same two points, one each way, and is one
|
||||
/// line now. Re-resolving it must not give the first line a new id: an offset names it as its
|
||||
/// source, and lost it ("an offset source vanished or changed shape").
|
||||
#[test]
|
||||
fn edge_on_face_saved_as_a_line_pair_keeps_the_line_an_offset_follows() {
|
||||
let (a, b) = (Vec2::new(5.0, 5.0), Vec2::new(15.0, 5.0));
|
||||
let mut sk = Sketch::new();
|
||||
// `add_link` makes one line of a reversed pair, so lay the pair out the way a saved file has it.
|
||||
let link = "j1".to_string();
|
||||
let (pa, pb) = (sk.add_point(a, None).unwrap(), sk.add_point(b, None).unwrap());
|
||||
for (from, to, id) in [(pa, pb, "l1"), (pb, pa, "l2")] {
|
||||
let c = sk.add_line_pts(from, to, Some(id)).unwrap();
|
||||
sk.curves[c].link = Some(link.clone());
|
||||
}
|
||||
for point in [pa, pb] {
|
||||
sk.points[point].link = Some(link.clone());
|
||||
}
|
||||
sk.links.push(Link {
|
||||
id: link.clone(),
|
||||
kind: LinkKind::Project,
|
||||
source: face(),
|
||||
face_name: None,
|
||||
edge_name: None,
|
||||
auto_face: false,
|
||||
lost: false,
|
||||
});
|
||||
let ids: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
|
||||
assert_eq!(ids.len(), 2, "the saved link holds the pair");
|
||||
let (p, q) = (sk.add_point(Vec2::new(5.0, 3.0), None).unwrap(), sk.add_point(Vec2::new(15.0, 3.0), None).unwrap());
|
||||
let out = sk.add_line_pts(p, q, None).unwrap();
|
||||
let out_id = sk.curves[out].id.clone();
|
||||
sk.offsets.push(crate::offset::OffsetChain {
|
||||
sources: vec![(ids[0].clone(), true)],
|
||||
outputs: vec![out_id],
|
||||
distance: -2.0,
|
||||
closed: false,
|
||||
topology: None,
|
||||
});
|
||||
sk.refresh_offsets().unwrap();
|
||||
|
||||
sk.update_link(&link, &[LinkGeom::Line(a, Vec2::new(17.0, 5.0))]).unwrap();
|
||||
|
||||
let kept: Vec<String> = sk.link_curves(&link).iter().map(|c| sk.curves[*c].id.clone()).collect();
|
||||
assert_eq!(kept, vec![ids[0].clone()], "the first line keeps its id");
|
||||
sk.refresh_offsets().unwrap();
|
||||
let end = sk.points[sk.link_points(&link)[1]].pos;
|
||||
assert_eq!(end, Vec2::new(17.0, 5.0), "the line moved to the new geometry");
|
||||
}
|
||||
|
||||
+148
-18
@@ -250,13 +250,16 @@ pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hovered centre or straight-edge midpoint. Transient, per viewport, never a constraint.
|
||||
/// The two most recent hovered centres or straight-edge midpoints. Transient, per viewport,
|
||||
/// never a constraint. A third hover drops the oldest. Endpoints are ordinary point snaps.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Tracking {
|
||||
sketch: Option<u64>,
|
||||
document: std::sync::Weak<solidcraft_engine::doc::Document>,
|
||||
anchor: Option<String>,
|
||||
previous: Option<String>,
|
||||
pub(crate) guide: Option<(Vec2, Vec2)>,
|
||||
pub(crate) remembered_guide: Option<(Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
impl Tracking {
|
||||
@@ -271,26 +274,91 @@ impl Tracking {
|
||||
}
|
||||
}
|
||||
|
||||
fn point_of(sk: &Sketch, id: &str) -> Option<Vec2> {
|
||||
Self::reference(sk, sk.curve_index(id)?)
|
||||
}
|
||||
|
||||
/// Keep `id` as one of the two remembered references. Hovering the older one swaps them,
|
||||
/// so going back does not drop it. A third, different reference drops the oldest.
|
||||
fn remember(&mut self, id: String) {
|
||||
if self.anchor.as_deref() == Some(id.as_str()) {
|
||||
return;
|
||||
}
|
||||
if self.previous.as_deref() == Some(id.as_str()) {
|
||||
std::mem::swap(&mut self.anchor, &mut self.previous);
|
||||
return;
|
||||
}
|
||||
self.previous = self.anchor.replace(id);
|
||||
}
|
||||
|
||||
/// Where the horizontal through one reference meets the vertical through the other, when
|
||||
/// that point is within a few pixels of the cursor and is not either reference itself.
|
||||
pub(crate) fn meeting(&self, sk: &Sketch, plane: &solidcraft_engine::geom::Plane, proj: &Proj, cursor: egui::Pos2) -> Option<Vec2> {
|
||||
let a = self.anchor.as_deref().and_then(|id| Self::point_of(sk, id))?;
|
||||
let b = self.previous.as_deref().and_then(|id| Self::point_of(sk, id))?;
|
||||
if a.dist(b) < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let to = |p: Vec2| proj.to_screen(plane.to_world(p));
|
||||
let mut best: Option<(f32, Vec2)> = None;
|
||||
for at in [Vec2::new(a.x, b.y), Vec2::new(b.x, a.y)] {
|
||||
if at.dist(a) < 1e-6 || at.dist(b) < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let Some(screen) = to(at).filter(|p| proj.rect.contains(*p)) else { continue };
|
||||
let distance = cursor.distance(screen);
|
||||
if distance < 8.0 && best.is_none_or(|(d, _)| distance < d) {
|
||||
best = Some((distance, at));
|
||||
}
|
||||
}
|
||||
best.map(|(_, at)| at)
|
||||
}
|
||||
|
||||
fn axis_guide(anchor: Vec2, at: Vec2, cursor: egui::Pos2, to: &impl Fn(Vec2) -> Option<egui::Pos2>) -> Option<(Vec2, Vec2)> {
|
||||
let origin = to(anchor)?;
|
||||
let mut distance = 6.0;
|
||||
let mut end = None;
|
||||
for candidate in [Vec2::new(at.x, anchor.y), Vec2::new(anchor.x, at.y)] {
|
||||
let Some(screen) = to(candidate) else { continue };
|
||||
let off_axis = cursor.distance(screen);
|
||||
if origin.distance(screen) > 8.0 && off_axis < distance {
|
||||
distance = off_axis;
|
||||
end = Some(candidate);
|
||||
}
|
||||
}
|
||||
end.map(|end| (anchor, end))
|
||||
}
|
||||
|
||||
fn update(&mut self, sk: &Sketch, plane: &solidcraft_engine::geom::Plane, proj: &Proj, cursor: egui::Pos2) {
|
||||
self.guide = None;
|
||||
self.remembered_guide = None;
|
||||
let to = |p: Vec2| proj.to_screen(plane.to_world(p));
|
||||
// Screen-space tolerances stay the same at any zoom; only visible references wake.
|
||||
let mut nearest = 8.0;
|
||||
let mut nearest_id = None;
|
||||
for (index, curve) in sk.curves.iter().enumerate() {
|
||||
let Some(p) = Self::reference(sk, index).and_then(to).filter(|p| proj.rect.contains(*p)) else { continue };
|
||||
let distance = cursor.distance(p);
|
||||
if distance < nearest {
|
||||
nearest = distance;
|
||||
self.anchor = Some(curve.id.clone());
|
||||
nearest_id = Some(curve.id.clone());
|
||||
}
|
||||
}
|
||||
let Some((anchor, origin)) = self.anchor.as_deref().and_then(|id| {
|
||||
let anchor = Self::reference(sk, sk.curve_index(id)?)?;
|
||||
Some((anchor, to(anchor)?))
|
||||
if let Some(id) = nearest_id {
|
||||
self.remember(id);
|
||||
}
|
||||
let Some(anchor) = self.anchor.as_deref().and_then(|id| Self::point_of(sk, id)).or_else(|| {
|
||||
self.anchor = self.previous.take();
|
||||
self.anchor.as_deref().and_then(|id| Self::point_of(sk, id))
|
||||
}) else {
|
||||
self.anchor = None;
|
||||
self.previous = None;
|
||||
return;
|
||||
};
|
||||
if self.previous.as_deref().and_then(|id| Self::point_of(sk, id)).is_none() {
|
||||
self.previous = None;
|
||||
}
|
||||
let Some(origin) = to(anchor) else { return };
|
||||
if !proj.rect.contains(origin) {
|
||||
return;
|
||||
}
|
||||
@@ -300,21 +368,27 @@ impl Tracking {
|
||||
if !at.x.is_finite() || !at.y.is_finite() {
|
||||
return;
|
||||
}
|
||||
// Near the crossing, both guides end on that point. Otherwise each reference keeps
|
||||
// its own axis, and a single guide stays visual (it does not move the cursor).
|
||||
if let Some(meet) = self.meeting(sk, plane, proj, cursor) {
|
||||
let span = |from: Vec2| to(from).zip(to(meet)).filter(|(a, b)| a.distance(*b) > 8.0).map(|_| (from, meet));
|
||||
self.guide = span(anchor);
|
||||
if let Some(previous) = self.previous.as_deref().and_then(|id| Self::point_of(sk, id)) {
|
||||
self.remembered_guide = span(previous);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Finite guides connect the reference to the cursor's projection onto its axis.
|
||||
// No model-space length cap: the viewport clips them, as it does other sketch guides.
|
||||
let mut distance = 6.0;
|
||||
for end in [Vec2::new(at.x, anchor.y), Vec2::new(anchor.x, at.y)] {
|
||||
let Some(screen) = to(end) else { continue };
|
||||
let off_axis = cursor.distance(screen);
|
||||
if origin.distance(screen) > 8.0 && off_axis < distance {
|
||||
distance = off_axis;
|
||||
self.guide = Some((anchor, end));
|
||||
}
|
||||
self.guide = Self::axis_guide(anchor, at, cursor, &to);
|
||||
if let Some(previous) = self.previous.as_deref().and_then(|id| Self::point_of(sk, id)) {
|
||||
self.remembered_guide = Self::axis_guide(previous, at, cursor, &to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual H/V tracking works before a tool's first click and while simply editing a sketch.
|
||||
/// The crossing of the two remembered references is also a snap. A single guide stays visual.
|
||||
/// Existing snaps, picks and constraints retain their behaviour.
|
||||
pub(crate) fn track_and_show(app: &mut SolveApp, painter: &egui::Painter, proj: &Proj, cursor: Option<egui::Pos2>) {
|
||||
crate::scenario::publish_count("sketch_tracking_guides", 0.0);
|
||||
@@ -334,13 +408,20 @@ pub(crate) fn track_and_show(app: &mut SolveApp, painter: &egui::Painter, proj:
|
||||
return;
|
||||
};
|
||||
tracking.update(&ss.sketch, &ss.plane, proj, cursor);
|
||||
crate::scenario::publish_count("sketch_tracking_guides", usize::from(tracking.guide.is_some()) as f64);
|
||||
if let Some((a, b)) = tracking.guide
|
||||
&& let (Some(a), Some(b)) = (proj.to_screen(ss.plane.to_world(a)), proj.to_screen(ss.plane.to_world(b)))
|
||||
{
|
||||
let tk = Tokens::get();
|
||||
let shown = tracking.guide.is_some() || tracking.remembered_guide.is_some();
|
||||
crate::scenario::publish_count("sketch_tracking_guides", usize::from(shown) as f64);
|
||||
let tk = Tokens::get();
|
||||
let draw = |guide: (Vec2, Vec2)| {
|
||||
let (Some(a), Some(b)) = (proj.to_screen(ss.plane.to_world(guide.0)), proj.to_screen(ss.plane.to_world(guide.1))) else { return None };
|
||||
painter.extend(crate::viewport::dashed_line(&[a, b], painter.clip_rect(), Stroke::new(1.0, tk.rubber_band), 5.0, 4.0));
|
||||
painter.circle_stroke(a, 4.0, Stroke::new(1.0, tk.rubber_band));
|
||||
Some(b)
|
||||
};
|
||||
let ends = [tracking.guide.and_then(draw), tracking.remembered_guide.and_then(draw)];
|
||||
if let (Some(a), Some(b)) = (ends[0], ends[1])
|
||||
&& a.distance(b) < 1.0
|
||||
{
|
||||
painter.circle_stroke(a, 4.0, Stroke::new(1.0, tk.rubber_band));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +474,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_recent_references_meet_and_a_third_drops_the_oldest() {
|
||||
use solidcraft_engine::geom::Plane;
|
||||
let mut app = SolveApp::new(Default::default(), Default::default());
|
||||
app.cam.yaw = 0.0;
|
||||
app.cam.pitch = std::f64::consts::FRAC_PI_2;
|
||||
app.cam.distance = 180.0;
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_line(Vec2::new(-30.0, 20.0), Vec2::new(30.0, 20.0), None, None, Some("top")).unwrap();
|
||||
sk.add_line(Vec2::new(30.0, -20.0), Vec2::new(30.0, 20.0), None, None, Some("right")).unwrap();
|
||||
sk.add_circle(Vec2::new(-10.0, 3.0), 5.0, None, Some("c")).unwrap();
|
||||
sk.add_line(Vec2::new(50.0, -10.0), Vec2::new(50.0, 10.0), None, None, Some("far")).unwrap();
|
||||
let proj = crate::viewport::projection(&app, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)));
|
||||
let to = |p: Vec2| proj.to_screen(Plane::XY.to_world(p)).unwrap();
|
||||
let mut tracking = Tracking::default();
|
||||
let hover = |tracking: &mut Tracking, p: Vec2| tracking.update(&sk, &Plane::XY, &proj, to(p));
|
||||
hover(&mut tracking, Vec2::new(0.0, 20.0));
|
||||
hover(&mut tracking, Vec2::new(30.0, 0.0));
|
||||
let centre = Vec2::ZERO;
|
||||
let near = to(centre) + egui::vec2(4.0, 3.0);
|
||||
assert!(tracking.meeting(&sk, &Plane::XY, &proj, near).unwrap().dist(centre) < 1e-6);
|
||||
tracking.update(&sk, &Plane::XY, &proj, near);
|
||||
let (a, b) = tracking.guide.unwrap();
|
||||
let (c, d) = tracking.remembered_guide.unwrap();
|
||||
assert!(b.dist(centre) < 1e-6 && d.dist(centre) < 1e-6, "both guides end on the crossing");
|
||||
assert!(a.dist(Vec2::new(30.0, 0.0)) < 1e-6 && c.dist(Vec2::new(0.0, 20.0)) < 1e-6);
|
||||
assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(centre) + egui::vec2(20.0, 0.0)).is_none());
|
||||
// Back to the first reference: both stay, in swapped order.
|
||||
hover(&mut tracking, Vec2::new(0.0, 20.0));
|
||||
assert_eq!(tracking.anchor.as_deref(), Some("top"));
|
||||
assert_eq!(tracking.previous.as_deref(), Some("right"));
|
||||
// A circle centre and a midpoint meet the same way.
|
||||
hover(&mut tracking, Vec2::new(-10.0, 3.0));
|
||||
let cross = Vec2::new(0.0, 3.0);
|
||||
assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(cross)).unwrap().dist(cross) < 1e-6);
|
||||
// A third reference drops the oldest. The far edge shares a horizontal with the right edge,
|
||||
// so their axes do not invent the rectangle centre.
|
||||
hover(&mut tracking, Vec2::new(30.0, 0.0));
|
||||
hover(&mut tracking, Vec2::new(50.0, 0.0));
|
||||
assert_eq!(tracking.anchor.as_deref(), Some("far"));
|
||||
assert_eq!(tracking.previous.as_deref(), Some("right"));
|
||||
assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(centre)).is_none());
|
||||
sk.curves.iter_mut().find(|c| c.id == "far").unwrap().id = "gone".into();
|
||||
tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(40.0, 40.0)));
|
||||
assert_eq!(tracking.anchor.as_deref(), Some("right"));
|
||||
assert!(tracking.previous.is_none());
|
||||
assert!(tracking.guide.is_none(), "a removed reference leaves no guide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_and_perpendicular_from_the_previous_point() {
|
||||
let s = sk();
|
||||
|
||||
@@ -1279,6 +1279,9 @@ preference_settings! {
|
||||
suppress_sketch_snap: ModifierCombo = ModifierCombo::CTRL_ALT, PAGE_SKETCH;
|
||||
/// Temporary horizontal/vertical guides from hovered centres and edge midpoints.
|
||||
sketch_tracking_guides: bool = true, PAGE_SKETCH;
|
||||
/// Snap a drawing point to where the two most recent centre or midpoint guides cross.
|
||||
/// Off: the guides stay visual and the cursor does not move (#8694).
|
||||
sketch_recent_snap: bool = false, PAGE_SKETCH;
|
||||
/// Drawing lines a point up with the sketch's points on both axes at once, keeping the
|
||||
/// horizontal or vertical snap to the previous point, and the click adds the alignments
|
||||
/// (#7828). Off by default: only with the previous point, one axis at a time, as before.
|
||||
@@ -1371,6 +1374,8 @@ pub struct PrefsWindow {
|
||||
snapshot: Option<Snapshot>,
|
||||
/// The up axis preference the view follows now (applied when it changes, and at startup).
|
||||
applied_up: Option<String>,
|
||||
/// The appearance last asked of the native window ([`sync_window_theme`]).
|
||||
window_theme: Option<egui::SystemTheme>,
|
||||
/// The perspective angle the view was last drawn with ([`follow_view_fov`]); 0 before the first.
|
||||
drawn_fov: f64,
|
||||
/// The search text of the interface font list.
|
||||
@@ -1859,6 +1864,26 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
|
||||
/// Settings that follow the preferences every frame: the theme when it follows the system, and
|
||||
/// the interface scale.
|
||||
/// Tell the native window which appearance to take (#8983): the operating system's while the
|
||||
/// theme follows it, otherwise the theme's own, so the title bar matches. egui's own window sync
|
||||
/// (`Options::sync_window_theme`) went by the egui theme, which [`crate::theme::apply`] always
|
||||
/// sets to light or dark. A macOS window told to be light stays light and no longer reports the
|
||||
/// system appearance changing, so a theme following the system never turned with it.
|
||||
fn sync_window_theme(app: &mut SolveApp, ctx: &egui::Context, follows_system: bool) {
|
||||
let window = if follows_system {
|
||||
egui::SystemTheme::SystemDefault
|
||||
} else if app.ui.dark {
|
||||
egui::SystemTheme::Dark
|
||||
} else {
|
||||
egui::SystemTheme::Light
|
||||
};
|
||||
if app.prefs_window.window_theme != Some(window) {
|
||||
ctx.options_mut(|o| o.sync_window_theme = false);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::SetTheme(window));
|
||||
app.prefs_window.window_theme = Some(window);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
sync_surface_analysis(app);
|
||||
apply_network(&app.preferences);
|
||||
@@ -1884,10 +1909,12 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if let Some(f) = file {
|
||||
app.ui.dark = f.base.is_dark();
|
||||
}
|
||||
let follows_system = app.preferences.theme == "system" && file.is_none();
|
||||
let custom = file.map(|f| crate::theme::Custom { dark: f.base.is_dark(), tokens: crate::theme_files::resolve(f) });
|
||||
if crate::theme::set_custom(custom) {
|
||||
app.styled = false;
|
||||
}
|
||||
sync_window_theme(app, ctx, follows_system);
|
||||
if crate::theme::set_logo_accent(app.preferences.logo_accent) {
|
||||
app.styled = false;
|
||||
}
|
||||
@@ -4947,6 +4974,7 @@ mod tests {
|
||||
("sketch_state_icons", json!(false), PAGE_SKETCH),
|
||||
("sketch_tracking_guides", json!(false), PAGE_SKETCH),
|
||||
("sketch_align_points", json!(true), PAGE_SKETCH),
|
||||
("sketch_recent_snap", json!(true), PAGE_SKETCH),
|
||||
("msaa", json!(8), PAGE_GRAPHICS),
|
||||
("single_surface_analysis", json!(false), PAGE_GRAPHICS),
|
||||
];
|
||||
@@ -5141,6 +5169,48 @@ mod tests {
|
||||
assert!(!app.ui.dark);
|
||||
}
|
||||
|
||||
/// The System theme leaves the native window to follow the operating system (#8983). A macOS
|
||||
/// window told to be light or dark keeps that appearance and no longer reports the system
|
||||
/// appearance changing, so the app stayed light after the system turned dark. Dark and Light
|
||||
/// still give the window their own appearance, for its title bar.
|
||||
#[test]
|
||||
fn the_system_theme_leaves_the_window_to_follow_the_system() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
// A frame with the system appearance `system`: the window themes the frame asked for.
|
||||
let frame = |h: &mut crate::scenario::Harness, system: egui::Theme| {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))),
|
||||
system_theme: Some(system),
|
||||
..Default::default()
|
||||
};
|
||||
let app = &mut h.app;
|
||||
let out = h.ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
let commands = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default();
|
||||
commands.into_iter().filter_map(|c| if let egui::ViewportCommand::SetTheme(t) = c { Some(t) } else { None }).collect::<Vec<_>>()
|
||||
};
|
||||
let frames = |h: &mut crate::scenario::Harness, system: egui::Theme| (0..3).flat_map(|_| frame(h, system)).collect::<Vec<_>>();
|
||||
let out = h.call("ui.prefs", json!({"theme": "system"}));
|
||||
assert_eq!(out["result"]["theme"], "system", "{out}");
|
||||
frames(&mut h, egui::Theme::Light);
|
||||
assert_eq!(h.app.prefs_window.window_theme, Some(egui::SystemTheme::SystemDefault));
|
||||
assert!(!h.ctx.options(|o| o.sync_window_theme), "egui would tell the window light or dark");
|
||||
assert!(!h.app.ui.dark);
|
||||
// The system turns dark: the app follows, and nothing is asked of the window.
|
||||
assert_eq!(frames(&mut h, egui::Theme::Dark), vec![]);
|
||||
assert!(h.app.ui.dark);
|
||||
assert_eq!(frames(&mut h, egui::Theme::Light), vec![]);
|
||||
assert!(!h.app.ui.dark);
|
||||
// An explicit theme gives the window its appearance, whatever the system's.
|
||||
let out = h.call("ui.prefs", json!({"theme": "light"}));
|
||||
assert_eq!(out["result"]["theme"], "light", "{out}");
|
||||
assert_eq!(frames(&mut h, egui::Theme::Dark), vec![]);
|
||||
assert_eq!(h.app.prefs_window.window_theme, Some(egui::SystemTheme::Light));
|
||||
assert!(!h.app.ui.dark);
|
||||
}
|
||||
|
||||
/// The 3D view in the other theme's colours (#4146): `ui.prefs` sets it, the window keeps its
|
||||
/// theme and the next frame draws the view from the other side, and it is saved with the
|
||||
/// preferences. With a theme file on, the view takes the built-in Light or Dark colours.
|
||||
|
||||
@@ -7,6 +7,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::settings_section(ui, "sketch", app.tr("prefs.sketching"), |ui| {
|
||||
ui_kit::checkbox(ui, app.tr("prefs.sketch_tracking_guides"), &mut app.preferences.sketch_tracking_guides)
|
||||
.on_hover_text(app.tr("prefs.sketch_tracking_guides_note"));
|
||||
ui_kit::checkbox(ui, app.tr("prefs.sketch_recent_snap"), &mut app.preferences.sketch_recent_snap)
|
||||
.on_hover_text(app.tr("prefs.sketch_recent_snap_note"));
|
||||
ui_kit::checkbox(ui, app.tr("prefs.sketch_align_points"), &mut app.preferences.sketch_align_points)
|
||||
.on_hover_text(app.tr("prefs.sketch_align_points_note"));
|
||||
let mut auto = app.session.auto_project;
|
||||
|
||||
@@ -1013,6 +1013,33 @@ fn status_bar_spans_the_window_below_history_in_both_layouts_and_is_covered() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_sketch_status_distinguishes_empty_and_constrained_geometry() {
|
||||
for mode in [LayoutMode::Docked, LayoutMode::Floating] {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.set", json!({"layout":mode}));
|
||||
let corner = |h: &Harness| painted_texts(h).into_iter().filter(|t| t.starts_with("Sketch1 — ")).collect::<Vec<_>>();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(corner(&h), ["Sketch1 — No geometry yet"], "{mode:?}: an empty sketch is not fully constrained");
|
||||
assert!(h.widget("No geometry yet").is_some(), "the palette agrees with the viewport");
|
||||
|
||||
let rectangle = h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(corner(&h), ["Sketch1 — 2 degrees of freedom"], "the rectangle's width and height are free");
|
||||
h.app.run("sketch.constraint.fix", json!({"entities":rectangle["curves"]})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(corner(&h), ["Sketch1 — fully constrained"], "drawn geometry can be fully constrained");
|
||||
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(corner(&h), ["Sketch1 — 2 degrees of freedom"]);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(corner(&h), ["Sketch1 — No geometry yet"], "undoing the geometry restores the empty status");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_names_what_is_edited_and_the_sketch_state_and_replaces_the_views_corner_text() {
|
||||
use crate::status_bar::SketchState;
|
||||
@@ -1020,7 +1047,7 @@ fn status_bar_names_what_is_edited_and_the_sketch_state_and_replaces_the_views_c
|
||||
let corner = |h: &Harness| painted_texts(h).into_iter().filter(|t| t.starts_with("Sketch1 — ")).collect::<Vec<_>>();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(corner(&h), ["Sketch1 — fully constrained"], "without the bar the state stays in the view's corner");
|
||||
assert_eq!(corner(&h), ["Sketch1 — No geometry yet"], "without the bar the state stays in the view's corner");
|
||||
h.call("ui.prefs", json!({"status_bar": true}));
|
||||
h.settle();
|
||||
let contents = crate::status_bar::contents(&h.app);
|
||||
|
||||
@@ -307,6 +307,55 @@ fn centre_and_midpoint_tracking_is_visual_in_idle_and_drawing_modes() {
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_recent_midpoints_snap_where_their_guides_meet() {
|
||||
use solidcraft_engine::geom::Vec2;
|
||||
let mut h = tracking_fixture();
|
||||
// Grid snap at this zoom already rounds a nearby cursor onto the origin. Snap off leaves
|
||||
// the cursor where it is, so only the remembered crossing can pull it to (0, 0).
|
||||
h.app.run("sketch.options", json!({"snap": false})).unwrap();
|
||||
h.app.start("sketch.line");
|
||||
// Top midpoint (0, 20) and the circle centre (-10, 3) meet at (0, 3), which is not the
|
||||
// model origin and not a sketch point. A cursor a fraction of a millimetre away stays put
|
||||
// until both references have been hovered.
|
||||
let cross = Vec2::new(0.0, 3.0);
|
||||
let near = json!({"sketch":[0.4,3.3]});
|
||||
step(&mut h, json!({"move":{"sketch":[0,20]}}));
|
||||
let [x, y] = h.at(&near).unwrap();
|
||||
let pos = egui::pos2(x as f32, y as f32);
|
||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.expect("viewport"));
|
||||
let (at, _) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
|
||||
assert!(at.dist(cross) > 0.2, "one reference does not pin both coordinates: {at:?}");
|
||||
step(&mut h, json!({"move":{"sketch":[-10,3]}}));
|
||||
step(&mut h, json!({"move":{"sketch":[0.4,3.3]}}));
|
||||
let (a, b) = h.app.viewport.tracking.guide.expect("current guide");
|
||||
let (c, d) = h.app.viewport.tracking.remembered_guide.expect("remembered guide");
|
||||
assert!(b.dist(cross) < 1e-6 && d.dist(cross) < 1e-6, "{a:?} {b:?} {c:?} {d:?}");
|
||||
let [x, y] = h.at(&near).unwrap();
|
||||
let pos = egui::pos2(x as f32, y as f32);
|
||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.expect("viewport"));
|
||||
let (at, _) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
|
||||
assert!(at.dist(cross) > 0.2, "the crossing snap is off until asked for: {at:?}");
|
||||
h.call("ui.prefs", json!({"sketch_recent_snap": true}));
|
||||
let (at, id) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
|
||||
assert!(id.is_none(), "{id:?}");
|
||||
assert!(at.dist(cross) < 1e-6, "{at:?}");
|
||||
crate::tool_modifiers::set_target_midpoint(true);
|
||||
let shifted = crate::viewport::sketch_point_at(&h.app, &proj, pos);
|
||||
crate::tool_modifiers::set_target_midpoint(false);
|
||||
let (at, _) = shifted.unwrap();
|
||||
assert!(at.dist(cross) > 0.2, "shift midpoint mode skips the crossing: {at:?}");
|
||||
step(&mut h, json!({"click":{"sketch":[0.4,3.3]}}));
|
||||
let placed = h.app.tool.as_ref().unwrap().pts[0].0;
|
||||
assert!(placed.dist(cross) < 1e-6, "{placed:?}");
|
||||
let curves = h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.len();
|
||||
assert_eq!(curves, 5, "the snap adds no construction geometry");
|
||||
h.call("ui.prefs", json!({"sketch_tracking_guides":false}));
|
||||
h.frames(2);
|
||||
let (at, _) = crate::viewport::sketch_point_at(&h.app, &proj, pos).unwrap();
|
||||
assert!(at.dist(cross) > 0.2, "the preference keeps the old cursor: {at:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_clears_on_escape_sketch_exit_and_preference_and_respects_suppression() {
|
||||
let mut h = tracking_fixture();
|
||||
|
||||
@@ -511,6 +511,16 @@ pub fn refine_snap(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The two most recent midpoints or centres: snap where their axes meet. A free coordinate,
|
||||
// so placing it adds no constraint. Off unless Sketch preferences ask for it. One reference
|
||||
// stays a visual guide. Shift skips this.
|
||||
if !mid_only
|
||||
&& app.preferences.sketch_recent_snap
|
||||
&& best.is_none()
|
||||
&& let Some(at) = app.viewport.tracking.meeting(sk, plane, proj, a)
|
||||
{
|
||||
best = Some((at.dist(lp), "+", at));
|
||||
}
|
||||
// The tool's points and kind (a click takes the tool out of the app while it runs, so the
|
||||
// last hover's are kept: a rectangle's click takes no Line tool inference its hover didn't).
|
||||
let (own, line_tool) = match app.tool.as_ref() {
|
||||
|
||||
@@ -101,6 +101,19 @@ pub(crate) fn navigation_fill(t: &Tokens, fill: NavigationFill) -> Option<egui::
|
||||
}
|
||||
}
|
||||
|
||||
/// The label and icon colour of a slot. An active slot keeps the accent ink on its selected fill
|
||||
/// where that reads; a theme whose selected fill is as dark as its accent (SolidCraft 95's navy)
|
||||
/// gets the theme's selected-text ink instead, so an active toggle never turns into a blank block.
|
||||
pub(crate) fn navigation_ink(t: &Tokens, fill: NavigationFill) -> egui::Color32 {
|
||||
if fill != NavigationFill::Active {
|
||||
return t.form_ink;
|
||||
}
|
||||
if crate::theme::contrast_ratio(t.form_primary, t.selected_bg) >= NAV_ACTIVE_MIN_CONTRAST { t.form_primary } else { t.selected_ink }
|
||||
}
|
||||
|
||||
/// WCAG small-text contrast for an active slot's label on its selected fill.
|
||||
pub(crate) const NAV_ACTIVE_MIN_CONTRAST: f64 = 4.5;
|
||||
|
||||
pub(crate) fn navigation_opacity(idle: Idle, active: bool) -> f32 {
|
||||
match idle {
|
||||
_ if active => 1.0,
|
||||
@@ -135,7 +148,7 @@ pub(crate) fn navigation_button_editable(ui: &mut Ui, rect: Rect, button: Naviga
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
let ink = if fill == NavigationFill::Active { t.form_primary } else { t.form_ink };
|
||||
let ink = navigation_ink(&t, fill);
|
||||
let text = button.labels.then(|| ui.painter().layout_no_wrap(button.label.to_owned(), crate::fonts::medium(NAV_TEXT), ink));
|
||||
let mut x = if button.menu {
|
||||
rect.left() + NAV_MENU_LEFT + if editing { NAV_BORDER } else { 0.0 }
|
||||
@@ -213,3 +226,24 @@ pub(crate) fn navigation_surface(ui: &Ui, rect: Rect, size: Size, editing: bool)
|
||||
.corner_radius(NavigationMetrics::for_size(size).radius);
|
||||
super::Container::paint_frame(ui.painter(), frame, rect.shrink(NAV_BORDER));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod active_ink_tests {
|
||||
use super::{NavigationFill, navigation_fill, navigation_ink};
|
||||
|
||||
#[test]
|
||||
fn active_navigation_slots_stay_readable_in_every_built_in_theme() {
|
||||
let mut themes = vec![("Dark", crate::theme::Tokens::dark()), ("Light", crate::theme::Tokens::light())];
|
||||
themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.as_str(), crate::theme_files::resolve(theme))));
|
||||
let mut failures = Vec::new();
|
||||
for (name, tokens) in themes {
|
||||
let Some(fill) = navigation_fill(&tokens, NavigationFill::Active) else { continue };
|
||||
let ink = navigation_ink(&tokens, NavigationFill::Active);
|
||||
let ratio = crate::theme::contrast_ratio(ink, fill);
|
||||
if ratio < 3.0 {
|
||||
failures.push(format!("{name}: {ratio:.2}:1 ({ink:?} on {fill:?})"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "active navigation slot unreadable:\n{}", failures.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5896,6 +5896,8 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
if let Some(r) = app.viewport.layout.as_ref().map(|l| l.content).filter(|_| !crate::status_bar::shown(app)) {
|
||||
let status = if !ss.report.ok() {
|
||||
crate::i18n::tr("sketch.status_conflict").to_string()
|
||||
} else if !crate::sketch_palette::has_geometry(sk) {
|
||||
crate::i18n::tr("ui.no_geometry_yet").to_string()
|
||||
} else if ss.report.dof == 0 {
|
||||
crate::i18n::tr("sketch.status_fully_constrained").to_string()
|
||||
} else if ss.report.dof == 1 {
|
||||
|
||||
@@ -32,10 +32,18 @@ constraint status and **Finish Sketch** button remain visible.
|
||||
Hover over a circle or arc centre, or the midpoint of a straight sketch edge, then move
|
||||
horizontally or vertically to see a dotted alignment guide. The guide connects that reference
|
||||
to the cursor's projection on the sketch axis, within the viewport; it disappears when you
|
||||
move away from the axis. This works while editing and before a drawing tool's first click.
|
||||
It adds no geometry or constraints. Esc, leaving the viewport, or ending the sketch clears
|
||||
the reference. Preferences › Sketch › **Center and midpoint alignment guides** turns it off;
|
||||
the configured snap-suppression modifier also hides it.
|
||||
move away from the axis. SolidCraft keeps the two most recent of these references and can
|
||||
draw both guides. One guide on its own does not move the cursor, and endpoints are ordinary
|
||||
point snaps rather than remembered references. Preferences › Sketch › **Auto snap to recent
|
||||
centers and midpoints** is off until you turn it on. With it on, a drawing tool snaps the
|
||||
cursor where the horizontal through one reference meets the vertical through the other. On a
|
||||
rectangle, the top midpoint and a side midpoint then meet at the rectangle's centre, so that
|
||||
point can be placed without a construction line. Holding Shift for midpoint-only snapping
|
||||
skips the crossing. This works while editing and before a drawing tool's first click.
|
||||
Placing the point adds no geometry or constraint of its own. Esc, leaving the viewport, or
|
||||
ending the sketch clears the references. **Center and midpoint alignment guides** turns the
|
||||
guides off, which also leaves the cursor unmoved; the configured snap-suppression modifier
|
||||
does the same for that gesture.
|
||||
|
||||
## Geometry
|
||||
|
||||
|
||||
@@ -421,6 +421,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
|
||||
- `sketch_auto_locate` (default false; with `sketch_auto_dimensions`, new shapes are also placed from the sketch origin until fully constrained, as `sketch.draw.dimensioned` with `locate`)
|
||||
- `sketch_conflict_colour` (default true: while the open sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the theme's `sketch_conflict` colour too, not only its glyph or dimension; false: only the glyphs and dimensions are red, in `error`, as before, #7244)
|
||||
- `sketch_exit_view` (`keep` (default: the view stays where it is when a sketch ends), `previous` (back to the view from before the sketch), `unless_rotated` (back unless the sketch view was turned))
|
||||
- `sketch_recent_snap` (default false: while drawing, the cursor snaps where the horizontal guide of one recent circle or arc centre or straight-edge midpoint meets the vertical guide of another; a free coordinate, with no constraint and no construction geometry. Off: those guides stay visual and the cursor does not move. The references come from `sketch_tracking_guides`, #8694)
|
||||
- `sketch_state_icons` (default true: a sketch's icon in History and the browser takes the colour of its solve state, `sketch` under constrained, `sketch_fixed` fully constrained, `error` while its constraints conflict, and its tooltip names the state; false: every sketch row's icon and tooltip as before, #7245)
|
||||
- `sketch_tab_always` (default false: the SKETCH tab stays in the ribbon right after MODEL while no sketch is open; a tool in its CREATE or REFERENCE section then opens Create Sketch, on the selected plane or planar face or else on the plane picked next, and starts in the new sketch, #7904)
|
||||
- `smooth_wheel_zoom` (default true)
|
||||
@@ -464,8 +465,10 @@ the Design camera. See [the integrated Render workspace](render-workspace.md).
|
||||
`ui.prefs {sketch_tracking_guides: true}` enables temporary horizontal and vertical dotted
|
||||
alignment guides from hovered circle/arc centres and straight-edge midpoints (default true).
|
||||
The guide spans the reference and cursor projection, including positions inside a circle.
|
||||
It is visual only: coordinates, snapping and constraints are unchanged. Set the preference
|
||||
to `false` to hide the guides.
|
||||
The two most recent references are kept, and both guides can be drawn. A single guide does
|
||||
not move the cursor. Set the preference to `false` to hide the guides. Snap suppression
|
||||
(Alt, unless rebound) does the same for that gesture. Snapping the cursor to where those
|
||||
guides cross is separate, and off unless `sketch_recent_snap` is true.
|
||||
|
||||
### Continuous camera motion
|
||||
|
||||
|
||||
+9
-5
@@ -68,10 +68,14 @@ temporary file by the app and read back by the server, so both must run on the s
|
||||
Commands with a typed parameter contract (every new command, and more of the older ones as they
|
||||
are converted; `describe_command` shows a command's schemas) can be checked as they are called.
|
||||
`--param-check off|warn|strict` sets the level for a headless session, including a script or
|
||||
recipe given with `--in`; the default is `off`, which changes nothing. `warn` runs the call and
|
||||
adds `param_warnings` to its result, such as ``unknown key `operaton`; did you mean
|
||||
`operation`?`` (a starting script's warnings go to stderr). `strict` refuses the call, and the
|
||||
design is left as it was. Calls bridged to the app are not checked yet.
|
||||
recipe given with `--in`, and for calls bridged to the app; the default is `off`, which changes
|
||||
nothing. `warn` runs the call and adds `param_warnings` to its result, such as ``unknown key
|
||||
`operaton`; did you mean `operation`?`` (a starting script's warnings go to stderr). `strict`
|
||||
refuses the call, and the design is left as it was. Bridged, the server checks each call (and
|
||||
each step of a `batch`) against its own commands' contracts before sending it: `strict` sends
|
||||
nothing that breaks one, and a command the server has no contract for goes to the app unchecked.
|
||||
Use the server from the same release as the app: an app newer than the server may take keys the
|
||||
server's contracts don't know yet, and `strict` would refuse them.
|
||||
|
||||
### Which files the agent can reach
|
||||
|
||||
@@ -277,7 +281,7 @@ The `skill_reference` tool returns these documents from the copies built into So
|
||||
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
|
||||
| `describe_command` | `id` | One command's parameter docs (`params`) and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); `null` for the others. The same as `execute` `engine.describe {command}`. |
|
||||
| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure`, `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure`, `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. |
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true), `joints?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), joints when the design has any (as `joint.inspect` gives them: each origin's occurrence, component and path, the snap as saved, the feature, face, edge or sketch curve (`sketch: {id, name, curve}`) its persistent names refer to, `resolves` and the `error` when it is lost; kind, offset, angle, flip, values, limits, expressions, motion links; `execute` `joint.inspect {joint}` gives one joint), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `measure` | `body?` or `items?` | Area (mm²), bbox, topology counts and `closed` per body. Volume (mm³), volume centre of mass, density and mass are reported only for closed, consistently oriented bodies (null or omitted for open surfaces). Volume/mass totals skip open bodies; `total.open_bodies_skipped` gives their count. Area and topology totals include all bodies. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: faces and edges need only `type`, `body` and `point`; `index` is optional. See below. |
|
||||
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
|
||||
|
||||
+8
-3
@@ -71,12 +71,17 @@ additionally yields after four rows. The UI pumps 1024 units per frame. These ar
|
||||
bounds, not hard real-time guarantees: allocation, codec compression and OS scheduling
|
||||
still have platform-dependent latency.
|
||||
|
||||
Admission caps output at 4096 per dimension and 4 million pixels; pixel scratch is
|
||||
CPU raster admission caps output at 4096 per dimension and 4 million pixels; pixel scratch is
|
||||
estimated at 48 bytes per output pixel (2x supersampling). Other limits: 262144
|
||||
triangles/face overrides/edge polylines, 128 MiB retained source geometry, 384 MiB total
|
||||
estimated memory per job, 512 MiB shared host admission including retained PNGs, and
|
||||
128 million projected pixel tests. The projected-work limit is checked during bounded
|
||||
preparation and may fail an admitted job. Encoded PNG bytes have an additional
|
||||
128 million projected pixel tests. Preflight and Start check projected work using the
|
||||
same clipped, supersampled triangle bounds as the rasterizer, including face opacity
|
||||
overrides. Raster admission reports `projected_pixel_tests`; CPU raytracing and registered
|
||||
GPU executors omit this field and use their own work and memory budgets. Over-budget
|
||||
raster requests are rejected before a job is queued, with advice to reduce image
|
||||
dimensions or visible geometry.
|
||||
The worker retains its bounded preparation check. Encoded PNG bytes have an additional
|
||||
`8 * output_pixels + 65536` cap. Allocator and codec internals are estimates, not an
|
||||
operating-system memory quota. Limits fail with errors rather than allocating unbounded
|
||||
buffers. Completed frames keep their memory reservation while bytes are retained.
|
||||
|
||||
Reference in New Issue
Block a user