mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 19:22:01 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2eb31816c | |||
| d88a7b1e0d | |||
| 3fffb8a9b9 | |||
| 201de37b96 | |||
| 0cb554058c | |||
| c481b24329 | |||
| 0bad690ed4 | |||
| 4c512a243f | |||
| 2b88913228 | |||
| 0e5e689f7c | |||
| ed7c33df66 | |||
| 46bb71b5e1 | |||
| 18928334c7 | |||
| 709133a27a | |||
| faaa92281a | |||
| d8ef6dcf15 | |||
| f56114219b | |||
| 2215a9a50a | |||
| 182d2c6bd8 | |||
| 126e91f95b | |||
| 54e5190955 | |||
| 6ca950789c | |||
| 89e3cb13b1 | |||
| 87400c80ef | |||
| d4e8c0b034 | |||
| 595bc8069c | |||
| 44fe6a0f24 | |||
| 12a9a7f0f9 | |||
| 3749c14ad1 | |||
| 9c4970d2b2 | |||
| 8d13350c30 | |||
| 995b80a78f | |||
| 7d271b3252 | |||
| a3dcc9ca55 | |||
| 2874e782fe | |||
| d8113d096c | |||
| 023b55277f | |||
| 3e2cc277ee | |||
| 5f46248771 | |||
| 176ba5464f |
@@ -30,7 +30,6 @@ on:
|
||||
- "crates/kernel/**"
|
||||
- "crates/geom/**"
|
||||
- "crates/smlib/**"
|
||||
- "crates/step/**"
|
||||
- "crates/io/src/**"
|
||||
- "crates/doc/src/**"
|
||||
- "crates/engine/src/**"
|
||||
@@ -51,7 +50,6 @@ on:
|
||||
- "crates/kernel/**"
|
||||
- "crates/geom/**"
|
||||
- "crates/smlib/**"
|
||||
- "crates/step/**"
|
||||
- "crates/io/src/**"
|
||||
- "crates/doc/src/**"
|
||||
- "crates/engine/src/**"
|
||||
|
||||
Generated
+11
-11
@@ -4234,6 +4234,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore-step"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"solidcore",
|
||||
"solidcore-convert",
|
||||
"solidcore-ops",
|
||||
"solidcore-record",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore-tess"
|
||||
version = "0.1.0"
|
||||
@@ -4478,11 +4488,11 @@ dependencies = [
|
||||
"serde_json",
|
||||
"solidcore-ops",
|
||||
"solidcore-record",
|
||||
"solidcore-step",
|
||||
"solidcore-tess",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-profile",
|
||||
"solidcraft-smlib",
|
||||
"solidcraft-step",
|
||||
"spade",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -4587,16 +4597,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-step"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"solidcore-ops",
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-profile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-testkit"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
+2
-1
@@ -21,7 +21,6 @@ solidcraft-profile = { path = "crates/profile" }
|
||||
solidcraft-sketch = { path = "crates/sketch" }
|
||||
solidcraft-kernel = { path = "crates/kernel" }
|
||||
solidcraft-smlib = { path = "crates/smlib" }
|
||||
solidcraft-step = { path = "crates/step" }
|
||||
solidcraft-render = { path = "crates/render" }
|
||||
solidcraft-dynamics = { path = "crates/dynamics" }
|
||||
solidcraft-gpu = { path = "crates/gpu" }
|
||||
@@ -55,6 +54,8 @@ solidcore-record = { path = "solidcore/crates/solidcore-record" }
|
||||
# The modelling layer (solidcore's layers §1, "Ops"): the record-level calls SolidCraft makes into the
|
||||
# kernel, converting through solidcore-convert.
|
||||
solidcore-ops = { path = "solidcore/crates/solidcore-ops" }
|
||||
# STEP import and export (vendored with solidcore): the Part 21 parser is solidcore-record's.
|
||||
solidcore-step = { path = "solidcore/crates/solidcore-step" }
|
||||
# Geometry made drawable (solidcore's layers §3): the one mesher, SolidCraft's, moved into the
|
||||
# subtree (RFC 0003, item 1.1), with the display mesh and its levels of detail, the types
|
||||
# SolidCraft's meshes are. Vendored with solidcore, in the same subtree.
|
||||
|
||||
@@ -148,14 +148,18 @@ self.addEventListener("activate", (event) => {
|
||||
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
|
||||
return self.registration.unregister();
|
||||
}
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
}
|
||||
const older = (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE);
|
||||
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
|
||||
await sweep();
|
||||
await self.clients.claim();
|
||||
// Pages open when the worker first takes over came from the network, and get their files from it.
|
||||
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
|
||||
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
|
||||
// With no earlier build kept, the pages open when the first worker takes over came from the
|
||||
// network, and get their files from it. After an update a page with no record is not
|
||||
// assumed to be anything: its record may only be late, and the network now serves a newer
|
||||
// build than the page's.
|
||||
if (!older.length) {
|
||||
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
|
||||
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! with what SolidCraft's requests mean ([`SolidCraft`]).
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -12,6 +12,7 @@ use solidcraft_control::{ControlRequest, auth};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_engine::control_auth::APP;
|
||||
use solidcraft_engine::control_pending;
|
||||
use solidcraft_ui_egui::control_switch;
|
||||
|
||||
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
|
||||
/// token in each, removed when the app ends unless another launch has written the file since.
|
||||
@@ -85,40 +86,60 @@ pub fn remove_token_file() {
|
||||
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
|
||||
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
|
||||
|
||||
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
/// Open the app's control channel (at launch, or turned on in the settings): its listener, its
|
||||
/// token file and the discovery file. Stopping it removes both files again.
|
||||
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Result<control_switch::Started, control_switch::StartError> {
|
||||
use control_switch::StartError;
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// Every connection must show the token before anything runs (docs/control-protocol.md).
|
||||
match APP.env_token().or_else(server::new_token) {
|
||||
Some(token) => {
|
||||
let listener = match listen_on {
|
||||
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
|
||||
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
|
||||
};
|
||||
match listener {
|
||||
Ok(l) => {
|
||||
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
|
||||
if matches!(listen_on, auth::Listen::Default) {
|
||||
solidcraft_engine::note!(
|
||||
"solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"
|
||||
);
|
||||
}
|
||||
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
|
||||
match APP.write_current_port(port) {
|
||||
Ok(_) => {
|
||||
if let Ok(mut p) = APP_PORT.lock() {
|
||||
*p = Some((port, token));
|
||||
}
|
||||
}
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
|
||||
let Some(token) = APP.env_token().or_else(server::new_token) else {
|
||||
solidcraft_engine::note!("solidcraft: control server not started: no random token available");
|
||||
return Err(StartError::Failed("no random token available".into()));
|
||||
};
|
||||
let listener = match listen_on {
|
||||
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
|
||||
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
|
||||
};
|
||||
let listener = listener.map_err(|e| {
|
||||
solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}");
|
||||
match (listen_on, e.kind()) {
|
||||
// In use; Windows reports a port another program holds exclusively as denied.
|
||||
(auth::Listen::Exact(port), std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(port),
|
||||
(auth::Listen::Default, std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(APP.default_port),
|
||||
_ => StartError::Failed(e.to_string()),
|
||||
}
|
||||
})?;
|
||||
let (port, stop) =
|
||||
listen_on_listener(listener, token.clone(), tx, ctx, false).ok_or_else(|| StartError::Failed("the listener did not start".into()))?;
|
||||
if matches!(listen_on, auth::Listen::Default) {
|
||||
solidcraft_engine::note!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
|
||||
}
|
||||
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
|
||||
match APP.write_current_port(port) {
|
||||
Ok(_) => {
|
||||
if let Ok(mut p) = APP_PORT.lock() {
|
||||
*p = Some((port, token.clone()));
|
||||
}
|
||||
}
|
||||
None => solidcraft_engine::note!("solidcraft: control server not started: no random token available"),
|
||||
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
|
||||
}
|
||||
rx
|
||||
let stop = Box::new(move || {
|
||||
stop.stop();
|
||||
if let Ok(mut p) = APP_PORT.lock()
|
||||
&& p.as_ref().is_some_and(|(held, _)| *held == port)
|
||||
{
|
||||
*p = None;
|
||||
}
|
||||
APP.remove_current_port(port, &token);
|
||||
if let Some(path) = APP.token_path(port) {
|
||||
auth::remove_token_file(&path, &token);
|
||||
if let Ok(mut files) = TOKEN_FILES.lock() {
|
||||
files.retain(|(p, _)| *p != path);
|
||||
}
|
||||
}
|
||||
solidcraft_engine::note!("solidcraft: control channel on 127.0.0.1:{port} closed");
|
||||
});
|
||||
Ok(control_switch::Started { port, requests: rx, stop })
|
||||
}
|
||||
|
||||
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
|
||||
@@ -137,12 +158,19 @@ pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u1
|
||||
return None;
|
||||
}
|
||||
};
|
||||
listen_on_listener(listener, token, tx, ctx, true)
|
||||
// The chat's endpoint lives as long as the app.
|
||||
listen_on_listener(listener, token, tx, ctx, true).map(|(port, _)| port)
|
||||
}
|
||||
|
||||
/// Serve `listener` (loopback) with `token`; the port it listens on.
|
||||
/// Serve `listener` (loopback) with `token`; the port it listens on and how to stop it.
|
||||
/// `patient`: every request may wait as long as a batch (a person decides on it).
|
||||
fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
|
||||
fn listen_on_listener(
|
||||
listener: TcpListener,
|
||||
token: String,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
patient: bool,
|
||||
) -> Option<(u16, server::Stop)> {
|
||||
let port = listener.local_addr().ok()?.port();
|
||||
match APP.token_path(port) {
|
||||
Some(path) => match APP.write_token_file(&path, &token) {
|
||||
@@ -164,8 +192,8 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
|
||||
)
|
||||
}
|
||||
}
|
||||
match hub(ctx).listen(listener, token, tx, patient) {
|
||||
Ok(()) => Some(port),
|
||||
match hub(ctx).listen_stoppable(listener, token, tx, patient) {
|
||||
Ok(stop) => Some((port, stop)),
|
||||
Err(e) => {
|
||||
solidcraft_engine::note!("solidcraft: control server not started: {e}");
|
||||
None
|
||||
|
||||
@@ -1320,12 +1320,11 @@ fn main() -> eframe::Result {
|
||||
));
|
||||
}
|
||||
mark("viewport pipelines");
|
||||
// `--control`/the environment name a port; otherwise the preference (on by default)
|
||||
// opens the default one (docs/control-protocol.md).
|
||||
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
|
||||
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
// `--control`/the environment name a port for this run; otherwise the preference (off
|
||||
// by default) opens its port, and turning it off or on later takes effect at once
|
||||
// (docs/control-protocol.md).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
solidcraft_ui_egui::control_switch::launch(&mut app, Box::new(move |listen| control_server::start(listen, ctx.clone())), control_port);
|
||||
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
|
||||
// on a free port the first time the chat connects (docs/agent-chat.md).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
|
||||
+41
-41
@@ -931,7 +931,7 @@
|
||||
"ai.http.confirm": "السماح لهذه النقطة الطرفية",
|
||||
"ai.http.warning": "سيُرسَل مفتاح API والمحادثة دون تشفير. يمكن لأي شخص لديه وصول إلى الشبكة قراءتهما أو تغييرهما. استخدم هذا الخيار فقط على شبكة تثق بها.",
|
||||
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال",
|
||||
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
|
||||
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، على المنفذ أدناه؛ وإذا كان 27878 مستخدمًا فيُستخدم المنفذ الحر التالي). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يسري فورًا. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
|
||||
"ai.prefs.availability": "التوفر",
|
||||
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
|
||||
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
|
||||
@@ -1890,13 +1890,13 @@
|
||||
"help.sketch.construction_line": "أنشئ خطًا إنشائيًا لا نهائيًا يمر بنقطتين. استخدمه لتخطيط الرسم دون تقسيم مقاطعه.",
|
||||
"help.sketch.construction_line.angle": "أنشئ خطًا إنشائيًا لا نهائيًا بزاوية من محور X للرسم. اختر النقطة ثم اكتب الزاوية، أو اختر اتجاهه.",
|
||||
"help.sketch.construction_line.bisector": "اختر خطين مستقيمين متقاطعين في الرسم، ثم اختر أحد منصفي الزاوية. يتبع الناتج الخطين اللذين أُنشئ منهما.",
|
||||
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له. انقر بزر الفأرة الأيمن لتغيير المرجع.",
|
||||
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور X للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له، أو أدخل إزاحته. تصبح الإزاحة المُدخلة مسافة قائدة. انقر بزر الفأرة الأيمن لتغيير المرجع.",
|
||||
"help.sketch.construction_line.perpendicular": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا عموديًا عليه. انقر بزر الفأرة الأيمن لتغيير المرجع.",
|
||||
"help.sketch.construction_line.tangent_at": "أنشئ مماسًا عند نقطة على دائرة أو قوس دائري، أو عند نهاية منحنى شريحي مفتوح أو قطع مخروطي مفتوح. اختر المنحنى ثم نقطة التماس.",
|
||||
"help.sketch.construction_line.tangent_external": "اختر دائرة أو قوسًا دائريًا، ثم نقطة خارجية وحل المماس. تعرض المعاينة نقاط التماس الواقعة على القوس المرسوم فقط.",
|
||||
"help.sketch.construction_line.tangent_two": "اختر دائرتين أو قوسين دائريين ثم اختر مماسًا مشتركًا من المعاينة. يجب أن تقع نقطتا التماس على المنحنيين المرسومين.",
|
||||
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور Y للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
|
||||
"help.sketch.copy": "ينسخ منحنيات الرسم ونقاطه المحددة مع القيود والأبعاد بينها. الصقها في هذا الرسم أو في رسم آخر.",
|
||||
"help.sketch.create": "يبدأ رسمًا ثنائي الأبعاد جديدًا على مستوى أو على وجه مسطح من النموذج. يبدأ كل بثق ودوران وكنس برسم.",
|
||||
"help.sketch.cut": "ينسخ هندسة الرسم المحددة ويحذفها من الرسم بخطوة تراجع واحدة. الصقها في مكان آخر من هذا الرسم أو في رسم آخر.",
|
||||
@@ -3224,10 +3224,10 @@
|
||||
"render.category_paint": "طلاء",
|
||||
"render.category_plastic": "بلاستيك",
|
||||
"render.category_wood": "خشب",
|
||||
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
|
||||
"render.caustics_area_lights": "تجمّع الضوء من الأضواء المساحية",
|
||||
"render.caustics_depth": "عمق مسار تجمّع الضوء",
|
||||
"render.caustics_enable": "تجمّع الضوء عبر الزجاج من مصادر نقطية واتجاهية",
|
||||
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
|
||||
"render.caustics_environment": "تجمّع الضوء من البيئة",
|
||||
"render.caustics_help": "يتحكم عدد الفوتونات في الضجيج وكلفة الحساب. يوازن نصف قطر التجميع بين التفاصيل الدقيقة والتقدير الأنعم. يتطلب CPU إضاءة غير مباشرة.",
|
||||
"render.caustics_path_required": "يتطلب تجمّع الضوء على CPU إضاءة غير مباشرة.",
|
||||
"render.caustics_photons": "عدد الفوتونات",
|
||||
@@ -3235,7 +3235,7 @@
|
||||
"render.check_glass_cpu": "يدعم CPU صفائح زجاج مستوية وصناديق محاذية للمحاور بعد التحقق منها. تتطلب الشبكات المغلقة العامة والتعشيق والتشتت الداخلي إضاءة غير مباشرة. لا تزال الظلال النافذة عبر الزجاج المغلق غير مدعومة.",
|
||||
"render.check_glass_gpu": "يدعم GPU أوساط زجاج منفصلة متحققًا منها، أو تعشيقًا متحققًا منه عند تفعيله صراحةً. يتطلب التعشيق أغلفة خارجية محدبة وأجسامًا داخلية محتواة بالكامل وحدودًا غير متلامسة.",
|
||||
"render.check_media_remaining": "لا تزال شبكات الكثافة والإزاحة والتكامل الطيفي الكامل ونقل حالة الاستقطاب غير متاحة.",
|
||||
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة الضجيج. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة ضجيج الأسطح.",
|
||||
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة التشويش. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة تشويش الأسطح.",
|
||||
"render.check_opaque": "الشفافية: أسطح معتمة فقط؛ ألفا الخلفية مدعومة.",
|
||||
"render.check_sorted_alpha": "الشفافية: تقريب ألفا مرتّب.",
|
||||
"render.check_transmission": "الشفافية: نفاذية فيزيائية.",
|
||||
@@ -3441,8 +3441,8 @@
|
||||
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة التشويش. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة التشويش. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
|
||||
"render.cpu_threads": "خيوط المعالجة",
|
||||
"render.cpu_threads_auto": "تلقائي",
|
||||
@@ -3458,40 +3458,40 @@
|
||||
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
|
||||
"render.cpu_warm_cool": "دافئ وبارد",
|
||||
"render.denoise_class_background": "متجاوز: خلفية",
|
||||
"render.denoise_class_coverage": "متجاوز: تغطية جزئية",
|
||||
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
|
||||
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
|
||||
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
|
||||
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
|
||||
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
|
||||
"render.denoise_class_lens": "متجاوز: عمق المجال",
|
||||
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
|
||||
"render.denoise_class_nested": "متجاوز: وسط متداخل",
|
||||
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
|
||||
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
|
||||
"render.denoise_class_background": "مستبعد: خلفية",
|
||||
"render.denoise_class_coverage": "مستبعد: تغطية جزئية",
|
||||
"render.denoise_class_degenerate": "مستبعد: مسار منحلّ",
|
||||
"render.denoise_class_depth": "مستبعد: حد عمق المسار",
|
||||
"render.denoise_class_emitter": "مستبعد: مصدر ضوء",
|
||||
"render.denoise_class_exit": "مستبعد: خروج غير متوافق",
|
||||
"render.denoise_class_identity": "مستبعد: حد هوية الدليل",
|
||||
"render.denoise_class_lens": "مستبعد: عمق المجال",
|
||||
"render.denoise_class_material": "مستبعد: مادة غير مدعومة",
|
||||
"render.denoise_class_nested": "مستبعد: وسط متداخل",
|
||||
"render.denoise_class_optical": "مؤهل: سطح مرئي عبر الانكسار",
|
||||
"render.denoise_class_reflection": "مستبعد: نهاية عاكسة",
|
||||
"render.denoise_class_surface": "مؤهل: سطح",
|
||||
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
|
||||
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
|
||||
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
|
||||
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
|
||||
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
|
||||
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_class_tir": "مستبعد: انعكاس داخلي كلي",
|
||||
"render.denoise_class_transmission": "مستبعد: نفاذ إضافي",
|
||||
"render.denoise_class_unavailable": "مستبعد: بلا دليل",
|
||||
"render.denoise_class_volume": "مستبعد: تشتت حجمي",
|
||||
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المستبعدة: {skipped}",
|
||||
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة التشويش",
|
||||
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
|
||||
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
|
||||
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمستبعدة حتى عند إيقاف إزالة التشويش. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
|
||||
"render.denoise_diagnostics_title": "تشخيصات إزالة التشويش",
|
||||
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
|
||||
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
|
||||
"render.denoise_format_mask": "قناع PNG",
|
||||
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
|
||||
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
|
||||
"render.denoise_optical_filter": "تصفية المرايا والزجاج",
|
||||
"render.denoise_optical_filter_help": "تصفية الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة الضجيج من الأسطح المعتمة فقط.",
|
||||
"render.denoise_passes": "مرات الترشيح",
|
||||
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
|
||||
"render.denoise_strength": "قوة إزالة الضوضاء",
|
||||
"render.denoise_mask_copy_help": "صدّر قناع إزالة التشويش بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
|
||||
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة التشويش وصيّر صورة لفحص قناعها.",
|
||||
"render.denoise_optical_filter": "ترشيح المرايا والزجاج",
|
||||
"render.denoise_optical_filter_help": "يرشّح الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة التشويش من الأسطح المعتمة فقط.",
|
||||
"render.denoise_passes": "تمريرات الترشيح",
|
||||
"render.denoise_settings_help": "تُنعّم تمريرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح الذي يطبّقه كل تمرير. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
|
||||
"render.denoise_strength": "قوة إزالة التشويش",
|
||||
"render.denoise_view_image": "الصورة المصيّرة",
|
||||
"render.denoise_view_mask": "قناع إزالة الضوضاء",
|
||||
"render.denoise_view_mask": "قناع إزالة التشويش",
|
||||
"render.diag_bodies": "الأجسام المرئية",
|
||||
"render.diag_job_details": "تفاصيل المهمة الملتقطة",
|
||||
"render.diag_last_check": "آخر فحص مسبق",
|
||||
@@ -4047,7 +4047,7 @@
|
||||
"render.material_v_up": "اتجاه V للنسيج يشير للأعلى في الصورة",
|
||||
"render.material_weight": "وزن الطبقة (0–1)",
|
||||
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
|
||||
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
|
||||
"render.metal_energy_compensation_help": "عوّض الضوء المفقود بين الأوجه المجهرية للمعادن الخشنة. يتطلب التصيير على CPU إضاءة غير مباشرة.",
|
||||
"render.method_cpu": "تتبع الأشعة على CPU",
|
||||
"render.method_gpu": "تتبع المسارات على GPU",
|
||||
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
|
||||
@@ -4507,7 +4507,7 @@
|
||||
"render.surface_import.source": "المصدر",
|
||||
"render.surface_import.title": "استيراد صورة",
|
||||
"render.texture_filtering": "ترشيح تفاصيل الخامات",
|
||||
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً.",
|
||||
"render.texture_filtering_help": "استخدم مستويات MIP والترشيح متباين الخواص لتقليل تعرّج الخامات عند التصغير. يستهلك ذاكرة أكبر؛ وعند إيقافه يبقى الترشيح السابق.",
|
||||
"render.tone_aces_fitted": "ACES مُلاءَم",
|
||||
"render.tone_reinhard": "راينهارد",
|
||||
"render.transmission_scope": "يدعم زجاج GPU الأوساط المنفصلة المعتمدة. لا تتوفر الأوساط المتداخلة غير المقيدة ولا مقدّر نفاذ خالٍ من التشويش.",
|
||||
@@ -4552,7 +4552,7 @@
|
||||
"render.use_preview": "استخدام المعاينة المدمجة",
|
||||
"render.volume_albedo": "بياض التشتت · RGB خطي",
|
||||
"render.volume_anisotropy": "متوسط جيب تمام الطور",
|
||||
"render.volume_denoise_required": "أوقف تقليل الضوضاء لاستخدام الغلاف الجوي.",
|
||||
"render.volume_denoise_required": "أوقف تقليل التشويش لاستخدام الغلاف الجوي.",
|
||||
"render.volume_enable": "تفعيل الوسط المشارك",
|
||||
"render.volume_extinction": "معامل الإخماد",
|
||||
"render.volume_glass_required": "لا يمكن الجمع بين الغلاف الجوي والزجاج في هذا الإعداد.",
|
||||
@@ -5041,7 +5041,7 @@
|
||||
"ui.agent_chat_permission": "يطلب الوكيل تشغيل: {title}",
|
||||
"ui.agent_chat_pick": "الوكيل",
|
||||
"ui.agent_chat_pick_context": "اختيار السياق",
|
||||
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج ليطّلع عليها الوكيل، ثم اضغط «موافق»",
|
||||
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج، ثم اضغط «موافق»: تُدرج في الرسالة كشارة في موضع المؤشر (كتابة @ تفعل الشيء نفسه)",
|
||||
"ui.agent_chat_placeholder": "اطلب من الوكيل تغيير التصميم…",
|
||||
"ui.agent_chat_placeholder_busy": "ضع رسالة في قائمة الانتظار لحين انتهاء الوكيل (Ctrl+Enter: أرسل الآن)",
|
||||
"ui.agent_chat_pop_out": "فتح في نافذة مستقلة يمكنك نقلها إلى شاشة أخرى",
|
||||
|
||||
@@ -931,7 +931,7 @@
|
||||
"ai.http.confirm": "Allow for this endpoint",
|
||||
"ai.http.warning": "The API key and conversation will be sent unencrypted. Anyone with access to the network may read or change them. Use this only on a network you trust.",
|
||||
"ai.prefs.allow_control": "Let AI agents connect",
|
||||
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.",
|
||||
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, on the port below; 27878 takes the next free one when it is in use). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect at once. Off: nothing listens unless you start SolidCraft with --control.",
|
||||
"ai.prefs.availability": "Availability",
|
||||
"ai.prefs.background_commands": "Run slow commands in the background",
|
||||
"ai.prefs.connect_apps": "Add SolidCraft to AI apps",
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
"Expected result (optional)": "Résultat attendu (facultatif)",
|
||||
"Explode": "Éclater",
|
||||
"Explode Text": "Décomposer le texte",
|
||||
"Export 3MF…": "Exporter 3MF…",
|
||||
"Export Animation…": "Exporter l'animation…",
|
||||
"Export Bend Table…": "Exporter la table des plis…",
|
||||
"Export CSV…": "Exporter CSV…",
|
||||
@@ -226,6 +227,10 @@
|
||||
"Export DXF": "Exporter en DXF",
|
||||
"Export DXF…": "Exporter en DXF…",
|
||||
"Export JSON…": "Exporter JSON…",
|
||||
"Export OBJ…": "Exporter OBJ…",
|
||||
"Export STEP…": "Exporter STEP…",
|
||||
"Export STL…": "Exporter STL…",
|
||||
"Export URDF…": "Exporter URDF…",
|
||||
"Export design library...": "Exporter la bibliothèque de la conception...",
|
||||
"Export hidden edges as dashed lines": "Exporter les arêtes cachées en tirets",
|
||||
"Exported by SolidWorks, which usually saves Y up": "Exporté par SolidWorks, qui enregistre généralement en Y vers le haut",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"en": { "ui.agent_chat_transcript_assistant": "Assistant:", "ui.agent_chat_transcript_user": "User:" },
|
||||
"ar": { "ui.agent_chat_transcript_assistant": "المساعد:", "ui.agent_chat_transcript_user": "المستخدم:" },
|
||||
"de": { "ui.agent_chat_transcript_assistant": "Assistent:", "ui.agent_chat_transcript_user": "Benutzer:" },
|
||||
"he": { "ui.agent_chat_transcript_assistant": "עוזר:", "ui.agent_chat_transcript_user": "משתמש:" },
|
||||
"ja": { "ui.agent_chat_transcript_assistant": "アシスタント:", "ui.agent_chat_transcript_user": "ユーザー:" },
|
||||
"ko": { "ui.agent_chat_transcript_assistant": "어시스턴트:", "ui.agent_chat_transcript_user": "사용자:" },
|
||||
"vi": { "ui.agent_chat_transcript_assistant": "Trợ lý:", "ui.agent_chat_transcript_user": "Người dùng:" },
|
||||
"zh": { "ui.agent_chat_transcript_assistant": "助手:", "ui.agent_chat_transcript_user": "用户:" }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"en": { "ui.agent_chat_model_search_or_id": "Search, or type any model id…" },
|
||||
"ar": { "ui.agent_chat_model_search_or_id": "ابحث، أو اكتب أي معرّف نموذج…" },
|
||||
"de": { "ui.agent_chat_model_search_or_id": "Suchen oder eine beliebige Modell-ID eingeben…" },
|
||||
"he": { "ui.agent_chat_model_search_or_id": "חיפוש, או הקלדת מזהה מודל כלשהו…" },
|
||||
"ja": { "ui.agent_chat_model_search_or_id": "検索、または任意のモデル ID を入力…" },
|
||||
"ko": { "ui.agent_chat_model_search_or_id": "검색하거나 모델 ID를 직접 입력…" },
|
||||
"vi": { "ui.agent_chat_model_search_or_id": "Tìm, hoặc nhập mã mô hình bất kỳ…" },
|
||||
"zh": { "ui.agent_chat_model_search_or_id": "搜索,或输入任意模型 ID…" }
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"ar": {
|
||||
"browser.straighten_angle": "اتجاه الحافة",
|
||||
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط تقويم.",
|
||||
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط «تقويم».",
|
||||
"browser.straighten_click_a": "انقر الصورة عند أحد طرفي حافة يجب أن تكون مستقيمة، أو اكتب إحداثياتها.",
|
||||
"browser.straighten_click_b": "انقر الطرف الآخر للحافة، أو اكتب إحداثياته.",
|
||||
"browser.straighten_horizontal": "أفقي",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"en": {
|
||||
"prefs.constraint_glyph_size": "Constraint glyph size",
|
||||
"prefs.constraint_glyph_size_note": "How large constraint glyphs and their letters or icons are drawn in a sketch. 100% is the usual size; glyphs keep this screen size at every zoom."
|
||||
},
|
||||
"ar": {
|
||||
"prefs.constraint_glyph_size": "حجم رموز القيود",
|
||||
"prefs.constraint_glyph_size_note": "حجم رسم رموز القيود وحروفها أو أيقوناتها في الرسم. 100% هو الحجم المعتاد؛ وتحتفظ الرموز بهذا الحجم على الشاشة عند أي مستوى تكبير."
|
||||
},
|
||||
"de": {
|
||||
"prefs.constraint_glyph_size": "Größe der Bindungssymbole",
|
||||
"prefs.constraint_glyph_size_note": "Wie groß Bindungssymbole samt Buchstaben oder Icons in einer Skizze gezeichnet werden. 100 % ist die übliche Größe; die Symbole behalten diese Bildschirmgröße bei jedem Zoom."
|
||||
},
|
||||
"fr": {
|
||||
"prefs.constraint_glyph_size": "Taille des symboles de contraintes",
|
||||
"prefs.constraint_glyph_size_note": "Taille à laquelle les symboles de contraintes, avec leurs lettres ou icônes, sont dessinés dans une esquisse. 100 % est la taille habituelle ; les symboles gardent cette taille à l'écran quel que soit le zoom."
|
||||
},
|
||||
"he": {
|
||||
"prefs.constraint_glyph_size": "גודל סמלי האילוצים",
|
||||
"prefs.constraint_glyph_size_note": "הגודל שבו מצוירים סמלי האילוצים, עם האותיות או הסמלונים שלהם, בשרטוט. 100% הוא הגודל הרגיל; הסמלים שומרים על גודל המסך הזה בכל זום."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.constraint_glyph_size": "拘束グリフのサイズ",
|
||||
"prefs.constraint_glyph_size_note": "スケッチ内の拘束グリフとその文字やアイコンを描く大きさ。100% が通常のサイズです。グリフはどのズームでもこの画面サイズを保ちます。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.constraint_glyph_size": "구속 조건 기호 크기",
|
||||
"prefs.constraint_glyph_size_note": "스케치에서 구속 조건 기호와 그 문자나 아이콘을 그리는 크기입니다. 100%가 기본 크기이며, 기호는 확대/축소와 관계없이 이 화면 크기를 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"prefs.constraint_glyph_size": "Kích thước biểu tượng ràng buộc",
|
||||
"prefs.constraint_glyph_size_note": "Kích thước vẽ biểu tượng ràng buộc cùng chữ cái hoặc biểu tượng bên trong trong bản phác. 100% là kích thước thông thường; biểu tượng giữ kích thước màn hình này ở mọi mức thu phóng."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.constraint_glyph_size": "约束符号大小",
|
||||
"prefs.constraint_glyph_size_note": "草图中约束符号及其字母或图标的绘制大小。100% 为常规大小;任意缩放比例下符号都保持此屏幕大小。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"en": {
|
||||
"ui.construct_planes": "Planes",
|
||||
"ui.construct_axes": "Axes",
|
||||
"ui.construct_points": "Points",
|
||||
"prefs.construct_menu_groups": "Group Construct tools by entity type",
|
||||
"prefs.construct_menu_groups_help": "Show Planes, Axes and Points headings. Off keeps the original menu order."
|
||||
},
|
||||
"ar": {
|
||||
"ui.construct_planes": "المستويات",
|
||||
"ui.construct_axes": "المحاور",
|
||||
"ui.construct_points": "النقاط",
|
||||
"prefs.construct_menu_groups": "تجميع أدوات الإنشاء حسب نوع العنصر",
|
||||
"prefs.construct_menu_groups_help": "إظهار عناوين المستويات والمحاور والنقاط. يحافظ التعطيل على ترتيب القائمة الأصلي."
|
||||
},
|
||||
"he": {
|
||||
"ui.construct_planes": "מישורים",
|
||||
"ui.construct_axes": "צירים",
|
||||
"ui.construct_points": "נקודות",
|
||||
"prefs.construct_menu_groups": "קיבוץ כלי הבנייה לפי סוג ישות",
|
||||
"prefs.construct_menu_groups_help": "הצגת כותרות מישורים, צירים ונקודות. כיבוי שומר על סדר התפריט המקורי."
|
||||
},
|
||||
"ja": {
|
||||
"ui.construct_planes": "平面",
|
||||
"ui.construct_axes": "軸",
|
||||
"ui.construct_points": "点",
|
||||
"prefs.construct_menu_groups": "構築ツールを要素の種類でグループ化",
|
||||
"prefs.construct_menu_groups_help": "平面、軸、点の見出しを表示します。オフにすると元のメニュー順序を維持します。"
|
||||
},
|
||||
"zh": {
|
||||
"ui.construct_planes": "平面",
|
||||
"ui.construct_axes": "轴",
|
||||
"ui.construct_points": "点",
|
||||
"prefs.construct_menu_groups": "按实体类型分组构造工具",
|
||||
"prefs.construct_menu_groups_help": "显示平面、轴和点标题。关闭时保留原始菜单顺序。"
|
||||
},
|
||||
"de": {
|
||||
"ui.construct_planes": "Ebenen",
|
||||
"ui.construct_axes": "Achsen",
|
||||
"ui.construct_points": "Punkte",
|
||||
"prefs.construct_menu_groups": "Konstruktionswerkzeuge nach Elementtyp gruppieren",
|
||||
"prefs.construct_menu_groups_help": "Zeigt Überschriften für Ebenen, Achsen und Punkte. Aus behält die ursprüngliche Menüreihenfolge bei."
|
||||
},
|
||||
"ko": {
|
||||
"ui.construct_planes": "평면",
|
||||
"ui.construct_axes": "축",
|
||||
"ui.construct_points": "점",
|
||||
"prefs.construct_menu_groups": "요소 유형별로 구성 도구 그룹화",
|
||||
"prefs.construct_menu_groups_help": "평면, 축, 점 제목을 표시합니다. 끄면 원래 메뉴 순서를 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"ui.construct_planes": "Các mặt phẳng",
|
||||
"ui.construct_axes": "Các trục",
|
||||
"ui.construct_points": "Các điểm",
|
||||
"prefs.construct_menu_groups": "Nhóm công cụ dựng theo loại đối tượng",
|
||||
"prefs.construct_menu_groups_help": "Hiện tiêu đề Mặt phẳng, Trục và Điểm. Tắt để giữ thứ tự menu ban đầu."
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@
|
||||
"prefs.export_name": "اسم ملف التصدير",
|
||||
"prefs.export_name.body": "اسم الجسم المُصدَّر (الأول إن تعددت الأجسام)",
|
||||
"prefs.export_name.design": "اسم التصميم",
|
||||
"prefs.export_name_hint": "مثلاً {design}-{body}",
|
||||
"prefs.export_name_hint": "مثلًا {design}-{body}",
|
||||
"prefs.export_name_sample": "تصدير هذا التصميم يقترح: {name}",
|
||||
"prefs.export_name_tip": "اسم الملف الذي يقترحه ملف › تصدير وحفظ كشبكة. تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغاً لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
|
||||
"prefs.export_name_tip": "اسم الملف الذي يقترحه «ملف › تصدير» و«حفظ كشبكة». تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغًا لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
|
||||
},
|
||||
"de": {
|
||||
"prefs.export": "Export",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"ar": {
|
||||
"prefs.fillet_legacy_radius": "استخدم نصف قطر Fillet الصلب الافتراضي السابق",
|
||||
"prefs.fillet_legacy_radius.help": "مطفأ افتراضيًا. عند التشغيل، يفتح Fillet الصلب الجديد عند الرقم 2 مقروءًا بوحدات التصميم (\"2 بوصة\" في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح عند نصف القطر الذي تُسلَّم به الأداة، 2 مم، معروضًا بوحدات التصميم (\"0.07874 بوصة\" في تصميم بالبوصة)."
|
||||
"prefs.fillet_legacy_radius": "استخدام نصف القطر الافتراضي السابق للتقويس المصمت",
|
||||
"prefs.fillet_legacy_radius.help": "معطّل افتراضيًا. عند التفعيل، يفتح التقويس المصمت الجديد على الرقم 2 مقروءًا بوحدات التصميم («2 بوصة» في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح على نصف القطر الافتراضي للأداة، 2 مم، معروضًا بوحدات التصميم («0.07874 بوصة» في تصميم بالبوصة)."
|
||||
},
|
||||
"de": {
|
||||
"prefs.fillet_legacy_radius": "Den vorherigen Standardradius für Solid-Fillet verwenden",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"ui.first_dimension_scales_about_origin_tip": "On: the drawing always grows or shrinks about the sketch origin, so a shape drawn away from the origin moves. Off: a drawing that nothing ties to the origin scales about its own middle and stays where it was drawn."
|
||||
},
|
||||
"ar": {
|
||||
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم التخطيطي",
|
||||
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر الرسم أو يصغر دائمًا حول أصل الرسم التخطيطي، فيتحرك الشكل المرسوم بعيدًا عن الأصل. عند الإيقاف: الرسم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى في مكانه."
|
||||
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم",
|
||||
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر المرسوم أو يصغر دائمًا حول أصل الرسم، فإذا كان الشكل مرسومًا بعيدًا عن الأصل تحرّك. عند الإيقاف: المرسوم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى حيث رُسم."
|
||||
},
|
||||
"de": {
|
||||
"ui.first_dimension_scales_about_origin": "Um den Skizzenursprung skalieren",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"prefs.graphics_api_auto": "تلقائي",
|
||||
"prefs.graphics_api_help": "اختر واجهة الرسوميات المستخدمة لفتح SolidCraft. إذا تعذّر فتح النافذة عند التشغيل التالي، فستُستعاد السابقة.",
|
||||
"prefs.graphics_api_override": "يتحكم إعداد عند التشغيل في واجهة الرسوميات. سيُطبّق الاختيار المحفوظ عند فتح SolidCraft بالطريقة العادية.",
|
||||
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصميمات غير المحفوظة قبل الإغلاق.",
|
||||
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصاميم غير المحفوظة قبل الإغلاق.",
|
||||
"prefs.graphics_api_restart_unavailable": "أعد تشغيل SolidCraft لتطبيق واجهة الرسوميات المحفوظة.",
|
||||
"prefs.graphics_api_running": "الحالية: {current}. المختارة للتشغيل التالي: {selected}.",
|
||||
"prefs.graphics_api_save_failed": "تعذّر حفظ اختيار واجهة الرسوميات: {error}",
|
||||
@@ -29,7 +29,7 @@
|
||||
"prefs.graphics_api_start_reverted": "لم يفتح التشغيل السابق باستخدام {requested} النافذة. استعاد SolidCraft الاختيار {previous}.",
|
||||
"prefs.graphics_api_unavailable": "اختيار واجهة الرسوميات متاح في تطبيق سطح المكتب.",
|
||||
"render.graphics_api_restart_vulkan": "إعادة التشغيل باستخدام Vulkan",
|
||||
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصميمات غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
|
||||
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصاميم غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
|
||||
"render.graphics_api_settings": "إعدادات الرسوميات",
|
||||
"render.graphics_api_vulkan_required": "يتطلب تتبّع المسارات بمعالج الرسوميات Vulkan في هذه الجلسة. أعد التشغيل باستخدام Vulkan، أو واصل استخدام طريقة صور بمعالج CPU."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+right drag: right zooms in, left zooms out. Turn off to restore vertical drag zoom.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: horizontal drag zoom"
|
||||
},
|
||||
"ar": {
|
||||
"nav.maya_horizontal_zoom.help": "اسحب مع Alt وزر الفأرة الأيمن: إلى اليمين للتكبير وإلى اليسار للتصغير. عطّل هذا الخيار لاستعادة التكبير بالسحب العمودي.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: التكبير بالسحب الأفقي"
|
||||
},
|
||||
"he": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt וגרירה עם לחצן העכבר הימני: ימינה לקירוב, שמאלה להרחקה. כבו כדי לחזור לזום בגרירה אנכית.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: זום בגרירה אופקית"
|
||||
},
|
||||
"ja": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+右ドラッグで右に動かすと拡大、左に動かすと縮小します。オフにすると従来の垂直ドラッグに戻ります。",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: 水平方向のドラッグでズーム"
|
||||
},
|
||||
"vi": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+kéo chuột phải: kéo sang phải để phóng to, sang trái để thu nhỏ. Tắt để khôi phục thu phóng bằng cách kéo dọc.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: thu phóng bằng cách kéo ngang"
|
||||
},
|
||||
"zh": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+右键拖动:向右放大,向左缩小。关闭此选项可恢复垂直拖动缩放。",
|
||||
"nav.maya_horizontal_zoom.label": "Maya:水平拖动缩放"
|
||||
},
|
||||
"de": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+Rechtsziehen: nach rechts vergrößern, nach links verkleinern. Ausschalten, um wieder vertikal zu zoomen.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: horizontal ziehen zum Zoomen"
|
||||
},
|
||||
"ko": {
|
||||
"nav.maya_horizontal_zoom.help": "Alt+오른쪽 버튼 드래그: 오른쪽으로 움직이면 확대하고 왼쪽으로 움직이면 축소합니다. 끄면 세로 드래그 확대/축소로 돌아갑니다.",
|
||||
"nav.maya_horizontal_zoom.label": "Maya: 가로 드래그로 확대/축소"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"en": {
|
||||
"ai.prefs.control_port": "Port",
|
||||
"ai.prefs.control_port_note": "The port AI agents connect to on this computer (127.0.0.1). 27878 is the default: when it is in use, the next free port is taken. Any other port is used exactly. Point your MCP client at the same port, for example solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "Listening on 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "Not listening",
|
||||
"ai.prefs.control_port_taken": "Port {port} is in use by another program (or another SolidCraft window). Choose another port.",
|
||||
"ai.prefs.control_failed": "The control channel could not start: {error}",
|
||||
"ai.prefs.control_from_launch": "Started with --control on port {port} for this run. Changes here take effect the next time SolidCraft starts without --control."
|
||||
},
|
||||
"de": {
|
||||
"ai.prefs.control_port": "Port",
|
||||
"ai.prefs.control_port_note": "Der Port, über den sich KI-Agenten auf diesem Computer verbinden (127.0.0.1). Standard ist 27878: Ist er belegt, wird der nächste freie Port genommen. Jeder andere Port wird genau so verwendet. Richte deinen MCP-Client auf denselben Port, zum Beispiel solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "Wartet auf 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "Nicht aktiv",
|
||||
"ai.prefs.control_port_taken": "Port {port} wird von einem anderen Programm (oder einem anderen SolidCraft-Fenster) verwendet. Wähle einen anderen Port.",
|
||||
"ai.prefs.control_failed": "Der Steuerkanal konnte nicht starten: {error}",
|
||||
"ai.prefs.control_from_launch": "Für diese Sitzung mit --control auf Port {port} gestartet. Änderungen hier gelten beim nächsten Start von SolidCraft ohne --control."
|
||||
},
|
||||
"ja": {
|
||||
"ai.prefs.control_port": "ポート",
|
||||
"ai.prefs.control_port_note": "このコンピューター上で AI エージェントが接続するポート (127.0.0.1)。既定は 27878 で、使用中の場合は次の空いているポートを使います。それ以外のポートはそのまま使います。MCP クライアントも同じポートに向けてください (例: solidcraft-cli mcp --connect 127.0.0.1:7878)。",
|
||||
"ai.prefs.control_listening": "127.0.0.1:{port} で待機中",
|
||||
"ai.prefs.control_not_listening": "待機していません",
|
||||
"ai.prefs.control_port_taken": "ポート {port} は別のプログラム (または別の SolidCraft ウィンドウ) が使用しています。別のポートを選んでください。",
|
||||
"ai.prefs.control_failed": "コントロールチャネルを開始できませんでした: {error}",
|
||||
"ai.prefs.control_from_launch": "この実行は --control でポート {port} を使って開始されました。ここでの変更は、次に --control なしで SolidCraft を起動したときに反映されます。"
|
||||
},
|
||||
"zh": {
|
||||
"ai.prefs.control_port": "端口",
|
||||
"ai.prefs.control_port_note": "AI 代理在本机上连接的端口 (127.0.0.1)。默认为 27878:若被占用,则使用下一个空闲端口。其他端口按原样使用。请让 MCP 客户端指向同一端口,例如 solidcraft-cli mcp --connect 127.0.0.1:7878。",
|
||||
"ai.prefs.control_listening": "正在监听 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "未在监听",
|
||||
"ai.prefs.control_port_taken": "端口 {port} 已被其他程序(或另一个 SolidCraft 窗口)占用。请选择其他端口。",
|
||||
"ai.prefs.control_failed": "控制通道无法启动:{error}",
|
||||
"ai.prefs.control_from_launch": "本次运行通过 --control 在端口 {port} 上启动。此处的更改将在下次不带 --control 启动 SolidCraft 时生效。"
|
||||
},
|
||||
"ko": {
|
||||
"ai.prefs.control_port": "포트",
|
||||
"ai.prefs.control_port_note": "이 컴퓨터에서 AI 에이전트가 연결하는 포트(127.0.0.1)입니다. 기본값은 27878이며, 사용 중이면 다음 빈 포트를 사용합니다. 다른 포트는 그대로 사용합니다. MCP 클라이언트도 같은 포트를 가리키게 하세요(예: solidcraft-cli mcp --connect 127.0.0.1:7878).",
|
||||
"ai.prefs.control_listening": "127.0.0.1:{port}에서 대기 중",
|
||||
"ai.prefs.control_not_listening": "대기하지 않음",
|
||||
"ai.prefs.control_port_taken": "포트 {port}을(를) 다른 프로그램(또는 다른 SolidCraft 창)이 사용 중입니다. 다른 포트를 선택하세요.",
|
||||
"ai.prefs.control_failed": "제어 채널을 시작할 수 없습니다: {error}",
|
||||
"ai.prefs.control_from_launch": "이번 실행은 --control로 포트 {port}에서 시작되었습니다. 여기서 바꾼 내용은 다음에 --control 없이 SolidCraft를 시작할 때 적용됩니다."
|
||||
},
|
||||
"vi": {
|
||||
"ai.prefs.control_port": "Cổng",
|
||||
"ai.prefs.control_port_note": "Cổng mà các tác tử AI kết nối trên máy tính này (127.0.0.1). Mặc định là 27878: nếu cổng đang được dùng, cổng trống tiếp theo sẽ được dùng. Mọi cổng khác được dùng đúng như vậy. Hãy trỏ ứng dụng MCP của bạn đến cùng cổng, ví dụ solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "Đang lắng nghe trên 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "Không lắng nghe",
|
||||
"ai.prefs.control_port_taken": "Cổng {port} đang được một chương trình khác (hoặc một cửa sổ SolidCraft khác) sử dụng. Hãy chọn cổng khác.",
|
||||
"ai.prefs.control_failed": "Không thể khởi động kênh điều khiển: {error}",
|
||||
"ai.prefs.control_from_launch": "Lần chạy này được khởi động với --control trên cổng {port}. Các thay đổi ở đây có hiệu lực vào lần tới SolidCraft khởi động mà không có --control."
|
||||
},
|
||||
"ar": {
|
||||
"ai.prefs.control_port": "المنفذ",
|
||||
"ai.prefs.control_port_note": "المنفذ الذي يتصل عبره وكلاء الذكاء الاصطناعي على هذا الحاسوب (127.0.0.1). الافتراضي هو 27878: إذا كان مستخدمًا يُستخدم المنفذ الحر التالي. أي منفذ آخر يُستخدم كما هو. وجّه عميل MCP إلى المنفذ نفسه، مثل solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "يستمع على 127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "لا يستمع",
|
||||
"ai.prefs.control_port_taken": "المنفذ {port} يستخدمه برنامج آخر (أو نافذة SolidCraft أخرى). اختر منفذًا آخر.",
|
||||
"ai.prefs.control_failed": "تعذّر بدء قناة التحكم: {error}",
|
||||
"ai.prefs.control_from_launch": "بدأ هذا التشغيل باستخدام --control على المنفذ {port}. تسري التغييرات هنا عند تشغيل SolidCraft في المرة القادمة دون --control."
|
||||
},
|
||||
"he": {
|
||||
"ai.prefs.control_port": "פורט",
|
||||
"ai.prefs.control_port_note": "הפורט שאליו סוכני AI מתחברים במחשב זה (127.0.0.1). ברירת המחדל היא 27878: אם הוא תפוס, נלקח הפורט הפנוי הבא. כל פורט אחר משמש בדיוק כפי שהוא. כוונו את לקוח ה-MCP לאותו פורט, למשל solidcraft-cli mcp --connect 127.0.0.1:7878.",
|
||||
"ai.prefs.control_listening": "מאזין ב-127.0.0.1:{port}",
|
||||
"ai.prefs.control_not_listening": "לא מאזין",
|
||||
"ai.prefs.control_port_taken": "פורט {port} בשימוש של תוכנה אחרת (או חלון SolidCraft אחר). בחרו פורט אחר.",
|
||||
"ai.prefs.control_failed": "לא ניתן היה להפעיל את ערוץ הבקרה: {error}",
|
||||
"ai.prefs.control_from_launch": "הפעלה זו התחילה עם --control בפורט {port}. שינויים כאן ייכנסו לתוקף בפעם הבאה ש-SolidCraft יופעל ללא --control."
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,9 @@
|
||||
"ui.right_click_confirms_tip": "對話方塊開啟時,在視圖中按右鍵會轉到下一個空白輸入,在最後一個輸入上執行 OK"
|
||||
},
|
||||
"ar": {
|
||||
"dialog.right_click_pick": "اختر {input} أولاً",
|
||||
"ui.right_click_confirms": "النقر بزر الماوس الأيمن يؤكد مدخلات مربع الحوار",
|
||||
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحاً، ينتقل النقر بزر الماوس الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ موافق"
|
||||
"dialog.right_click_pick": "حدد {input} أولًا",
|
||||
"ui.right_click_confirms": "النقر بزر الفأرة الأيمن يؤكد مدخلات مربع الحوار",
|
||||
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحًا، ينتقل النقر بزر الفأرة الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ «موافق»"
|
||||
},
|
||||
"he": {
|
||||
"dialog.right_click_pick": "יש לבחור תחילה {input}",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"document.save_row": "حفظ",
|
||||
"document.save_selected": "حفظ المحدد",
|
||||
"prefs.save_prompt_one_at_a_time": "السؤال عن كل تصميم غير محفوظ على حدة عند إغلاق عدة تصاميم",
|
||||
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلاً من قائمة مجمّعة."
|
||||
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلًا من قائمة مجمّعة."
|
||||
},
|
||||
"de": {
|
||||
"document.discard_row": "Verwerfen",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"en": { "cmd.selection_get": "Get Selection" },
|
||||
"ar": { "cmd.selection_get": "الحصول على التحديد" },
|
||||
"he": { "cmd.selection_get": "קבלת הבחירה" },
|
||||
"ja": { "cmd.selection_get": "選択を取得" },
|
||||
"vi": { "cmd.selection_get": "Lấy vùng chọn" },
|
||||
"zh": { "cmd.selection_get": "获取选择" }
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"ar": {
|
||||
"prefs.sketch.cursor_coordinates": "إظهار إحداثيات المؤشر في الرسم",
|
||||
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. إيقاف: فقط أثناء وضع أداة لنقطة."
|
||||
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. عند الإيقاف: فقط عندما تضع أداةٌ نقطة."
|
||||
},
|
||||
"de": {
|
||||
"prefs.sketch.cursor_coordinates": "Cursorkoordinaten in der Skizze anzeigen",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"prefs.sketch.max_points_unit": "points"
|
||||
},
|
||||
"ar": {
|
||||
"prefs.sketch.max_points": "أكبر رسم تخطيطي",
|
||||
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم تخطيطي واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض إدراج SVG وإدراج DXF رسمًا أكبر. الرسم التخطيطي الكبير يُحل ويُعرض بشكل أبطأ.",
|
||||
"prefs.sketch.max_points": "أكبر حجم للرسم",
|
||||
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض «إدراج SVG» و«إدراج DXF» رسمًا أكبر. الرسم الكبير يُحل ويُعرض بشكل أبطأ.",
|
||||
"prefs.sketch.max_points_unit": "نقطة"
|
||||
},
|
||||
"de": {
|
||||
|
||||
+1214
-212
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
| assets/locales/pending/agent-chat-select-all.json | SolidCraft contributors | Original assistant chat select-all strings and translations | MIT OR Apache-2.0 |
|
||||
@@ -0,0 +1 @@
|
||||
| assets/locales/pending/constraint-glyph-size.json | SolidCraft contributors | Original constraint glyph size preference label and help text | MIT OR Apache-2.0 |
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Where a control request is, so the connection that sent it can cancel it (`control.cancel`,
|
||||
//! #1989): shared between the window, which moves it along, and the connection. Whoever changes
|
||||
//! it first wins.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
/// A request's place in the app, and how to stop the work computing it.
|
||||
#[derive(Default)]
|
||||
pub struct Ticket {
|
||||
state: AtomicU8,
|
||||
/// It is an export computing on a copy, whose worker writes its file.
|
||||
writes_file: AtomicBool,
|
||||
/// Tells its worker to stop: set by the window when it hands the request to one.
|
||||
stopper: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Ticket {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Ticket").field("state", &self.state).field("writes_file", &self.writes_file).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Waiting its turn (sent, or held behind another request).
|
||||
const WAITING: u8 = 0;
|
||||
/// Running on the window thread (or handed to something that can't stop it, such as the file
|
||||
/// loader).
|
||||
const RUNNING: u8 = 1;
|
||||
/// Computing off the window thread.
|
||||
const COMPUTING: u8 = 2;
|
||||
const CANCELLED: u8 = 3;
|
||||
|
||||
/// What [`Ticket::cancel`] did.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Cancel {
|
||||
/// It was waiting its turn: it will not run.
|
||||
Removed,
|
||||
/// It was computing off the window thread: it is answered as stopped and nothing of it is
|
||||
/// applied. An export's worker may still write its file.
|
||||
Stopped { writes_file: bool },
|
||||
/// It runs on the window thread: it will finish.
|
||||
Running,
|
||||
/// It was cancelled or stopped before.
|
||||
Already,
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
/// Cancel the request, unless it already runs on the window thread.
|
||||
pub fn cancel(&self) -> Cancel {
|
||||
let mut state = self.state.load(Ordering::Acquire);
|
||||
loop {
|
||||
let done = match state {
|
||||
WAITING => Cancel::Removed,
|
||||
COMPUTING => Cancel::Stopped { writes_file: self.writes_file.load(Ordering::Acquire) },
|
||||
CANCELLED => return Cancel::Already,
|
||||
_ => return Cancel::Running,
|
||||
};
|
||||
match self.state.compare_exchange(state, CANCELLED, Ordering::AcqRel, Ordering::Acquire) {
|
||||
Ok(_) => {
|
||||
self.stop_worker();
|
||||
return done;
|
||||
}
|
||||
Err(now) => state = now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.state.load(Ordering::Acquire) == CANCELLED
|
||||
}
|
||||
|
||||
/// The window takes the request to run it: false when it was cancelled.
|
||||
pub fn take(&self) -> bool {
|
||||
self.moves(WAITING, RUNNING)
|
||||
}
|
||||
|
||||
/// The work computing it stops on `stop` (a worker's own stop flag). Cancelled already, it is
|
||||
/// stopped at once.
|
||||
pub fn on_stop(&self, stop: impl Fn() + Send + Sync + 'static) {
|
||||
*self.stopper.lock().unwrap_or_else(PoisonError::into_inner) = Some(Box::new(stop));
|
||||
if self.is_cancelled() {
|
||||
self.stop_worker();
|
||||
}
|
||||
}
|
||||
|
||||
/// The window hands it to a worker (`writes_file`: an export on a copy).
|
||||
pub fn computing(&self, writes_file: bool) {
|
||||
self.writes_file.store(writes_file, Ordering::Release);
|
||||
let _ = self.moves(RUNNING, COMPUTING) || self.moves(WAITING, COMPUTING);
|
||||
}
|
||||
|
||||
/// Its worker is done and it runs on the window thread now (or it never left): false when it
|
||||
/// was cancelled meanwhile. Only the window moves it on from running, so running stays running.
|
||||
pub fn back(&self) -> bool {
|
||||
self.moves(COMPUTING, RUNNING) || self.state.load(Ordering::Acquire) == RUNNING
|
||||
}
|
||||
|
||||
/// Computed, but it waits its turn again (a file opening meanwhile): false when cancelled.
|
||||
pub fn wait_again(&self) -> bool {
|
||||
self.moves(COMPUTING, WAITING)
|
||||
}
|
||||
|
||||
/// The person at the app stopped it (Stop): its worker stops too.
|
||||
pub fn stop(&self) {
|
||||
self.state.store(CANCELLED, Ordering::Release);
|
||||
self.stop_worker();
|
||||
}
|
||||
|
||||
fn stop_worker(&self) {
|
||||
if let Some(stop) = self.stopper.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
fn moves(&self, from: u8, to: u8) -> bool {
|
||||
self.state.compare_exchange(from, to, Ordering::AcqRel, Ordering::Acquire).is_ok()
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
mod cancel;
|
||||
mod effect;
|
||||
mod request;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
|
||||
pub use cancel::{Cancel, Ticket};
|
||||
pub use effect::Effect;
|
||||
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
|
||||
|
||||
@@ -18,12 +18,15 @@ pub struct ControlRequest {
|
||||
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
|
||||
/// validity follows them through queues, computation and cursor animation.
|
||||
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
/// Where the request is, shared with the connection that sent it so it can cancel it
|
||||
/// (`control.cancel`, #1989).
|
||||
pub ticket: std::sync::Arc<crate::Ticket>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
|
||||
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None, ticket: Default::default() }, rx)
|
||||
}
|
||||
|
||||
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
|
||||
|
||||
+275
-10
@@ -14,6 +14,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Effect;
|
||||
use crate::auth::tokens_match;
|
||||
use crate::cancel::{Cancel, Ticket};
|
||||
use crate::request::{ControlRequest, DOCUMENT_WATCH, DocumentWatch, Subscriber};
|
||||
|
||||
/// What the server needs to know of a request before it sends it to the window: how to name it
|
||||
@@ -94,28 +95,101 @@ impl Hub {
|
||||
/// on it). A listener that is not on a loopback address is refused: the channel never goes on
|
||||
/// the network.
|
||||
pub fn listen(self: &Arc<Self>, listener: TcpListener, token: String, requests: Sender<ControlRequest>, patient: bool) -> std::io::Result<()> {
|
||||
self.listen_stoppable(listener, token, requests, patient).map(drop)
|
||||
}
|
||||
|
||||
/// [`Hub::listen`], and a [`Stop`] that closes the listener and the connections it accepted
|
||||
/// (a channel turned off in the app's settings). Dropping the [`Stop`] leaves it serving.
|
||||
pub fn listen_stoppable(
|
||||
self: &Arc<Self>,
|
||||
listener: TcpListener,
|
||||
token: String,
|
||||
requests: Sender<ControlRequest>,
|
||||
patient: bool,
|
||||
) -> std::io::Result<Stop> {
|
||||
let addr = listener.local_addr()?;
|
||||
if !addr.ip().is_loopback() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("the control channel listens on loopback only, not {addr}")));
|
||||
}
|
||||
let hub = Arc::clone(self);
|
||||
let token = Arc::new(token);
|
||||
let shared = Arc::new(Accepted::default());
|
||||
let (closed_tx, closed) = channel::<()>();
|
||||
let stop = Stop { addr, shared: Arc::clone(&shared), closed };
|
||||
std::thread::spawn(move || {
|
||||
// Dropped when this thread ends, with the listener: `Stop::stop` waits for that.
|
||||
let _closed = closed_tx;
|
||||
for stream in listener.incoming().flatten() {
|
||||
if shared.stopped.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
if hub.open.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
hub.open.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = send(&mut refused, &json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let (hub, tx, token) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token));
|
||||
let id = shared.next.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(copy) = stream.try_clone() {
|
||||
shared.lock().push((id, copy));
|
||||
}
|
||||
// Stopped while this one was being accepted: it is closed with the rest.
|
||||
if shared.stopped.load(Ordering::SeqCst) {
|
||||
shared.close_all();
|
||||
}
|
||||
let (hub, tx, token, shared) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token), Arc::clone(&shared));
|
||||
std::thread::spawn(move || {
|
||||
hub.serve(stream, &token, tx, patient);
|
||||
hub.open.fetch_sub(1, Ordering::Relaxed);
|
||||
shared.lock().retain(|(i, _)| *i != id);
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
Ok(stop)
|
||||
}
|
||||
}
|
||||
|
||||
/// The connections a stoppable listener accepted, so stopping it can close them too.
|
||||
#[derive(Default)]
|
||||
struct Accepted {
|
||||
stopped: AtomicBool,
|
||||
next: AtomicU64,
|
||||
streams: Mutex<Vec<(u64, TcpStream)>>,
|
||||
}
|
||||
|
||||
impl Accepted {
|
||||
fn lock(&self) -> MutexGuard<'_, Vec<(u64, TcpStream)>> {
|
||||
self.streams.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn close_all(&self) {
|
||||
for (_, stream) in self.lock().drain(..) {
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a listener started with [`Hub::listen_stoppable`].
|
||||
pub struct Stop {
|
||||
addr: std::net::SocketAddr,
|
||||
shared: Arc<Accepted>,
|
||||
closed: Receiver<()>,
|
||||
}
|
||||
|
||||
impl Stop {
|
||||
/// The port the listener is on.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.addr.port()
|
||||
}
|
||||
|
||||
/// Stop accepting, close the port and end every connection it accepted. Returns once the
|
||||
/// port is closed (at most a couple of seconds), so the same port can be bound again at once.
|
||||
pub fn stop(self) {
|
||||
self.shared.stopped.store(true, Ordering::SeqCst);
|
||||
// The listener waits in `accept`: a connection of our own wakes it to see the flag.
|
||||
let _ = TcpStream::connect_timeout(&self.addr, Duration::from_secs(1));
|
||||
let _ = self.closed.recv_timeout(Duration::from_secs(2));
|
||||
self.shared.close_all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +351,62 @@ struct Pending {
|
||||
/// The request in messages ("`engine.execute` (solid.fillet)").
|
||||
what: String,
|
||||
reply: Receiver<Value>,
|
||||
/// Where it is in the app, to cancel it (`control.cancel`).
|
||||
job: Arc<Ticket>,
|
||||
}
|
||||
|
||||
/// `control.cancel {id?}`: cancel the request this connection still waits for (the one
|
||||
/// `control.pending` reports; `id`, when given, must name it). A connection can only cancel its
|
||||
/// own requests. Waiting its turn, it is removed; computing off the window thread, it is stopped
|
||||
/// as the app's Stop stops it. Either way nothing of it is applied and the connection is free.
|
||||
/// One that runs on the window thread can't be cancelled; one that finished is answered.
|
||||
fn cancel(pending: &mut Option<Pending>, late: Option<Value>, id: Option<&Value>, document: &str) -> Value {
|
||||
let id = id.filter(|v| !v.is_null());
|
||||
let mine = pending.as_ref().filter(|p| id.is_none_or(|id| *id == p.id));
|
||||
let Some(p) = mine else {
|
||||
return match id {
|
||||
None => json!({"ok": true, "result": {"cancelled": null, "note": "nothing to cancel: no request of this connection is running"}}),
|
||||
Some(id) => json!({"ok": false, "error": format!(
|
||||
"nothing to cancel: request {} is not running on this connection (a connection can only cancel its own requests)",
|
||||
id.to_string().chars().take(64).collect::<String>()
|
||||
)}),
|
||||
};
|
||||
};
|
||||
let (what, id) = (format!("request {} ({})", p.id, p.what), p.id.clone());
|
||||
let finished = |p: Pending, reply: Value| {
|
||||
json!({"ok": true, "result": {"cancelled": null, "pending": p.id, "method": p.method, "done": true, "reply": reply,
|
||||
"note": format!("nothing to cancel: {what} had already finished; its reply is here")}})
|
||||
};
|
||||
if let Some(reply) = late {
|
||||
return pending.take().map_or(Value::Null, |p| finished(p, reply));
|
||||
}
|
||||
let outcome = p.job.cancel();
|
||||
let note = match outcome {
|
||||
Cancel::Removed => format!("cancelled: {what} was still waiting its turn and will not run; nothing was applied"),
|
||||
Cancel::Stopped { writes_file: false } => format!("stopped: {what} was computing off the window thread; nothing was applied"),
|
||||
Cancel::Stopped { writes_file: true } => {
|
||||
format!("stopped: {what} was computing on a copy of the {document}, which is unchanged; its worker may still write the file")
|
||||
}
|
||||
Cancel::Already => format!("{what} was already cancelled or stopped; nothing of it was applied"),
|
||||
Cancel::Running => {
|
||||
// It may have just finished.
|
||||
if let Some(reply) = pending.as_ref().and_then(|p| p.reply.try_recv().ok()) {
|
||||
return pending.take().map_or(Value::Null, |p| finished(p, reply));
|
||||
}
|
||||
return json!({"ok": false, "cancellable": false, "error": format!(
|
||||
"cannot be cancelled: {what} already runs in the app (on its window thread, or as a file it opens) and will \
|
||||
still finish; ask `control.pending` for its result. Only a request still waiting its turn, or a change or \
|
||||
export computing off the window thread, can be cancelled"
|
||||
)});
|
||||
}
|
||||
};
|
||||
let method = pending.take().map(|p| p.method);
|
||||
let how = match outcome {
|
||||
Cancel::Removed => "removed",
|
||||
Cancel::Stopped { .. } => "stopped",
|
||||
_ => "already",
|
||||
};
|
||||
json!({"ok": true, "result": {"cancelled": id, "method": method, "outcome": how, "note": note}})
|
||||
}
|
||||
|
||||
/// Every request the app has been sent and not answered yet, from every connection, for
|
||||
@@ -311,7 +441,7 @@ impl Running {
|
||||
|
||||
/// List `req`, sent on `connection`, until the returned ticket is dropped; `command` and
|
||||
/// `steps` as the app's [`Protocol`] summarised it.
|
||||
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Ticket {
|
||||
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Listing {
|
||||
let key = self.keys.fetch_add(1, Ordering::Relaxed);
|
||||
// Ids and methods come from the client: only short ones are repeated to the others.
|
||||
let id = match id {
|
||||
@@ -322,7 +452,7 @@ impl Running {
|
||||
let method = req.method.chars().take(64).collect();
|
||||
let started = req.started.clone();
|
||||
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
|
||||
Ticket { running: Arc::clone(self), key }
|
||||
Listing { running: Arc::clone(self), key }
|
||||
}
|
||||
|
||||
/// The `control.running` result, as `asker`'s connection sees it.
|
||||
@@ -355,12 +485,12 @@ impl Running {
|
||||
}
|
||||
|
||||
/// A listed request; dropped when its reply arrives.
|
||||
struct Ticket {
|
||||
struct Listing {
|
||||
running: Arc<Running>,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl Drop for Ticket {
|
||||
impl Drop for Listing {
|
||||
fn drop(&mut self) {
|
||||
let key = self.key;
|
||||
self.running.lock().retain(|e| e.key != key);
|
||||
@@ -369,7 +499,7 @@ impl Drop for Ticket {
|
||||
|
||||
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
|
||||
/// until the app answers, also once its connection has closed (#3654).
|
||||
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
|
||||
fn watch(reply: Receiver<Value>, ticket: Listing) -> Receiver<Value> {
|
||||
let (late_tx, late_rx) = channel();
|
||||
// A small thread per timed-out request (each connection has at most one). If none can be
|
||||
// made, the reply is lost and `control.pending` says the app ended the request without one.
|
||||
@@ -395,11 +525,12 @@ pub fn timeout_message(what: &str, effect: Effect, document: &str) -> String {
|
||||
}
|
||||
Effect::WritesFile => format!(
|
||||
"timeout: {what} is still running in the app and will still write the file when it finishes (it was not cancelled: \
|
||||
don't send it again; the {document} does not change); ask `control.pending` for its result"
|
||||
don't send it again; the {document} does not change); ask `control.pending` for its result, or `control.cancel` to \
|
||||
cancel it"
|
||||
),
|
||||
Effect::Changes => format!(
|
||||
"timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \
|
||||
again); ask `control.pending` for its result"
|
||||
again); ask `control.pending` for its result, or `control.cancel` to cancel it"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -597,6 +728,10 @@ impl Hub {
|
||||
json!({"ok": true, "result": running.result(connection)})
|
||||
} else if method == "control.capabilities" {
|
||||
json!({"ok": true, "result": self.protocol.capabilities()})
|
||||
} else if method == "control.cancel" {
|
||||
let r = cancel(&mut pending, late, params.get("id"), self.protocol.document());
|
||||
(self.wake)();
|
||||
r
|
||||
} else if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
@@ -636,6 +771,7 @@ impl Hub {
|
||||
// answer on.
|
||||
joining = (method == "control.subscribe").then(|| documents.join());
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
let job = Arc::clone(&req.ticket);
|
||||
let ticket = running.add(connection, &id, &req, command, steps);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
@@ -645,7 +781,7 @@ impl Hub {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": timeout_message(&what, effect, self.protocol.document())});
|
||||
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket) });
|
||||
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket), job });
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -807,6 +943,104 @@ mod tests {
|
||||
assert!(l.contains("unauthorized"), "{l}");
|
||||
}
|
||||
|
||||
/// #1989: `control.cancel` cancels the connection's own outstanding request. One still
|
||||
/// waiting its turn is removed and never runs, and the connection is free at once; one that
|
||||
/// already runs in the app can't be, and says so; another connection's request is refused;
|
||||
/// with nothing outstanding there is nothing to cancel.
|
||||
#[test]
|
||||
fn control_cancel_cancels_only_this_connections_request() {
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = ran.clone();
|
||||
// A stand-in for the window thread: `queued` waits behind the others (never taken until
|
||||
// `slow` is done), `slow` runs until the gate opens.
|
||||
std::thread::spawn(move || {
|
||||
let mut held = Vec::new();
|
||||
for req in rx {
|
||||
if req.method == "queued" {
|
||||
held.push(req);
|
||||
continue;
|
||||
}
|
||||
if !req.ticket.take() {
|
||||
continue;
|
||||
}
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
if req.method == "slow" {
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
if req.method == "slow" {
|
||||
for req in held.drain(..) {
|
||||
if req.ticket.take() {
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100), token: TOKEN_WAIT };
|
||||
let hub = hub(waits, &DOCUMENT_WATCH);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let (tx, hub) = (tx.clone(), Arc::clone(&hub));
|
||||
std::thread::spawn(move || hub.serve(stream, token, tx, false));
|
||||
}
|
||||
});
|
||||
let connect = || {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let out = s.try_clone().unwrap();
|
||||
(out, BufReader::new(s))
|
||||
};
|
||||
let ask = |(out, lines): &mut (TcpStream, BufReader<TcpStream>), v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
};
|
||||
let mut a = connect();
|
||||
let mut b = connect();
|
||||
let r = ask(&mut b, json!({"id": 1, "method": "control.cancel", "token": token}));
|
||||
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "nothing to cancel: {r}");
|
||||
|
||||
// Waiting its turn: removed, never run, and the connection is free.
|
||||
let r = ask(&mut a, json!({"id": 1, "method": "queued", "token": token}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
let r = ask(&mut a, json!({"id": 2, "method": "control.cancel", "params": {"id": 1}}));
|
||||
assert_eq!((&r["ok"], &r["result"]["cancelled"], &r["result"]["outcome"]), (&json!(true), &json!(1), &json!("removed")), "{r}");
|
||||
assert!(r["result"]["note"].as_str().is_some_and(|n| n.contains("will not run")), "{r}");
|
||||
let r = ask(&mut a, json!({"id": 3, "method": "control.pending"}));
|
||||
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
|
||||
|
||||
// Already running in the app: it can't be, and it still answers.
|
||||
let r = ask(&mut a, json!({"id": 4, "method": "slow"}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
let r = ask(&mut b, json!({"id": 2, "method": "control.cancel", "params": {"id": 4}}));
|
||||
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.contains("its own requests")), "another connection's: {r}");
|
||||
let r = ask(&mut a, json!({"id": 5, "method": "control.cancel", "params": {"id": 99}}));
|
||||
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("nothing to cancel")), "{r}");
|
||||
let r = ask(&mut a, json!({"id": 6, "method": "control.cancel"}));
|
||||
assert!(r["ok"] == false && r["cancellable"] == false, "{r}");
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.starts_with("cannot be cancelled") && e.contains("control.pending")), "{r}");
|
||||
gate_tx.send(()).unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let r = loop {
|
||||
let r = ask(&mut a, json!({"id": 7, "method": "control.pending"}));
|
||||
if r["result"]["done"] == true || Instant::now() > deadline {
|
||||
break r;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
assert_eq!(r["result"]["reply"]["result"], "slow", "{r}");
|
||||
let r = ask(&mut a, json!({"id": 8, "method": "control.cancel"}));
|
||||
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "{r}");
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow"], "the cancelled request never ran");
|
||||
}
|
||||
|
||||
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
|
||||
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
|
||||
/// is queued to run unseen behind it.
|
||||
@@ -1154,4 +1388,35 @@ mod tests {
|
||||
assert_eq!(out.bytes.last(), Some(&b'\n'));
|
||||
assert_eq!(serde_json::from_slice::<Value>(&out.bytes).unwrap(), reply);
|
||||
}
|
||||
|
||||
/// A stopped listener frees its port at once and ends the connections it had, so a channel
|
||||
/// turned off in the settings leaves nothing listening and can be turned on again on the
|
||||
/// same port.
|
||||
#[test]
|
||||
fn a_stopped_listener_closes_its_port_and_its_connections() {
|
||||
use std::io::Read;
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (tx, _rx) = channel();
|
||||
let stop = hub(Waits::default(), &DOCUMENT_WATCH).listen_stoppable(listener, "0123456789abcdef0123456789abcdef".into(), tx, false).unwrap();
|
||||
assert_eq!(stop.port(), port);
|
||||
let mut client = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(4))).unwrap();
|
||||
// Wait until the server holds the connection, so stopping has one to close.
|
||||
let held = Instant::now();
|
||||
while stop.shared.lock().is_empty() && held.elapsed() < Duration::from_secs(4) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let started = Instant::now();
|
||||
stop.stop();
|
||||
let mut byte = [0u8; 1];
|
||||
match client.read(&mut byte) {
|
||||
Ok(n) => assert_eq!(n, 0, "the connection ends"),
|
||||
Err(e) => {
|
||||
assert!(!matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut), "the connection ends, not waits: {e}")
|
||||
}
|
||||
}
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "before the token wait runs out");
|
||||
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok(), "the port is free again");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,6 +1033,10 @@ pub enum FeatureKind {
|
||||
length: Option<Expr>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
modeled: bool,
|
||||
/// Continuous flank faces for this feature. Missing in older designs: keep
|
||||
/// their half-turn faces and groove-before-chamfer order for saved references.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
continuous_flanks: bool,
|
||||
/// A point near the end of the face the thread starts at; `None`: the face's only free
|
||||
/// end (no material past it), or the end the pick at `face` lies near.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
+129
-1
@@ -3717,6 +3717,70 @@ pub(crate) fn empty_intersection(bodies: &[&str]) -> DocError {
|
||||
DocError::Invalid(format!("the intersection is empty: nothing of {body} would remain; check that the profile overlaps the body"))
|
||||
}
|
||||
|
||||
/// The body a feature's target name `name` picks. Body names are unique, so a body of the
|
||||
/// feature's component asked to be called a name another component's body already has (a
|
||||
/// linked component's `AS5047P_PCB`) was named otherwise (`Body1`), and the name still picks the
|
||||
/// other component's body. That is refused with both bodies listed, instead of running the
|
||||
/// boolean on the other component's body and failing there with a kernel error (#12141).
|
||||
pub(crate) fn named_target(doc: &Document, state: &ModelState, f: &Feature, name: &str) -> Result<usize> {
|
||||
let i = state.bodies.iter().position(|b| b.name == name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
|
||||
let Some(found) = state.bodies.get(i).filter(|b| b.component != f.component) else { return Ok(i) };
|
||||
let asked: Vec<&str> =
|
||||
asked_names(doc, state).filter(|(b, asked)| b.component == f.component && *asked == Some(name)).map(|(b, _)| b.name.as_str()).collect();
|
||||
if asked.is_empty() {
|
||||
return Ok(i);
|
||||
}
|
||||
let component = |id: u64| doc.components.iter().find(|c| c.id == id).map_or_else(|| "the root".to_string(), |c| format!("`{}`", c.name));
|
||||
Err(DocError::Invalid(format!(
|
||||
"the body name `{name}` is ambiguous: it names the body of component {}, while this feature's component {} has {} that was asked to be called `{name}` (the name was taken); target {} or rename the bodies so each name is used once",
|
||||
component(found.component),
|
||||
component(f.component),
|
||||
asked.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", "),
|
||||
asked.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(" or "),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Each body with the name it was asked to be called: its slot of its feature's stored names.
|
||||
/// An Import's skipped solids make no body, so its bodies take the slots left, in order; any
|
||||
/// other feature's k-th body takes slot k. Siblings a later feature removed shift the count, so
|
||||
/// a body then reads a later slot's name (only ever refusing an ambiguous target). One pass
|
||||
/// over the bodies, each feature's slots listed once, so a large import costs linear time.
|
||||
fn asked_names<'a, 's>(doc: &'a Document, state: &'s ModelState) -> impl Iterator<Item = (&'s ModelBody, Option<&'a str>)> {
|
||||
let mut made: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
|
||||
let mut slots: std::collections::HashMap<u64, Vec<usize>> = std::collections::HashMap::new();
|
||||
state.bodies.iter().map(move |b| {
|
||||
let j = made.entry(b.feature).or_insert(0);
|
||||
let k = *j;
|
||||
*j += 1;
|
||||
let asked = doc.feature(b.feature).and_then(|m| {
|
||||
let slot = match &m.kind {
|
||||
FeatureKind::Import { skip, .. } => *slots
|
||||
.entry(m.id)
|
||||
.or_insert_with(|| {
|
||||
let skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
|
||||
(0..m.body_names.len()).filter(|s| !skip.contains(&(*s as u64))).collect()
|
||||
})
|
||||
.get(k)?,
|
||||
_ => k,
|
||||
};
|
||||
m.body_names.get(slot).map(String::as_str).filter(|n| !n.is_empty())
|
||||
});
|
||||
(b, asked)
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse the target names among `names` that [`named_target`] finds ambiguous, before any
|
||||
/// work that does not go through it (a sheet's cut-outs are machined before the boolean). Names
|
||||
/// that pick no body are left to the feature, which may allow them.
|
||||
fn refuse_ambiguous_targets(doc: &Document, state: &ModelState, f: &Feature, names: &[String]) -> Result<()> {
|
||||
for name in names.iter().filter(|n| *n != "*") {
|
||||
if let Err(e @ DocError::Invalid(_)) = named_target(doc, state, f, name) {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bodies a feature's boolean takes part in, and whether they were named rather than taken
|
||||
/// by default (every body, or the feature's component).
|
||||
pub(crate) fn boolean_scope(state: &ModelState, f: &Feature, targets: &[String]) -> (bool, Vec<String>) {
|
||||
@@ -3812,7 +3876,7 @@ fn apply_traced_with(
|
||||
} else {
|
||||
let mut v = Vec::new();
|
||||
for t in targets {
|
||||
v.push(state.bodies.iter().position(|b| &b.name == t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?);
|
||||
v.push(named_target(doc, state, f, t)?);
|
||||
}
|
||||
v
|
||||
};
|
||||
@@ -8242,6 +8306,7 @@ fn eval_feature(
|
||||
) -> Result<()> {
|
||||
// Its tools meet bodies in other placements where those are shown (#3383).
|
||||
let _frames = ToolFrames::install(doc, f, st);
|
||||
refuse_ambiguous_targets(doc, st, f, &f.participants)?;
|
||||
match &f.kind {
|
||||
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
|
||||
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
|
||||
@@ -8290,6 +8355,7 @@ fn eval_feature(
|
||||
| FeatureKind::Loft { operation, targets, .. }
|
||||
| FeatureKind::Coil { operation, targets, .. }
|
||||
| FeatureKind::Sweep { operation, targets, .. } => {
|
||||
refuse_ambiguous_targets(doc, st, f, &crate::plain(targets))?;
|
||||
let mut constructor = None;
|
||||
let mut to_body = None;
|
||||
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor, &mut to_body, warning)?;
|
||||
@@ -10146,6 +10212,68 @@ mod world_placement_cache_tests {
|
||||
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
|
||||
}
|
||||
|
||||
/// A body asked to be called a name a linked component's body has is named otherwise; a
|
||||
/// later feature of its component that targets the name is refused with both bodies listed,
|
||||
/// not run on the other component's body (#12141).
|
||||
#[test]
|
||||
fn a_target_name_taken_by_another_component_is_refused_as_ambiguous() {
|
||||
let mut doc = Document::new("names");
|
||||
let linked = doc.add_component(Some("Encoder"), 0).unwrap();
|
||||
let local = doc.add_component(Some("Assembly"), 0).unwrap();
|
||||
let mut boxes = |comp: u64, operation: Operation, corner: Vec3| {
|
||||
let id = doc
|
||||
.add_feature(
|
||||
FeatureKind::Box { corner: crate::point_expr(corner), length: "10".into(), width: "10".into(), height: "2".into(), operation },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let f = doc.feature_mut(id).unwrap();
|
||||
f.component = comp;
|
||||
id
|
||||
};
|
||||
let a = boxes(linked, Operation::NewBody, Vec3::new(100., 0., 0.));
|
||||
let b = boxes(local, Operation::NewBody, Vec3::ZERO);
|
||||
let cut = boxes(local, Operation::Cut, Vec3::new(2., 2., -1.));
|
||||
doc.feature_mut(a).unwrap().body_names = vec!["AS5047P_PCB".into()];
|
||||
doc.feature_mut(b).unwrap().body_names = vec!["AS5047P_PCB".into()];
|
||||
if let FeatureKind::Box { length, width, .. } = &mut doc.feature_mut(cut).unwrap().kind {
|
||||
*length = "2".into();
|
||||
*width = "2".into();
|
||||
}
|
||||
doc.feature_mut(cut).unwrap().participants = vec!["AS5047P_PCB".into()];
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let st = model.state();
|
||||
let own = st.bodies.iter().find(|x| x.component == local).unwrap().name.clone();
|
||||
assert_ne!(own, "AS5047P_PCB", "names stay unique");
|
||||
let err = model.result(cut).and_then(|r| r.error.clone()).expect("the cut is refused");
|
||||
assert!(err.contains("ambiguous") && err.contains("`Encoder`") && err.contains(&format!("`{own}`")), "{err}");
|
||||
// The linked body is left alone.
|
||||
let linked_body = st.body("AS5047P_PCB").unwrap();
|
||||
assert_eq!(linked_body.component, linked);
|
||||
|
||||
// Targeting the local body by the name it has works.
|
||||
doc.feature_mut(cut).unwrap().participants = vec![own.clone()];
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.result(cut).and_then(|r| r.error.clone()).is_none());
|
||||
}
|
||||
|
||||
/// An Import's skipped solids make no body: its bodies take the stored names left, so one
|
||||
/// built from slot B was asked for B, not for the skipped slot's A (#12141).
|
||||
#[test]
|
||||
fn an_imports_body_is_asked_for_its_own_slots_name() {
|
||||
let mut doc = Document::new("names");
|
||||
let id =
|
||||
doc.add_feature(FeatureKind::Import { file: "x.step".into(), step: "".into(), components: Vec::new(), skip: vec![0] }, None).unwrap();
|
||||
doc.feature_mut(id).unwrap().body_names = vec!["A".into(), "B".into()];
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(ModelBody::new("B (2)".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(1., 1., 1.)).unwrap(), id));
|
||||
assert_eq!(asked_names(&doc, &state).next().unwrap().1, Some("B"));
|
||||
doc.feature_mut(id).unwrap().kind = FeatureKind::Import { file: "x.step".into(), step: "".into(), components: Vec::new(), skip: Vec::new() };
|
||||
assert_eq!(asked_names(&doc, &state).next().unwrap().1, Some("A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
|
||||
let mut doc = Document::new("placements");
|
||||
|
||||
@@ -141,6 +141,7 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
});
|
||||
// One body supplies both old nearest-face snaps and persistent named-face snaps.
|
||||
// A scratch model shares its measurements, even though each sample changes the pose.
|
||||
@@ -1412,6 +1413,7 @@ fn marking(p: Vec3) -> FeatureKind {
|
||||
chamfer_angle: None,
|
||||
chamfer_depth: None,
|
||||
steep_flank: Default::default(),
|
||||
continuous_flanks: false,
|
||||
old_auto_size: false,
|
||||
}
|
||||
}
|
||||
@@ -2000,6 +2002,56 @@ fn a_hole_classifies_the_faces_it_reads_not_every_face_of_the_design() {
|
||||
assert!((side - Vec3::new(-1.0, 0.0, 0.0)).len() < 1e-6, "into the side wall: {side:?}");
|
||||
}
|
||||
|
||||
/// A cut whose target name is taken by another component's sheet, while a body of the cut's own
|
||||
/// component was asked for that name, is refused before the sheet is machined (#12141).
|
||||
#[test]
|
||||
fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
|
||||
let mut doc = Document::new("names");
|
||||
let linked = doc.add_component(Some("Encoder"), 0).unwrap();
|
||||
let local = doc.add_component(Some("Assembly"), 0).unwrap();
|
||||
flat_sheet(&mut doc, Vec2::new(0.0, 0.0), 40.0, 40.0);
|
||||
for f in doc.features.iter_mut() {
|
||||
f.component = linked;
|
||||
}
|
||||
let base = doc.features.last().unwrap().id;
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Plate".into()];
|
||||
let own = doc
|
||||
.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(100.0, 0.0, 0.0)),
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "2".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(own).unwrap().component = local;
|
||||
doc.feature_mut(own).unwrap().body_names = vec!["Plate".into()];
|
||||
let cut = doc
|
||||
.add_feature(
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(10.0, 10.0, -5.0)),
|
||||
length: "5".into(),
|
||||
width: "5".into(),
|
||||
height: "20".into(),
|
||||
operation: Operation::Cut,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(cut).unwrap().component = local;
|
||||
doc.feature_mut(cut).unwrap().participants = vec!["Plate".into()];
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let err = model.result(cut).and_then(|r| r.error.clone()).expect("the cut is refused");
|
||||
assert!(err.contains("ambiguous") && err.contains("`Encoder`"), "{err}");
|
||||
let st = model.state();
|
||||
let sheet = st.sheets.iter().find(|s| s.body == "Plate").expect("the linked sheet");
|
||||
assert!(sheet.holes.is_empty(), "the linked sheet is not machined");
|
||||
}
|
||||
|
||||
/// A hole's look at the material over its entry face (#10695) is best effort: when the kernel
|
||||
/// cannot build the run-on's column (here a column of no width), the hole keeps its full run-on
|
||||
/// and cuts nothing out of its tools, as before the check, instead of failing.
|
||||
|
||||
@@ -765,6 +765,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
designation,
|
||||
length,
|
||||
modeled,
|
||||
continuous_flanks,
|
||||
from,
|
||||
up_to,
|
||||
form,
|
||||
@@ -827,7 +828,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
None => note,
|
||||
});
|
||||
}
|
||||
if let Some(note) = cut(st, on, (*tip, angle, depth))? {
|
||||
if let Some(note) = cut_with_flanks(st, on, (*tip, angle, depth), *continuous_flanks)? {
|
||||
*warning = Some(match warning.take() {
|
||||
Some(w) => format!("{w}; {note}"),
|
||||
None => note,
|
||||
@@ -846,11 +847,18 @@ pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRun
|
||||
})
|
||||
}
|
||||
|
||||
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
|
||||
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
|
||||
/// (`tip`, with its angle and depth when given). What the user should be told about the result, if
|
||||
/// anything, is returned.
|
||||
/// Cut a modelled thread with its historical half-turn faces and groove-before-chamfer
|
||||
/// order. Existing hole features use this layout; Thread features keep their saved choice.
|
||||
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<Option<String>> {
|
||||
cut_with_flanks(st, on, (tip, angle, depth), false)
|
||||
}
|
||||
|
||||
fn cut_with_flanks(
|
||||
st: &mut ModelState,
|
||||
on: ThreadOn,
|
||||
(tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>),
|
||||
continuous_flanks: bool,
|
||||
) -> Result<Option<String>> {
|
||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow, shoulder_unchecked } = on;
|
||||
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
@@ -888,11 +896,28 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
// Preserve historical construction order when rebuilding an older feature.
|
||||
let chamfer = |mut input: Traced| -> Result<Traced> {
|
||||
if tip != ThreadTip::None {
|
||||
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
|
||||
let faces = cone.face_count();
|
||||
let cut =
|
||||
kernel::boolean(&input.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
|
||||
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
|
||||
input = Traced { body: cut, lineage };
|
||||
}
|
||||
}
|
||||
Ok(input)
|
||||
};
|
||||
if continuous_flanks {
|
||||
input = chamfer(input)?;
|
||||
}
|
||||
let cut_grooves = |ends: [End; 2]| -> Result<(Option<Body>, usize)> {
|
||||
let mut grooves = Vec::new();
|
||||
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?);
|
||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law, continuous_flanks)?);
|
||||
}
|
||||
if let Some(taper) = taper
|
||||
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
|
||||
@@ -932,16 +957,8 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
||||
// The groove's faces have no role to name them by.
|
||||
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||||
let mut nb = Traced { body: nb, lineage };
|
||||
if tip != ThreadTip::None {
|
||||
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
|
||||
let faces = cone.face_count();
|
||||
let cut = kernel::boolean(&nb.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
|
||||
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
|
||||
let lineage = nb.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
|
||||
nb = Traced { body: cut, lineage };
|
||||
}
|
||||
}
|
||||
let nb = Traced { body: nb, lineage };
|
||||
let nb = if continuous_flanks { nb } else { chamfer(nb)? };
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
@@ -951,7 +968,7 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
||||
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
|
||||
/// names (`Start`: the end of the axis `from`, which must be free; `Both`: each free end):
|
||||
/// the material outside a cone `angle` to the axis (default 45°) that reaches the diameter
|
||||
/// d_f = d - 2 `depth` at the face's end, as a ring to cut after the groove, so the last teeth
|
||||
/// d_f = d - 2 `depth` at the face's end, as a ring to cut from the stock, so the last teeth
|
||||
/// taper into it. ISO 4753 bounds d_f by the external thread's minor diameter; the default is
|
||||
/// that bound, d3 = d - 1.226869 P (ISO 724, the rounded root of the design profile). It lies
|
||||
/// below the modelled basic root d1, so the cone crosses the root cylinder and leaves a whole
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Joints save their captured values: `captured`, the values Capture Position
|
||||
//! (`component.capture_position`) saved and Revert Position (`component.revert_position`) puts
|
||||
//! the joint back to. An older design has none, so its joints load with their values as before
|
||||
//! and Revert Position leaves them where they are. The last release warns about the newer format
|
||||
//! number, opens the design and keeps the `captured` values it can't read.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Saves each thread feature's continuous-flank layout choice. Older threads omit
|
||||
//! the field and keep their historical faces and chamfer order, preserving references.
|
||||
//! The previous release refuses designs with this newer format rather than losing it.
|
||||
@@ -458,6 +458,10 @@ pub struct Joint {
|
||||
/// again whenever the design changes, so a joint follows the parameters it is defined by.
|
||||
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
|
||||
pub exprs: JointExprs,
|
||||
/// The values Capture Position saved, which Revert Position puts the joint back to. None in
|
||||
/// files from before captured joint values: such a joint keeps its values on Revert Position.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub captured: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
|
||||
@@ -2751,6 +2755,7 @@ mod tests {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
})
|
||||
.collect();
|
||||
let links =
|
||||
|
||||
@@ -944,6 +944,7 @@ mod tests {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
};
|
||||
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
|
||||
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
|
||||
|
||||
@@ -93,6 +93,7 @@ mod tests {
|
||||
angle: 0.6,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,12 @@ pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
stop.within(|| solidcraft_kernel::watch::watching(kernel, f))
|
||||
}
|
||||
|
||||
/// Whether the watch over this thread's evaluations asked them to stop. Work done after that may
|
||||
/// have been cut short: it is not kept.
|
||||
pub fn cancelled() -> bool {
|
||||
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|cur| cur.progress.is_cancelled()))
|
||||
}
|
||||
|
||||
/// Entered at the start of an evaluation; the guard leaves it.
|
||||
pub(crate) struct Evaluation {
|
||||
outer: bool,
|
||||
|
||||
@@ -114,6 +114,112 @@ fn parametric_plate_reevaluates() {
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// #14256: an end plate whose 25 mm arc meets two lines tangentially, each joint held by two
|
||||
/// "point on curve" constraints instead of a shared point. The solver leaves the arc's ends on
|
||||
/// the lines only to its 1e-9 mm residual, so along the tangent they lie up to 2.25e-4 mm from
|
||||
/// the lines' ends. The outline is still one closed profile, and extruding it gives the plate.
|
||||
#[test]
|
||||
fn an_outline_closed_by_point_on_curve_tangent_joints_extrudes() {
|
||||
let mut doc = Document::new("end plate");
|
||||
let mut sk = Sketch::new();
|
||||
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
|
||||
let mut point = |x: f64, y: f64| sk.add_point(Vec2::new(x, y), None).unwrap();
|
||||
let corners = [(-14.0, 90.0), (14.0, 90.0), (14.0, 48.0), (knee, 11.125), (90.0, 11.125), (90.0, -11.125), (25.0, -11.125), (25.0, 0.0)];
|
||||
let [top_left, top_right, shoulder, knee_point, right_top, right_bottom, step_bottom, step_top] = corners.map(|(x, y)| point(x, y));
|
||||
// Where the solver left the 25 mm arc's ends, and the end of the line along its top.
|
||||
let arc_start = point(24.999999999949498, 5.025035674136865e-5);
|
||||
let arc_end = point(0.0002252128472149822, 24.999999999324313);
|
||||
let ledge_right = point(0.0, 25.0);
|
||||
let ledge_left = point(-14.0, 25.0);
|
||||
let centre = point(0.0, 0.0);
|
||||
let small_centre = point(0.0, 0.0);
|
||||
let pos = |sk: &Sketch, i: usize| sk.points[i].pos;
|
||||
let line = |sk: &mut Sketch, a: usize, b: usize| sk.add_line_pts(a, b, None).unwrap();
|
||||
line(&mut sk, top_left, top_right);
|
||||
line(&mut sk, top_right, shoulder);
|
||||
sk.add_arc(Vec2::ZERO, pos(&sk, knee_point), pos(&sk, shoulder), [Some(centre), Some(knee_point), Some(shoulder)], None).unwrap();
|
||||
line(&mut sk, right_top, knee_point);
|
||||
line(&mut sk, right_bottom, right_top);
|
||||
line(&mut sk, step_bottom, right_bottom);
|
||||
let side = line(&mut sk, step_top, step_bottom);
|
||||
let arc = sk.add_arc(Vec2::ZERO, pos(&sk, arc_start), pos(&sk, arc_end), [Some(small_centre), Some(arc_start), Some(arc_end)], None).unwrap();
|
||||
let ledge = line(&mut sk, ledge_left, ledge_right);
|
||||
line(&mut sk, top_left, ledge_left);
|
||||
use ConstraintKind::*;
|
||||
for kind in [
|
||||
Coincident { p: centre, q: 0 },
|
||||
Coincident { p: small_centre, q: 0 },
|
||||
Horizontal { l: ledge },
|
||||
Vertical { l: side },
|
||||
Tangent { a: ledge, b: arc },
|
||||
Tangent { a: side, b: arc },
|
||||
PointOnCurve { p: ledge_right, c: arc },
|
||||
PointOnCurve { p: step_top, c: arc },
|
||||
PointOnCurve { p: arc_start, c: side },
|
||||
PointOnCurve { p: arc_end, c: ledge },
|
||||
] {
|
||||
sk.add_constraint(kind, None).unwrap();
|
||||
}
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: crate::SketchId(s),
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "10".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
second_follows_first: false,
|
||||
taper: None,
|
||||
corner: Default::default(),
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
to_body_apart: false,
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||
let st = m.state();
|
||||
assert_eq!(st.sketches[0].profiles.len(), 1);
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
// The polygon through the corners, plus the segment the 50 mm arc adds, less the segment the
|
||||
// quarter of the 25 mm circle takes away, 10 mm deep.
|
||||
let outline = [
|
||||
(25.0, -11.125),
|
||||
(90.0, -11.125),
|
||||
(90.0, 11.125),
|
||||
(knee, 11.125),
|
||||
(14.0, 48.0),
|
||||
(14.0, 90.0),
|
||||
(-14.0, 90.0),
|
||||
(-14.0, 25.0),
|
||||
(0.0, 25.0),
|
||||
(25.0, 0.0),
|
||||
];
|
||||
let polygon: f64 = (0..outline.len())
|
||||
.map(|i| {
|
||||
let ((x0, y0), (x1, y1)) = (outline[i], outline[(i + 1) % outline.len()]);
|
||||
x0 * y1 - x1 * y0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ 2.0;
|
||||
let segment = |r: f64, sweep: f64| r * r / 2.0 * (sweep - sweep.sin());
|
||||
let area = polygon + segment(50.0, 48.0_f64.atan2(14.0) - 11.125_f64.atan2(knee)) - segment(25.0, std::f64::consts::FRAC_PI_2);
|
||||
let volume = measure(&st.bodies[0].body).unwrap().volume;
|
||||
assert!(rel(volume, area * 10.0) < 1e-6, "{volume} vs {}", area * 10.0);
|
||||
}
|
||||
|
||||
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
|
||||
/// the evaluation reports the features it restored (their geometry did change back).
|
||||
#[test]
|
||||
|
||||
@@ -279,14 +279,16 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("capture")
|
||||
.contract(&CAPTURE_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<CapturePositionResult>)
|
||||
.params("keeps pending occurrence moves"),
|
||||
.params("keeps pending occurrence moves and saves every moving joint's values → captured, joints"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
|
||||
.at("SOLID", "POSITION")
|
||||
.icon("revert")
|
||||
.noundo()
|
||||
.contract(&REVERT_POSITION_CONTRACT)
|
||||
.output(output_schema_for::<RevertPositionResult>)
|
||||
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
.params(
|
||||
"occurrences?: [ids] (default all); drops selected pending occurrence moves; with no occurrences the joints also go back to \
|
||||
the values Capture Position saved (clamped to limits) → reverted, joints",
|
||||
),
|
||||
];
|
||||
|
||||
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
|
||||
@@ -1005,8 +1007,9 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
o.transform = m;
|
||||
}
|
||||
}
|
||||
let joints = super::joints::capture_values(s);
|
||||
s.revision += 1;
|
||||
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
|
||||
reply(CapturePositionResult { captured: n, joints, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1027,8 +1030,11 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.pending_moves.clear();
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
// Every occurrence going back takes the joints back too; a revert of some occurrences
|
||||
// leaves the joints alone.
|
||||
let joints = if p.get("occurrences").is_none() { super::joints::revert_values(s) } else { 0 };
|
||||
s.revision += 1;
|
||||
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
|
||||
reply(RevertPositionResult { reverted: n, joints, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The active component's frame from world coordinates (for new features' geometry).
|
||||
|
||||
@@ -23,6 +23,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
angle: 0.1,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2109,6 +2109,12 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
if let FeatureKind::Thread { continuous_flanks, .. } = &old.kind
|
||||
&& cid == "solid.thread"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("continuous_flanks").or_insert_with(|| json!(continuous_flanks));
|
||||
}
|
||||
// A thread saved before holes were sized by their nominal diameter keeps the size it had
|
||||
// when it is edited without naming one.
|
||||
if let FeatureKind::Thread { old_auto_size: true, .. } = &old.kind
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Edit Feature must replace the selections as well as the numerical definition.
|
||||
//! Edit Feature must replace the selections as well as the numerical definition; the selection
|
||||
//! commands (`select.set`, `selection.get`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::MeshGeom;
|
||||
|
||||
use crate::{Sel, Session};
|
||||
use crate::{EngineError, Sel, Session};
|
||||
|
||||
fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
@@ -2154,3 +2156,229 @@ fn a_pick_point_that_moves_a_hair_keeps_its_stretches_name_and_a_new_edge_keeps_
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
// `selection.get`: the selection read back, resolved, without changing anything.
|
||||
|
||||
fn close(v: &Value, want: f64) -> bool {
|
||||
v.as_f64().is_some_and(|x| (x - want).abs() < 1e-6)
|
||||
}
|
||||
|
||||
fn close3(v: &Value, want: [f64; 3]) -> bool {
|
||||
v.as_array().is_some_and(|a| a.len() == 3 && a.iter().zip(want).all(|(x, w)| close(x, w)))
|
||||
}
|
||||
|
||||
/// A 40 × 30 × 20 box with a Ø10 hole through it at (20, 15).
|
||||
fn holed_box(s: &mut Session) -> String {
|
||||
run(s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
run(s, "solid.cylinder", json!({"radius": 5, "height": 22, "base": [20, 15, -1], "operation": "cut"}));
|
||||
s.model.state().bodies.first().unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// The face of the first body whose surface satisfies `want`, as a pick.
|
||||
fn face_pick(s: &Session, want: impl Fn(solidcraft_kernel::FaceSurface) -> bool) -> Value {
|
||||
let st = s.model.state();
|
||||
let b = st.bodies.first().unwrap();
|
||||
let m = b.mesh();
|
||||
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
|
||||
let f = (0..n).find(|&f| solidcraft_kernel::face_surface(&b.body, f).is_some_and(&want)).expect("face");
|
||||
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
|
||||
json!({"type": "face", "body": b.name, "index": f, "point": p})
|
||||
}
|
||||
|
||||
/// The mesh edge all of whose points satisfy `on`, as a pick.
|
||||
fn edge_pick(s: &Session, on: impl Fn(Vec3) -> bool) -> Value {
|
||||
let st = s.model.state();
|
||||
let b = st.bodies.first().unwrap();
|
||||
let m = b.mesh();
|
||||
let i = m.edges.iter().position(|e| e.iter().all(|p| on(*p))).expect("edge");
|
||||
let e = &m.edges[i];
|
||||
json!({"type": "edge", "body": b.name, "index": i, "point": e[e.len() / 2]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_selection_is_an_empty_list() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(run(&mut s, "selection.get", json!({})), json!({"count": 0, "items": []}));
|
||||
holed_box(&mut s);
|
||||
assert_eq!(run(&mut s, "selection.get", json!({"detail": false})), json!({"count": 0, "items": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_edges_and_bodies_are_resolved() {
|
||||
let mut s = Session::default();
|
||||
let body = holed_box(&mut s);
|
||||
let top = face_pick(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Plane { normal, .. } if normal.z > 0.99));
|
||||
let hole = face_pick(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { .. }));
|
||||
// The top front edge: y = 0, z = 20, along X.
|
||||
let line = edge_pick(&s, |p| p.y.abs() < 1e-9 && (p.z - 20.0).abs() < 1e-9);
|
||||
// The hole's rim on the top face.
|
||||
let rim = edge_pick(&s, |p| (p.z - 20.0).abs() < 1e-9 && ((p.x - 20.0).hypot(p.y - 15.0) - 5.0).abs() < 1e-6);
|
||||
let items = json!([top, hole, line, rim, {"type": "body", "name": body}]);
|
||||
run(&mut s, "select.set", json!({ "items": items }));
|
||||
let revision = s.revision;
|
||||
let (undo, dirty, doc) = (s.undo.len(), s.is_dirty(), s.doc.clone());
|
||||
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
assert_eq!(r["count"], 5, "{r}");
|
||||
let it = r["items"].as_array().unwrap();
|
||||
|
||||
let top = &it[0];
|
||||
assert_eq!(top["kind"], "face");
|
||||
assert_eq!(top["type"], "face", "items keep the select.set form");
|
||||
assert_eq!(top["body"], json!(body));
|
||||
assert_eq!(top["surface"]["type"], "plane", "{top}");
|
||||
assert!(close3(&top["surface"]["normal"], [0.0, 0.0, 1.0]), "{top}");
|
||||
let area = 40.0 * 30.0 - std::f64::consts::PI * 25.0;
|
||||
assert!(top["area_mm2"].as_f64().is_some_and(|a| (a - area).abs() < area * 1e-3), "{top}");
|
||||
assert!(top["name"].as_str().is_some_and(|n| !n.is_empty()), "persistent face name: {top}");
|
||||
assert!(top["name_exact"].is_boolean());
|
||||
|
||||
let hole = &it[1];
|
||||
assert_eq!(hole["surface"]["type"], "cylinder", "{hole}");
|
||||
assert!(close(&hole["surface"]["radius"], 5.0), "{hole}");
|
||||
assert!(hole["surface"]["axis"].as_array().is_some_and(|a| a[2].as_f64().is_some_and(|z| z.abs() > 0.999)), "{hole}");
|
||||
assert!(close(&hole["diameter"], 10.0), "{hole}");
|
||||
|
||||
let line = &it[2];
|
||||
assert_eq!(line["kind"], "edge");
|
||||
assert_eq!(line["curve"], "line", "{line}");
|
||||
assert!(close(&line["length_mm"], 40.0), "{line}");
|
||||
let ends: Vec<f64> = [&line["start"], &line["end"]].iter().map(|p| p[0].as_f64().unwrap()).collect();
|
||||
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 40.0).abs() < 1e-9), "{line}");
|
||||
assert!(line["name"].as_str().is_some_and(|n| n.contains('|')), "edge names join their faces' names: {line}");
|
||||
|
||||
let rim = &it[3];
|
||||
assert_eq!(rim["curve"], "circle", "{rim}");
|
||||
assert!(close(&rim["radius"], 5.0), "{rim}");
|
||||
assert!(close3(&rim["center"], [20.0, 15.0, 20.0]), "{rim}");
|
||||
|
||||
let b = &it[4];
|
||||
assert_eq!(b["kind"], "body");
|
||||
assert_eq!(b["name"], json!(body));
|
||||
let v = 40.0 * 30.0 * 20.0 - std::f64::consts::PI * 25.0 * 20.0;
|
||||
assert!(b["volume_mm3"].as_f64().is_some_and(|x| (x - v).abs() < v * 1e-2), "{b}");
|
||||
assert!(b["feature"]["name"].is_string(), "{b}");
|
||||
assert_eq!(b["visible"], true);
|
||||
assert!(b.get("component").is_none(), "no component outside an assembly");
|
||||
|
||||
// Read-only: nothing changed.
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(s.is_dirty(), dirty);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
|
||||
assert_eq!(s.selection.len(), 5);
|
||||
|
||||
// Without detail: identity only.
|
||||
let r = run(&mut s, "selection.get", json!({"detail": false}));
|
||||
let first = r["items"][0].as_object().unwrap();
|
||||
assert!(first.contains_key("index") && first.contains_key("point") && !first.contains_key("surface"), "{r}");
|
||||
// The items go straight back into select.set.
|
||||
let items: Vec<Value> = r["items"].as_array().unwrap().clone();
|
||||
run(&mut s, "select.clear", json!({}));
|
||||
run(&mut s, "select.set", json!({ "items": items }));
|
||||
assert_eq!(s.selection.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_entities_name_their_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 7}));
|
||||
let curve = r["created"]["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "sketch_curve", "id": curve}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!(it["kind"], "sketch_curve");
|
||||
assert_eq!(it["entity"], "circle", "{it}");
|
||||
assert!(close(&it["radius"], 7.0), "{it}");
|
||||
assert_eq!(it["sketch"], json!(s.active_sketch.unwrap()), "{it}");
|
||||
assert!(it["sketch_name"].is_string());
|
||||
// What it returns goes back in as it is.
|
||||
run(&mut s, "select.set", json!({"items": [it.clone()]}));
|
||||
assert_eq!(run(&mut s, "selection.get", json!({}))["items"][0], *it);
|
||||
|
||||
// A curve picked into a dialog names its sketch; the item keeps it.
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "sketch_curve", "id": curve, "sketch": sketch}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!((&it["sketch"], &it["entity"]), (&json!(sketch), &json!("circle")), "{it}");
|
||||
|
||||
// A line's midpoint: named by its line, reported where it is.
|
||||
let r = run(&mut s, "sketch.line", json!({"points": [[2, 4], [12, 10]]}));
|
||||
let line = r["created"]["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "sketch_midpoint", "id": line}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!(it["kind"], "sketch_midpoint", "{it}");
|
||||
assert_eq!(it["id"], json!(line), "{it}");
|
||||
assert_eq!(it["entity"], "midpoint", "{it}");
|
||||
assert!(close3(&it["position"], [7.0, 7.0, 0.0]), "{it}");
|
||||
assert_eq!(it["sketch"], json!(s.active_sketch.unwrap()), "{it}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_points_report_their_position() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.point.vertex", json!({"point": {"point": [15, 6, 7]}, "name": "Pin"}));
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "construction_point", "name": "Pin"}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let it = &r["items"][0];
|
||||
assert_eq!(it["kind"], "construction_point", "{it}");
|
||||
assert_eq!(it["name"], "Pin", "{it}");
|
||||
assert!(close3(&it["position"], [15.0, 6.0, 7.0]), "{it}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembly_bodies_report_their_component_and_placement() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name": "Part"}));
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 3, "height": 4, "body_name": "Block", "operation": "new"}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence": first}))["occurrence"].as_u64().unwrap();
|
||||
let name = s.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "body", "name": name}, {"type": "occurrence", "path": [second]}]}));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
let b = &r["items"][0];
|
||||
assert_eq!(b["component"]["id"], component, "{b}");
|
||||
assert_eq!(b["component"]["name"], "Part", "{b}");
|
||||
assert_eq!(b["occurrence_path"], json!([second]), "{b}");
|
||||
assert_eq!(b["source_body"], "Block", "{b}");
|
||||
let o = &r["items"][1];
|
||||
assert_eq!(o["kind"], "occurrence");
|
||||
assert_eq!(o["component"]["id"], component, "{o}");
|
||||
assert_eq!(o["bodies"], json!([name]), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_and_stale_selections_never_fail_badly() {
|
||||
let mut s = Session::default();
|
||||
let body = holed_box(&mut s);
|
||||
// `select.set` refuses a body that isn't there (#9737), so `selection.get` never sees one.
|
||||
let e = s.execute("select.set", &json!({"items": [{"type": "edge", "body": "nope", "index": 0, "point": [0, 0, 0]}]}));
|
||||
assert!(matches!(e, Err(EngineError::BadParams { .. })), "{e:?}");
|
||||
// Items that point at nothing still come back, by identity.
|
||||
let stale = json!([
|
||||
{"type": "face", "body": body, "index": 999_999, "point": [1e300, -1e300, 0]},
|
||||
{"type": "edge", "body": body, "index": usize::MAX, "point": [0, 0, 0]},
|
||||
{"type": "vertex", "body": body, "point": [0, 0, 0]},
|
||||
{"type": "feature", "id": u64::MAX},
|
||||
{"type": "sketch_curve", "id": "zz"},
|
||||
{"type": "sketch_point", "id": "99999999999999999999999:p"},
|
||||
{"type": "sketch_constraint", "id": ""},
|
||||
{"type": "profile", "sketch": 12345, "index": 3},
|
||||
{"type": "plane", "name": "XY"},
|
||||
{"type": "axis", "name": "Q"},
|
||||
]);
|
||||
run(&mut s, "select.set", json!({ "items": stale }));
|
||||
let r = run(&mut s, "selection.get", json!({}));
|
||||
assert_eq!(r["count"], 10, "{r}");
|
||||
for v in [json!(1), json!("yes"), json!([true]), json!({"a": 1})] {
|
||||
let e = s.execute("selection.get", &json!({ "detail": v })).unwrap_err();
|
||||
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
|
||||
}
|
||||
for v in [json!(null), json!([]), json!("x"), json!(f64::MAX), json!({"detail": null})] {
|
||||
assert!(s.execute("selection.get", &v).is_ok(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.thread", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its nominal diameter (a 12 mm hole is M12) or else the size whose tap drill it is); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); old_auto_size?: bool (an automatic size keeps the rule of designs saved before a hole was sized by its nominal diameter: a 12 mm hole is M14; set on those threads, keeps them as they were when edited); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, after the groove is cut, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a hole as wide as the thread or wider is narrowed to the thread's minor diameter first, and the reply's warning says so; a shaft thicker than the thread is refused with the size to make it, never turned down"),
|
||||
.params("face: [x,y,z] on a cylindrical face; form?: metric|trapezoidal|acme|buttress|buttress_asme|bspp|rp|bspt|npt (default: the one `designation` names, else metric; square is refused: no current standard defines it); designation?: the size, as its standard writes it: metric \"M8\", \"M8x1\", \"M16xPh3P1.5\" (ISO 68-1, ISO 261, ISO 965-1); trapezoidal \"Tr20x4\", \"Tr40x14(P7)\" (ISO 2904, ISO 2903); buttress \"S20x4\", \"S40x14(P7)\" (DIN 513, 3°/30°); acme \"1/2-10 ACME\" (ASME B1.5); buttress_asme \"2.5-8 BUTT\" (ASME B1.9, 7°/45°, basic profile; no size table); bspp \"G 1/2\" (ISO 228-1, 55° parallel); rp \"Rp 1/2\" (ISO 7-1's parallel internal thread, 55°, an R screws into it; internal only); bspt \"R 1/2\" external, \"Rc 1/2\" internal (ISO 7-1, 55°, taper 1 in 16); npt \"1/2-14 NPT\" (ASME B1.20.1, 60°, taper 1 in 16) — a taper thread starts at a free end (the pipe end or the hole mouth) with its standard diameter there, and an external one runs its standard length unless `length` or `up_to` says otherwise (default: the standard size that fits the face, a shaft its major diameter, a hole its nominal diameter (a 12 mm hole is M12) or else the size whose tap drill it is); hand?: right|left|both (default right; both: a right- and a left-handed groove crossing); old_auto_size?: bool (an automatic size keeps the rule of designs saved before a hole was sized by its nominal diameter: a 12 mm hole is M14; set on those threads, keeps them as they were when edited); steep_flank?: start|end (buttress only, default end: the steep flank of the screw's teeth faces the thread's end, the way its axis points); starts?: 1..8 (lead = starts x pitch: that many grooves, each a turn's starts-th further round; a designation that gives the lead gives the starts); ease_out?: expr|standard|short (modelled threads: the inner end, away from where the thread starts, fades out over this length (shorter than a turn too), its section moving out of the material as it turns; standard 2.5 P, short 1.25 P per ISO 3508; it lies past the full-form length, up to a shoulder's clearance; at a free end, inside the face; without it the groove stops square there, ending in its own profile; the start never fades: it runs off its free end, plain or chamfered (`tip`)); tip?: none|start|both (modelled external threads: start chamfers the end the thread starts from, which must be free, both each free end the thread reaches, ISO 4753's chamfered end, the last teeth tapering into the cone; chamfer_angle?: angle expr to the axis, default 45 deg; chamfer_depth?: expr, radial from the major diameter, default for a metric thread down to the external minor diameter d3 = d - 1.226869 P (ISO 724), the largest ISO 4753 allows; other forms give it); extent?: full|length|up_to (default: length when `length` is given, up_to when `up_to` is, else full: the whole face); length?: expr (from the end the thread starts at); up_to?: [x,y,z] (a point, such as on a shoulder face: the thread runs to its place along the axis) | plane name or {origin, normal} (to where the plane crosses the axis); from?: [x,y,z] near the end of the face the thread starts at (default: the face's only free end, the one with no material past it; else the end the `face` point lies within a third of the face of; a pick in the middle of a face whose ends are alike is refused); modeled?: bool (default false); continuous_flanks?: bool (default true; false keeps the historical half-turn faces and groove-before-chamfer construction). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body: past a free end the groove runs out a pitch into the air (or through a chamfer); at a shoulder, a head or a blind hole's bottom it stops with its section clear of it, never cutting into it; a hole as wide as the thread or wider is narrowed to the thread's minor diameter first, and the reply's warning says so; a shaft thicker than the thread is refused with the size to make it, never turned down"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -3884,11 +3884,17 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, msg));
|
||||
}
|
||||
let designation_is_none = designation.is_none();
|
||||
let continuous_flanks = match p.get("continuous_flanks") {
|
||||
None => true,
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(_) => return Err(bad(cmd, "`continuous_flanks` must be a boolean")),
|
||||
};
|
||||
let kind = FeatureKind::Thread {
|
||||
face: FacePick(face),
|
||||
designation,
|
||||
length,
|
||||
modeled,
|
||||
continuous_flanks,
|
||||
from,
|
||||
up_to,
|
||||
form,
|
||||
|
||||
@@ -424,6 +424,7 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
@@ -1368,6 +1368,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
angle: angle.unwrap_or(0.0),
|
||||
suppressed: false,
|
||||
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
|
||||
captured: None,
|
||||
};
|
||||
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
|
||||
// come only from how its origin's x was chosen. (An angle relation's angle is its tilt.)
|
||||
@@ -1413,6 +1414,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
};
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
@@ -2315,6 +2317,45 @@ mod reach_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "joints_relations_tests.rs"]
|
||||
mod relations_tests;
|
||||
/// Capture Position: every moving joint keeps its current values (as the solver places them) as
|
||||
/// its captured position. Returns how many joints were captured.
|
||||
pub(in crate::cmd) fn capture_values(s: &mut Session) -> usize {
|
||||
let current = joints::joint_values(&s.doc.assembly);
|
||||
if s.doc.assembly.joints.iter().all(|j| j.kind == JointKind::Rigid) {
|
||||
return 0;
|
||||
}
|
||||
let mut n = 0;
|
||||
for j in s.doc_mut().assembly.joints.iter_mut().filter(|j| j.kind != JointKind::Rigid) {
|
||||
let mut q = current.get(&j.id).cloned().unwrap_or_else(|| j.values.clone());
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
j.captured = Some(q);
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Revert Position: every joint with a captured position goes back to it, clamped to its limits.
|
||||
/// A joint with none (a design from before captured joint values) keeps its values. The values
|
||||
/// put back drop their expressions, as `joint.drive` does. Returns how many joints went back.
|
||||
pub(in crate::cmd) fn revert_values(s: &mut Session) -> usize {
|
||||
if !s.doc.assembly.joints.iter().any(|j| j.captured.is_some() && j.kind != JointKind::Rigid) {
|
||||
return 0;
|
||||
}
|
||||
let mut n = 0;
|
||||
for j in s.doc_mut().assembly.joints.iter_mut().filter(|j| j.kind != JointKind::Rigid) {
|
||||
let Some(mut q) = j.captured.clone() else { continue };
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
joints::clamp(j, &mut q);
|
||||
j.values = q;
|
||||
for i in 0..j.kind.dofs().len() {
|
||||
j.exprs.set_value(i, None);
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
resolve(s);
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "joints_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -256,6 +256,7 @@ fn a_panel_full_of_parts_placed_by_relations_stays_solved() {
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
};
|
||||
if let Some(q) = solidcraft_doc::joints::relation_values(&s.doc, &s.model.state(), &j) {
|
||||
j.values = q;
|
||||
|
||||
@@ -191,6 +191,50 @@ fn slider_limits_clamp() {
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 19.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_position_saves_joint_values_and_revert_position_puts_them_back() {
|
||||
// #14275: Capture Position also saves every joint's values; Revert Position puts them back.
|
||||
let mut s = two_cubes();
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Rail"}));
|
||||
let b = Vec3::new(25.0, 5.0, 0.0);
|
||||
// Nothing captured yet: Revert Position leaves the joint where it is.
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3}));
|
||||
let r = run(&mut s, "component.revert_position", json!({}));
|
||||
assert_eq!(r["joints"], json!(0), "{r}");
|
||||
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 13.0)));
|
||||
// Capture at 3 mm, drive away, and revert.
|
||||
let r = run(&mut s, "component.capture_position", json!({}));
|
||||
assert_eq!(r["joints"], json!(1), "{r}");
|
||||
assert_eq!(s.doc.assembly.joints[0].captured, Some(vec![3.0]));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 9}));
|
||||
let r = run(&mut s, "component.revert_position", json!({}));
|
||||
assert_eq!(r["joints"], json!(1), "{r}");
|
||||
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 13.0)));
|
||||
// One undo step puts it back where it was driven.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 19.0)));
|
||||
// The captured values are saved with the design.
|
||||
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
*s.doc_mut() = doc;
|
||||
run(&mut s, "joint.solve", json!({}));
|
||||
run(&mut s, "component.revert_position", json!({}));
|
||||
assert!(near(place(&s, "B", b), Vec3::new(5.0, 5.0, 13.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_without_captured_joint_values_loads_unchanged() {
|
||||
// A file saved before captured joint values has no `captured`: it loads with the same values,
|
||||
// and a joint with none saves without the key.
|
||||
let mut s = two_cubes();
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Rail"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 4}));
|
||||
let json = s.doc.to_json();
|
||||
assert!(!json.contains("\"captured\""), "no captured key before Capture Position");
|
||||
let doc = solidcraft_doc::Document::from_json(&json).unwrap();
|
||||
assert_eq!(doc.assembly.joints[0].captured, None);
|
||||
assert_eq!(doc.assembly.joints[0].values, s.doc.assembly.joints[0].values);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rigid_cycles_solve_and_conflicts_report() {
|
||||
let mut s = two_cubes();
|
||||
|
||||
@@ -166,7 +166,7 @@ fn arc_points(center: Vec3, axis: UnitVec3, start: Vec3, angle: f64) -> Vec<Vec3
|
||||
|
||||
/// The solved sketch holding a picked curve or point: `sketch` when given, else the active
|
||||
/// sketch, else the last sketch with that id.
|
||||
fn sketch_of<'a>(
|
||||
pub(super) fn sketch_of<'a>(
|
||||
s: &Session,
|
||||
st: &'a solidcraft_doc::ModelState,
|
||||
sketch: Option<u64>,
|
||||
@@ -489,6 +489,23 @@ pub(crate) fn shape_of(s: &Session, sel: &Sel) -> Option<(Shape, Vec<Vec3>, Vec<
|
||||
})
|
||||
}
|
||||
|
||||
/// What `selection.get` reports of a picked item beyond its identity: the measurement
|
||||
/// `inspect.measure` gives it (length, area, volume, radius…), its direction where it has one
|
||||
/// (a planar face's normal, a straight edge's direction) and, for an edge, whether it is a line,
|
||||
/// a circle, an arc or another curve.
|
||||
pub(super) fn resolve(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<(Value, Option<Vec3>)> {
|
||||
let item = describe(s, st, sel)?;
|
||||
let mut json = item.json;
|
||||
if matches!(sel, Sel::Edge { .. }) {
|
||||
json["curve"] = match item.shape {
|
||||
Shape::Line { .. } => json!("line"),
|
||||
Shape::Round { .. } => json.get("round").cloned().unwrap_or_else(|| json!("arc")),
|
||||
_ => json!("other"),
|
||||
};
|
||||
}
|
||||
Some((json, item.dir))
|
||||
}
|
||||
|
||||
fn sample<T: Copy>(v: &[T], max: usize) -> Vec<T> {
|
||||
let step = v.len().div_ceil(max).max(1);
|
||||
v.iter().step_by(step).copied().collect()
|
||||
|
||||
@@ -89,6 +89,7 @@ mod parts;
|
||||
mod point_cloud;
|
||||
mod script;
|
||||
mod section;
|
||||
mod selection;
|
||||
pub mod settings;
|
||||
pub(crate) use clipboard::{component_path_form, copy_keys};
|
||||
pub(crate) use component::matrix::{rigid as rigid_matrix, tolerance as matrix_tolerance};
|
||||
@@ -364,6 +365,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(browser::COMMANDS.iter());
|
||||
v.extend(delete::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(selection::COMMANDS.iter());
|
||||
v.extend(clipboard::COMMANDS.iter());
|
||||
v.extend(sketch_clipboard::COMMANDS.iter());
|
||||
v.extend(script::COMMANDS.iter());
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Reading the selection (`selection.get`): what is selected, resolved to what an agent or a
|
||||
//! script needs to act on it, without changing the design, the selection or the undo history.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::measure_sel::{resolve, sketch_of};
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.get", "Get Selection", selection_get).icon("select").noundo().params(
|
||||
"detail?: bool (default true: resolve each item's geometry) → {count, items: [{kind (= type: body|face|edge|vertex|feature|sketch_curve|\
|
||||
sketch_point|sketch_midpoint|sketch_constraint|profile|plane|axis|construction_point|occurrence…), the selection's own fields as select.set takes them (body, index, point, \
|
||||
id, sketch, path…), and with detail: face: name (persistent face name), name_exact, surface ({type: plane|cylinder|cone|sphere|torus|\
|
||||
other, normal/point, origin/axis/radius…}), area_mm2, planar; edge: name (persistent edge name), curve (line|circle|arc|other), \
|
||||
length_mm, start, end, center/axis/radius for round edges; body: volume_mm3, feature, visible; any body item: component {id, name} \
|
||||
and occurrence_path in an assembly; sketch items: sketch (its id, as select.set takes it), sketch_name, entity (line|circle|arc|point|midpoint|…), position for points and midpoints}]}; read-only, [] when nothing is \
|
||||
selected",
|
||||
)];
|
||||
|
||||
/// Items resolved in full; the rest of a huge selection is listed by identity only.
|
||||
const MAX_DETAIL: usize = 500;
|
||||
|
||||
fn selection_get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "selection.get";
|
||||
let detail = match p.get("detail") {
|
||||
None | Some(Value::Null) => true,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`detail` must be true or false")),
|
||||
};
|
||||
let s: &Session = s;
|
||||
let mut ctx = Context::new(s)?;
|
||||
let items: Vec<Value> = s
|
||||
.selection
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, sel)| {
|
||||
let mut v = identity(sel);
|
||||
if detail && i < MAX_DETAIL {
|
||||
ctx.add_detail(sel, &mut v);
|
||||
}
|
||||
Value::Object(v)
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({"count": items.len(), "items": items});
|
||||
if detail && s.selection.len() > MAX_DETAIL {
|
||||
out["detail_limit"] = json!(MAX_DETAIL);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The selection item as `select.set` takes it, with `kind` naming what it is.
|
||||
fn identity(sel: &Sel) -> Map<String, Value> {
|
||||
let mut v = match serde_json::to_value(sel) {
|
||||
Ok(Value::Object(m)) => m,
|
||||
_ => Map::new(),
|
||||
};
|
||||
if let Some(t) = v.get("type").cloned() {
|
||||
v.insert("kind".into(), t);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// What resolving items shares: each body's placement and edge names, worked out once.
|
||||
struct Context<'a> {
|
||||
s: &'a Session,
|
||||
st: std::sync::Arc<solidcraft_doc::ModelState>,
|
||||
/// World body name → (definition name, component, occurrence path); only in an assembly.
|
||||
placements: Option<BTreeMap<String, (String, u64, Vec<u64>)>>,
|
||||
edge_names: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'a> Context<'a> {
|
||||
fn new(s: &'a Session) -> Result<Self> {
|
||||
Ok(Context { s, st: s.world_state()?, placements: None, edge_names: BTreeMap::new() })
|
||||
}
|
||||
|
||||
fn add_detail(&mut self, sel: &Sel, v: &mut Map<String, Value>) {
|
||||
// The measurement `inspect.measure` gives the item, without its own `type`.
|
||||
let mut dir = None;
|
||||
if let Some((Value::Object(m), d)) = resolve(self.s, &self.st, sel) {
|
||||
dir = d;
|
||||
for (k, x) in m {
|
||||
if k != "type" && !v.contains_key(&k) {
|
||||
v.insert(k, x);
|
||||
}
|
||||
}
|
||||
}
|
||||
match sel {
|
||||
Sel::Body { name } => {
|
||||
self.body_info(name, v);
|
||||
if let Some(b) = self.st.body(name) {
|
||||
let feature = self.s.doc.feature(b.feature);
|
||||
v.insert("feature".into(), json!({"id": b.feature, "name": feature.map(|f| f.name.clone())}));
|
||||
}
|
||||
v.insert("visible".into(), json!(self.s.body_visible(name)));
|
||||
}
|
||||
Sel::Face { body, index, .. } => {
|
||||
self.body_info(body, v);
|
||||
if let Some(b) = self.st.body(body) {
|
||||
let names = solidcraft_doc::naming::face_names(b);
|
||||
if let Some(n) = names.get(*index).filter(|n| !n.is_empty()) {
|
||||
v.insert("name".into(), json!(n));
|
||||
v.insert("name_exact".into(), json!(solidcraft_doc::naming::face_name_exact(b, *index)));
|
||||
}
|
||||
v.insert("surface".into(), surface(solidcraft_kernel::face_surface(&b.body, *index), dir));
|
||||
}
|
||||
}
|
||||
Sel::Edge { body, index, .. } => {
|
||||
self.body_info(body, v);
|
||||
if let Some(b) = self.st.body(body) {
|
||||
// Its ends from its exact curve; an intersection curve, which the record holds
|
||||
// only as its surfaces, has none to give.
|
||||
if let Ok(segs) = solidcraft_kernel::edge_path(&b.body, *index)
|
||||
&& let (Some(a), Some(z)) = (segs.first(), segs.last())
|
||||
{
|
||||
v.insert("start".into(), json!(a.start()));
|
||||
v.insert("end".into(), json!(z.end()));
|
||||
}
|
||||
let names = self.edge_names.entry(body.clone()).or_insert_with(|| solidcraft_doc::naming::edge_names(b));
|
||||
if let Some(n) = names.get(*index).filter(|n| !n.is_empty()) {
|
||||
v.insert("name".into(), json!(n));
|
||||
}
|
||||
if let Some(d) = dir {
|
||||
v.insert("direction".into(), json!(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
Sel::Vertex { body, .. } => self.body_info(body, v),
|
||||
Sel::Feature { id } => {
|
||||
if let Some(f) = self.s.doc.feature(*id) {
|
||||
v.insert("name".into(), json!(f.name));
|
||||
v.insert("feature_type".into(), json!(f.kind.type_name()));
|
||||
v.insert("suppressed".into(), json!(f.suppressed));
|
||||
}
|
||||
}
|
||||
Sel::SketchCurve { id, sketch } => self.sketch_entity(*sketch, id, Entity::Curve, v),
|
||||
Sel::ExternalSketchCurve { sketch, id } => self.sketch_entity(Some(*sketch), id, Entity::Curve, v),
|
||||
Sel::SketchPoint { id } => self.sketch_entity(None, id, Entity::Point, v),
|
||||
// The midpoint of a line (`id` names the line): its sketch and where it is.
|
||||
Sel::SketchMidpoint { id } => self.sketch_entity(None, id, Entity::Midpoint, v),
|
||||
Sel::ExternalSketchPoint { sketch, id } => self.sketch_entity(Some(*sketch), id, Entity::Point, v),
|
||||
Sel::SketchConstraint { id } => self.sketch_entity(None, id, Entity::Constraint, v),
|
||||
Sel::Profile { sketch, .. } => {
|
||||
if let Some(ss) = self.st.sketch(*sketch) {
|
||||
v.insert("sketch".into(), json!(ss.feature));
|
||||
v.insert("sketch_name".into(), json!(ss.name));
|
||||
}
|
||||
}
|
||||
Sel::Occurrence { path } => {
|
||||
if let Ok(o) = self.s.occurrence_placement(path) {
|
||||
v.insert("name".into(), json!(o.name));
|
||||
v.insert("component".into(), self.component(o.component));
|
||||
v.insert("grounded".into(), json!(o.grounded));
|
||||
v.insert("bodies".into(), json!(o.bodies.iter().map(|b| &b.world).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
// What these are (origin, direction, position) comes from the measurement above.
|
||||
Sel::Plane { .. } | Sel::Axis { .. } | Sel::ConstructionPoint { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A body item's component and occurrence path (in an assembly), and its definition's name
|
||||
/// when it is a further placement of it.
|
||||
fn body_info(&mut self, body: &str, v: &mut Map<String, Value>) {
|
||||
if self.s.doc.occurrences.is_empty() {
|
||||
return;
|
||||
}
|
||||
let s = self.s;
|
||||
let placements = self.placements.get_or_insert_with(|| {
|
||||
s.doc
|
||||
.body_frames_with_source(&s.model.state())
|
||||
.into_iter()
|
||||
.map(|(world, source, component, path, _)| (world, (source, component, path)))
|
||||
.collect()
|
||||
});
|
||||
let Some((source, component, path)) = placements.get(body).cloned() else { return };
|
||||
if source != body {
|
||||
v.insert("source_body".into(), json!(source));
|
||||
}
|
||||
if !path.is_empty() {
|
||||
v.insert("occurrence_path".into(), json!(path));
|
||||
}
|
||||
v.insert("component".into(), self.component(component));
|
||||
}
|
||||
|
||||
fn component(&self, id: u64) -> Value {
|
||||
let name = if id == 0 { Some(self.s.doc.name.clone()) } else { self.s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()) };
|
||||
json!({"id": id, "name": name})
|
||||
}
|
||||
|
||||
/// A sketch curve, point, line midpoint or constraint: its sketch and what kind of entity it is.
|
||||
fn sketch_entity(&self, sketch: Option<u64>, id: &str, entity: Entity, v: &mut Map<String, Value>) {
|
||||
// "<sketch id>:<point id>" names a point of any sketch.
|
||||
let (sketch, id) = match (entity, id.split_once(':').and_then(|(k, p)| Some((k.parse::<u64>().ok()?, p)))) {
|
||||
(Entity::Point, Some((k, p))) if sketch.is_none() => (Some(k), p),
|
||||
_ => (sketch, id),
|
||||
};
|
||||
let found = match entity {
|
||||
Entity::Curve | Entity::Midpoint => sketch_of(self.s, &self.st, sketch, |sk| sk.curve_index(id).is_some()),
|
||||
Entity::Point => sketch_of(self.s, &self.st, sketch, |sk| sk.point_index(id).is_some()),
|
||||
Entity::Constraint => sketch_of(self.s, &self.st, sketch, |sk| sk.constraints.iter().any(|c| c.id == id)),
|
||||
};
|
||||
let Some(ss) = found else { return };
|
||||
v.insert("sketch".into(), json!(ss.feature));
|
||||
v.insert("sketch_name".into(), json!(ss.name));
|
||||
let sk = &ss.sketch;
|
||||
match entity {
|
||||
Entity::Curve => {
|
||||
if let Some(c) = sk.curve_index(id).and_then(|i| sk.curves.get(i)) {
|
||||
v.insert("entity".into(), kind_name(serde_json::to_value(&c.kind).ok()));
|
||||
v.insert("construction".into(), json!(c.construction));
|
||||
}
|
||||
}
|
||||
Entity::Point => {
|
||||
if let Some(pt) = sk.point_index(id).and_then(|i| sk.points.get(i)) {
|
||||
v.insert("entity".into(), json!("point"));
|
||||
v.entry("position").or_insert_with(|| json!(ss.plane.to_world(pt.pos)));
|
||||
}
|
||||
}
|
||||
Entity::Midpoint => {
|
||||
v.insert("entity".into(), json!("midpoint"));
|
||||
let line = sk.curve_index(id).and_then(|i| sk.curves.get(i)).and_then(|c| match c.kind {
|
||||
solidcraft_sketch::CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some((a, b)) = line {
|
||||
v.entry("position").or_insert_with(|| json!(ss.plane.to_world(a.lerp(b, 0.5))));
|
||||
}
|
||||
}
|
||||
Entity::Constraint => {
|
||||
if let Some(c) = sk.constraints.iter().find(|c| c.id == id) {
|
||||
v.insert("entity".into(), kind_name(serde_json::to_value(c).ok()));
|
||||
v.insert("driven".into(), json!(c.driven));
|
||||
if let Some(param) = &c.param {
|
||||
v.insert("parameter".into(), json!(param));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Entity {
|
||||
Curve,
|
||||
Point,
|
||||
Midpoint,
|
||||
Constraint,
|
||||
}
|
||||
|
||||
/// The `type` tag of a serialized sketch curve or constraint.
|
||||
fn kind_name(v: Option<Value>) -> Value {
|
||||
v.and_then(|v| v.get("type").cloned()).unwrap_or_else(|| json!("other"))
|
||||
}
|
||||
|
||||
/// A face's surface: an analytic one with its defining values, else a plane when the face is
|
||||
/// flat (`normal` from its triangles), else `other`.
|
||||
fn surface(s: Option<solidcraft_kernel::FaceSurface>, planar: Option<Vec3>) -> Value {
|
||||
use solidcraft_kernel::FaceSurface as F;
|
||||
match s {
|
||||
Some(F::Plane { normal, point }) => json!({"type": "plane", "normal": normal, "point": point}),
|
||||
Some(F::Cylinder { origin, axis, radius }) => json!({"type": "cylinder", "origin": origin, "axis": axis, "radius": radius}),
|
||||
Some(F::Cone { apex, axis, half_angle }) => json!({"type": "cone", "apex": apex, "axis": axis, "half_angle_deg": half_angle.to_degrees()}),
|
||||
Some(F::Sphere { center, radius }) => json!({"type": "sphere", "center": center, "radius": radius}),
|
||||
Some(F::Torus { center, axis, major, minor }) => {
|
||||
json!({"type": "torus", "center": center, "axis": axis, "major_radius": major, "minor_radius": minor})
|
||||
}
|
||||
None => match planar {
|
||||
Some(n) => json!({"type": "plane", "normal": n}),
|
||||
None => json!({"type": "other"}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use crate::schema::surface::{
|
||||
UnstitchParams, UntrimParams,
|
||||
};
|
||||
use crate::schema::surface_blend::BlendParams;
|
||||
use crate::schema::surface_ruled::SurfaceRuledParams;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -35,6 +36,7 @@ const SURFACE_TRIM_CONTRACT: TypedContract<SurfaceTrimParams> = TypedContract::n
|
||||
const SURFACE_SWEEP_CONTRACT: TypedContract<SurfaceSweepParams> = TypedContract::new();
|
||||
const SURFACE_LOFT_CONTRACT: TypedContract<SurfaceLoftParams> = TypedContract::new();
|
||||
const BLEND_CONTRACT: TypedContract<BlendParams> = TypedContract::new();
|
||||
const SURFACE_RULED_CONTRACT: TypedContract<SurfaceRuledParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.patch", "Patch", patch)
|
||||
@@ -91,9 +93,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&OFFSET_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("faces: [[x,y,z]] (points on the faces); body?; distance: expr (signed, along the faces' normals; 0 copies the faces); chain?: bool (each pick's whole smooth chain of faces); tolerance?: expr (how far a fitted face may stray, default 0.01 mm). Planes and cylinders move exactly; other faces are fitted. A new surface body (a solid where its faces close)"),
|
||||
CommandSpec::new("surface.ruled", "Ruled Surface", ruled)
|
||||
CommandSpec::new("surface.ruled", "Ruled Surface", crate::checked!("surface.ruled", ruled))
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_ruled")
|
||||
.contract(&SURFACE_RULED_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("body; edges: [[x,y,z]] (points on a chain of its edges, open or closed); distance: expr (how far the strip reaches); mode?: normal|tangent|angle|direction (default normal: along the normal of the face beside each edge; tangent: continuing it); angle?: expr (angle mode: from `direction`, else from the face's normal, turned away from the face; 90 deg from the normal is tangent); direction?: [x,y,z] (direction mode: the rulings; angle mode: the reference, e.g. a pull direction for a draft); face?: [x,y,z] (a point on the face the directions come from; default: the face beside the most picked edges); flip?: bool (the other side); corners?: mitre|round (sharp corners of the chain; inside corners are always cut back); far?: [null|[[x,y,z]]] (per chain edge, the interior control points of a free strip's far curve, as the default curve's handles list them; null: the default). A constant ruling, or one keeping its angle to an arc's axis, gives an exact plane, cylinder or cone; where the rulings turn freely along an edge, the strip is the exact ruled surface to a far curve of its own (from corner to corner, fair, near the rulings' ends). A new surface body"),
|
||||
CommandSpec::new("surface.blend", "Blend Surface", crate::checked!("surface.blend", blend))
|
||||
.at("SURFACE", "CREATE")
|
||||
|
||||
@@ -355,7 +355,10 @@ impl Ahead {
|
||||
let stop = self.0.stop.clone();
|
||||
stop.within(|| {
|
||||
let (after, _) = self.0.preview_in_place(commands, Some(step))?;
|
||||
after.world_state()?.warm_display();
|
||||
// Stopped by its watcher: nothing it ends with will be shown.
|
||||
if !solidcraft_doc::progress::cancelled() {
|
||||
after.world_state()?.warm_display();
|
||||
}
|
||||
Ok(Evaluated(after.model))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1462,8 +1462,9 @@ fn sketch_names_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
})
|
||||
}
|
||||
|
||||
/// Make every pending occurrence move the occurrence's placement. The exploded view is shown,
|
||||
/// never captured: it stays shown, and the design keeps its placements.
|
||||
/// Make every pending occurrence move the occurrence's placement, and save every moving joint's
|
||||
/// current values for Revert Position. The exploded view is shown, never captured: it stays
|
||||
/// shown, and the design keeps its placements.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CapturePositionParams {}
|
||||
@@ -1473,13 +1474,16 @@ pub struct CapturePositionParams {}
|
||||
pub struct CapturePositionResult {
|
||||
/// How many pending moves became the occurrences' placements.
|
||||
pub captured: usize,
|
||||
/// How many joints had their current values saved, for Revert Position to put back.
|
||||
pub joints: usize,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Drop pending occurrence moves, putting the occurrences back where they were captured. Not an
|
||||
/// undo step.
|
||||
/// Drop pending occurrence moves, putting the occurrences back where they were captured. Without
|
||||
/// `occurrences`, the joints also go back to the values Capture Position saved, clamped to their
|
||||
/// limits. One undo step, not a history step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RevertPositionParams {
|
||||
@@ -1494,6 +1498,9 @@ pub struct RevertPositionParams {
|
||||
pub struct RevertPositionResult {
|
||||
/// How many pending moves were dropped.
|
||||
pub reverted: usize,
|
||||
/// How many joints went back to the values Capture Position saved (clamped to their
|
||||
/// limits). Only when every occurrence is reverted (no `occurrences`).
|
||||
pub joints: usize,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
|
||||
@@ -137,6 +137,7 @@ pub mod surface_analysis;
|
||||
pub mod surface_blend;
|
||||
pub mod surface_boundary;
|
||||
pub mod surface_match;
|
||||
pub mod surface_ruled;
|
||||
pub mod timeline_clipboard;
|
||||
pub mod ui_prefs;
|
||||
pub mod view_navigation;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Contract for Ruled Surface (`surface.ruled`) in `cmd/surface.rs` (#13134). It replies with the
|
||||
//! plain feature reply, [`super::features::FeatureResult`].
|
||||
//!
|
||||
//! The handler reads `angle` only in angle mode and `direction` only in angle and direction
|
||||
//! modes, and says when either is missing there. The contract checks their form whenever they are
|
||||
//! given and leaves which mode needs them to the handler. Whether the body exists, the points lie
|
||||
//! on a chain of its edges and on a face, and the expressions evaluate are the handler's to check.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
// `Vec3Geom::len`, which a direction's check measures as the handler does.
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::check::whole;
|
||||
use super::types::{AngleExpr, FeatureCommon, LengthExpr, Point3, RuledCornersParam, RuledModeParam, feature_common};
|
||||
use crate::params;
|
||||
|
||||
/// Make a surface body: a strip that reaches `distance` out from a chain of a body's edges, along
|
||||
/// rulings set by `mode`. A constant ruling, or one keeping its angle to an arc's axis, gives an
|
||||
/// exact plane, cylinder or cone; where the rulings turn freely along an edge, the strip is the
|
||||
/// exact ruled surface to a far curve of its own.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SurfaceRuledParams {
|
||||
/// The body whose edges the strip starts from, by name.
|
||||
#[serde(deserialize_with = "body")]
|
||||
pub body: String,
|
||||
pub edges: RuledEdges,
|
||||
/// How far the strip reaches from the edges.
|
||||
pub distance: LengthExpr,
|
||||
#[serde(default)]
|
||||
pub mode: Option<RuledModeParam>,
|
||||
/// Angle mode: the rulings' angle from `direction`, else from the face's normal, turned away
|
||||
/// from the face (90 deg from the normal is tangent). Required in angle mode; read in no
|
||||
/// other.
|
||||
#[serde(default)]
|
||||
pub angle: Option<AngleExpr>,
|
||||
#[serde(default)]
|
||||
pub direction: Option<RuledDirection>,
|
||||
#[serde(default)]
|
||||
pub face: Option<RuledFace>,
|
||||
/// Make the strip on the other side. Default: false.
|
||||
#[serde(default)]
|
||||
pub flip: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub corners: Option<RuledCornersParam>,
|
||||
#[serde(default)]
|
||||
pub far: Option<RuledFar>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// The handler's message for `body` when it isn't text.
|
||||
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(D::Error::custom(whole("`body` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The handler's message for `edges` it can't read: not a list, no points, or over 10,000.
|
||||
const EDGES_MESSAGE: &str = "`edges` must list points on a chain of the body's edges";
|
||||
|
||||
/// The handler's message for `far` it can't read.
|
||||
const FAR_MESSAGE: &str = "`far` must be a list, per edge, of null or [[x, y, z], ...]";
|
||||
|
||||
/// Points on a chain of the body's edges, 1 to 10,000, as `params::vec3` reads each. The handler
|
||||
/// drops an item that isn't a point and takes the rest; the contract refuses it, with the
|
||||
/// handler's message for a list it can't read.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuledEdges(pub Vec<Vec3>);
|
||||
|
||||
impl<'de> Deserialize<'de> for RuledEdges {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let points = v.as_array().and_then(|items| items.iter().map(params::vec3).collect::<Option<Vec<_>>>());
|
||||
match points {
|
||||
Some(points) if !points.is_empty() && points.len() <= 10_000 => Ok(RuledEdges(points)),
|
||||
_ => Err(D::Error::custom(whole(EDGES_MESSAGE))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RuledEdges {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RuledEdges".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "Points in mm on a chain of the body's edges, open or closed: 1 to 10,000.",
|
||||
"type": "array",
|
||||
"items": point,
|
||||
"minItems": 1,
|
||||
"maxItems": 10000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The rulings' direction in direction mode, or the reference the angle is measured from in angle
|
||||
/// mode (a pull direction for a draft): not zero. Read in those modes only. The schema can't say
|
||||
/// "not zero", so a zero direction matches it and the check refuses it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RuledDirection(pub Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for RuledDirection {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
// As the handler reads it: a point whose length is over 1e-12.
|
||||
params::vec3(&Value::deserialize(d)?)
|
||||
.filter(|v| v.len() > 1e-12)
|
||||
.map(RuledDirection)
|
||||
.ok_or_else(|| D::Error::custom(whole("`direction` must be a non-zero [x, y, z]")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RuledDirection {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RuledDirection".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = generator.subschema_for::<Point3>();
|
||||
schema.insert(
|
||||
"description".into(),
|
||||
"Direction mode: the rulings' direction; angle mode: the reference the angle is measured from (a pull direction for a draft). Not zero. Required in direction mode; read in no other mode but angle.".into(),
|
||||
);
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
/// A point on the face the rulings' directions come from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RuledFace(pub Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for RuledFace {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec3(&Value::deserialize(d)?).map(RuledFace).ok_or_else(|| D::Error::custom(whole("`face` must be [x, y, z] on a face")))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RuledFace {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RuledFace".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = generator.subschema_for::<Point3>();
|
||||
schema.insert(
|
||||
"description".into(),
|
||||
"A point in mm on the face the rulings' directions come from. Default: the face beside the most picked edges.".into(),
|
||||
);
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
/// A free strip's far-curve handles, per chain edge in order: `null` for the default curve, or the
|
||||
/// interior control points as the default curve's handles list them. At most 10,000 edges and
|
||||
/// 100,000 points each. Read in order, as the handler reads it, so the first item it can't read
|
||||
/// gives the message.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuledFar(pub Vec<Option<Vec<Vec3>>>);
|
||||
|
||||
impl<'de> Deserialize<'de> for RuledFar {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let lists = v.as_array().filter(|a| a.len() <= 10_000).ok_or_else(|| D::Error::custom(whole(FAR_MESSAGE)))?;
|
||||
let mut far = Vec::with_capacity(lists.len());
|
||||
for list in lists {
|
||||
far.push(match list {
|
||||
Value::Null => None,
|
||||
Value::Array(points) if points.len() <= 100_000 => Some(
|
||||
points
|
||||
.iter()
|
||||
.map(params::vec3)
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or_else(|| D::Error::custom(whole("`far` handles must be [x, y, z]")))?,
|
||||
),
|
||||
_ => return Err(D::Error::custom(whole(FAR_MESSAGE))),
|
||||
});
|
||||
}
|
||||
Ok(RuledFar(far))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RuledFar {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RuledFar".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "A free strip's far-curve handles, per chain edge in order: null for the default curve, or the interior control points in mm, as the default curve's handles list them.",
|
||||
"type": "array",
|
||||
"maxItems": 10000,
|
||||
"items": {"anyOf": [{"type": "null"}, {"type": "array", "maxItems": 100000, "items": point}]}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "surface_ruled_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,188 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::SurfaceRuledParams;
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<SurfaceRuledParams>(),
|
||||
&[
|
||||
"angle",
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"corners",
|
||||
"direction",
|
||||
"distance",
|
||||
"edges",
|
||||
"face",
|
||||
"far",
|
||||
"flip",
|
||||
"mode",
|
||||
"name",
|
||||
"participants",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Points on the four top edges of [`boxed`]'s `Box`, a chain round its top.
|
||||
fn rim() -> Value {
|
||||
json!([[5, 0, 10], [10, 5, 10], [5, 10, 10], [0, 5, 10]])
|
||||
}
|
||||
|
||||
/// A point on `Box`'s top face.
|
||||
fn top() -> Value {
|
||||
json!([5, 5, 10])
|
||||
}
|
||||
|
||||
/// A 10 mm cube `Box` from the origin, at `level`.
|
||||
fn boxed(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s
|
||||
}
|
||||
|
||||
/// Calls that fail today give the same message in strict, each on a fresh [`boxed`]: what the
|
||||
/// contract checks (the body, the edges, the keywords, the direction, the face and the far
|
||||
/// handles) and what it leaves to the handler (a body the design lacks, points off its edges or
|
||||
/// faces, a distance or angle that doesn't evaluate or has the wrong unit, a zero distance, and
|
||||
/// the angle or direction a mode needs). Left out: a missing `edges`, and a `distance` that isn't
|
||||
/// a number or text, whose messages differ on purpose, as for every converted command.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let with = |extra: Value| {
|
||||
let mut p = json!({"body": "Box", "edges": rim(), "distance": 2});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
p
|
||||
};
|
||||
let cases = [
|
||||
json!({}),
|
||||
json!({"body": 5, "edges": rim(), "distance": 2}),
|
||||
json!({"body": null, "edges": rim(), "distance": 2}),
|
||||
json!({"body": "Nope", "edges": rim(), "distance": 2}),
|
||||
with(json!({"edges": 5})),
|
||||
with(json!({"edges": []})),
|
||||
with(json!({"edges": null})),
|
||||
with(json!({"edges": ["x"]})),
|
||||
with(json!({"edges": vec![[5, 0, 10]; 10_001]})),
|
||||
with(json!({"edges": [[500, 500, 500]]})),
|
||||
json!({"body": "Box", "edges": rim()}),
|
||||
with(json!({"distance": "nonsense"})),
|
||||
with(json!({"distance": "30 deg"})),
|
||||
with(json!({"distance": 0})),
|
||||
with(json!({"mode": "sideways"})),
|
||||
with(json!({"mode": "SIDEWAYS"})),
|
||||
with(json!({"mode": "angle"})),
|
||||
with(json!({"mode": "angle", "angle": null})),
|
||||
with(json!({"mode": "angle", "angle": "5 mm"})),
|
||||
with(json!({"mode": "angle", "angle": 45, "direction": [1, 2]})),
|
||||
with(json!({"mode": "direction"})),
|
||||
with(json!({"mode": "direction", "direction": [0, 0, 0]})),
|
||||
with(json!({"mode": "direction", "direction": "up"})),
|
||||
with(json!({"corners": "square"})),
|
||||
with(json!({"face": "top"})),
|
||||
with(json!({"far": 5})),
|
||||
with(json!({"far": [5]})),
|
||||
with(json!({"far": [["x"]]})),
|
||||
with(json!({"far": [null, [[1, 2]]]})),
|
||||
];
|
||||
for p in cases {
|
||||
let error = |level: ParamCheck| boxed(level).execute("surface.ruled", &p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #13134: the contract takes what its input schema allows: each call's check finds no issue
|
||||
/// exactly when the call matches the schema. A call in angle mode without `angle` matches it; the
|
||||
/// handler refuses it. The one place the schema can't follow the check is a zero `direction`,
|
||||
/// which it can't say isn't allowed; a last call shows it.
|
||||
#[test]
|
||||
fn the_check_takes_what_the_schema_allows() {
|
||||
let schema = input_schema_for::<SurfaceRuledParams>();
|
||||
let schema = schema.as_value();
|
||||
let p = |extra: Value| {
|
||||
let mut p = json!({"body": "B", "edges": [[1, 2, 3]], "distance": 2});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
p
|
||||
};
|
||||
for (p, takes) in [
|
||||
(p(json!({})), true),
|
||||
(
|
||||
p(
|
||||
json!({"edges": [{"x": 1, "y": 2, "z": 3}], "distance": "2 mm", "mode": "tangent", "face": [1, 2, 3], "flip": true, "corners": "round", "name": "S", "body_name": "Strip"}),
|
||||
),
|
||||
true,
|
||||
),
|
||||
(p(json!({"mode": "angle", "angle": "45 deg", "direction": [0, 0, 1]})), true),
|
||||
(p(json!({"mode": "direction", "direction": {"x": 0, "y": -1, "z": 0}, "corners": "miter"})), true),
|
||||
(p(json!({"far": [null, [[1, 2, 3], [4, 5, 6]], []], "body_names": ["Strip"], "participants": ["*"]})), true),
|
||||
(p(json!({"mode": null, "angle": null, "direction": null, "face": null, "flip": null, "corners": null, "far": null})), true),
|
||||
(p(json!({"mode": "angle"})), true),
|
||||
(json!({"edges": [[1, 2, 3]], "distance": 2}), false),
|
||||
(p(json!({"body": 1})), false),
|
||||
(p(json!({"edges": []})), false),
|
||||
(p(json!({"edges": 5})), false),
|
||||
(p(json!({"edges": [[1, 2]]})), false),
|
||||
(p(json!({"edges": [[1, 2, 3], "x"]})), false),
|
||||
(json!({"body": "B", "edges": [[1, 2, 3]]}), false),
|
||||
(p(json!({"distance": true})), false),
|
||||
(p(json!({"mode": "sideways"})), false),
|
||||
(p(json!({"mode": 3})), false),
|
||||
(p(json!({"angle": true})), false),
|
||||
(p(json!({"direction": [1, 2]})), false),
|
||||
(p(json!({"direction": "up"})), false),
|
||||
(p(json!({"face": "top"})), false),
|
||||
(p(json!({"flip": "yes"})), false),
|
||||
(p(json!({"corners": "square"})), false),
|
||||
(p(json!({"far": 5})), false),
|
||||
(p(json!({"far": [5]})), false),
|
||||
(p(json!({"far": [["x"]]})), false),
|
||||
(p(json!({"plane": "XY"})), false),
|
||||
] {
|
||||
let issues = crate::schema::issues("surface.ruled", &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
let zero = p(json!({"mode": "direction", "direction": [0, 0, 0]}));
|
||||
assert!(!crate::schema::issues("surface.ruled", &zero).is_empty() && conforms(schema, schema, &zero), "{zero}");
|
||||
}
|
||||
|
||||
/// #13134: Ruled Surface replies with `FeatureResult`, with the keys it gave before, in each mode
|
||||
/// and with each optional key: in Warn, without warnings, so its contract takes each call. Each
|
||||
/// call makes one surface body and changes no other.
|
||||
#[test]
|
||||
fn ruled_surface_replies_with_feature_result() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
|
||||
let calls = [
|
||||
json!({"body": "Box", "edges": rim(), "distance": "2 mm", "mode": "tangent", "face": top(), "body_name": "Flange"}),
|
||||
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top()}),
|
||||
json!({"body": "Box", "edges": rim(), "distance": 2, "mode": "ANGLE", "angle": "45 deg", "corners": "round", "face": top(), "name": "Draft"}),
|
||||
json!({"body": "Box", "edges": [[5, 0, 10]], "distance": 2, "mode": "direction", "direction": [0, -1, 0], "body_name": "Lip"}),
|
||||
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top(), "flip": true, "corners": "miter", "far": [null, null, null, null]}),
|
||||
json!({"body": "Box", "edges": rim(), "distance": 2, "face": top(), "mode": null, "angle": null, "direction": null, "body_names": ["Rim"]}),
|
||||
];
|
||||
for p in calls {
|
||||
let mut s = boxed(ParamCheck::Warn);
|
||||
let r = s.execute("surface.ruled", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(keys, before, "{p}: {r}");
|
||||
let count = |key: &str| r[key].as_array().map_or(usize::MAX, Vec::len);
|
||||
assert_eq!((count("created_bodies"), count("changed_bodies"), count("removed_bodies")), (1, 0, 0), "{p}: {r}");
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,22 @@ keyword_param!(
|
||||
"emboss or deboss",
|
||||
"Whether the profiles are raised from the face (emboss, the default) or sunk into it (deboss). Any case."
|
||||
);
|
||||
keyword_param!(
|
||||
/// `surface.ruled`'s `mode`.
|
||||
RuledModeParam,
|
||||
"mode",
|
||||
&["normal", "tangent", "angle", "direction"],
|
||||
"normal, tangent, angle or direction",
|
||||
"Which way the rulings run from each edge: normal (the default: along the normal of the face beside the edge), tangent (continuing that face), angle (`angle` from `direction`, else from the face's normal, turned away from the face) or direction (along `direction`). Matching ignores case."
|
||||
);
|
||||
keyword_param!(
|
||||
/// `surface.ruled`'s `corners`.
|
||||
RuledCornersParam,
|
||||
"corners",
|
||||
&["mitre", "miter", "round"],
|
||||
"mitre or round",
|
||||
"How the strip turns the chain's sharp corners: mitre (the default; miter is an alias) or round. Inside corners are always cut back. Matching ignores case."
|
||||
);
|
||||
keyword_param!(
|
||||
/// `solid.fillet`'s `type`.
|
||||
FilletTypeParam,
|
||||
|
||||
@@ -25,4 +25,3 @@ solid.thread
|
||||
surface.extrude
|
||||
surface.patch
|
||||
surface.revolve
|
||||
surface.ruled
|
||||
|
||||
@@ -1118,6 +1118,7 @@ fn hostile_params_never_panic() {
|
||||
"favorites",
|
||||
"filter",
|
||||
"kind",
|
||||
"detail",
|
||||
"prefix",
|
||||
"orientation",
|
||||
"seam",
|
||||
|
||||
@@ -998,7 +998,7 @@ fn m10_stock(z0: f64, z1: f64, rmax: impl Fn(f64) -> f64) -> f64 {
|
||||
}
|
||||
|
||||
/// ISO 4753's chamfered end on both free ends of an M10 stud: a 45° cone down to the external
|
||||
/// minor diameter d3 (ISO 724), cut after the groove; the volume as the stock's chamfered section
|
||||
/// minor diameter d3 (ISO 724); the volume as the stock's chamfered section
|
||||
/// with the thread's material fraction (as ladder 06/006). d3 lies below the modelled root, so
|
||||
/// each end face is the whole disk of d3, with the cone a whole ring around it, not pinched
|
||||
/// where the groove reaches the end.
|
||||
@@ -1140,48 +1140,112 @@ fn a_runout_fades_the_groove_out_before_the_shoulder() {
|
||||
assert!(e.err().map(|e| e.to_string()).unwrap_or_default().contains("`ease_in` is gone"));
|
||||
}
|
||||
|
||||
/// A modelled M10 thread's flanks are several B-spline faces per turn (spans of one sweep); where
|
||||
/// two of them meet with one tangent plane and one curvature the join is left out of the shaded
|
||||
/// view (still pickable and in the wireframe), so the flank reads as one surface. The normal angle across each join is measured
|
||||
/// independently (`seam_diagnostics`, projecting stations onto both surfaces).
|
||||
#[test]
|
||||
fn a_modelled_threads_flank_joins_are_not_drawn() {
|
||||
fn a_saved_segmented_thread_flank_pick_does_not_select_a_different_face() {
|
||||
use solidcore_record::brep::Surface;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5.0, "height": 12, "body_name": "Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face": [5.0, 0, 6.0], "designation": "M10x1.5", "modeled": true}));
|
||||
let st = s.model.state();
|
||||
let body = &st.body("Shaft").unwrap().body;
|
||||
let mesh = body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap();
|
||||
let record = body.record();
|
||||
let free_form = |f: u32| {
|
||||
record
|
||||
.faces
|
||||
.get(f as usize)
|
||||
.is_some_and(|f| f.nurbs.is_none() && matches!(f.surface, Surface::Spline { .. } | Surface::RationalSpline { .. }))
|
||||
};
|
||||
let tolerances = solidcraft_kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: 0.5, curvature_per_mm: 1.0, ..Default::default() };
|
||||
let report = solidcraft_kernel::seam_diagnostics(body, &[], 33, tolerances).unwrap();
|
||||
let mut flank_joins = 0;
|
||||
let mut worst: f64 = 0.0;
|
||||
for seam in &report.seams {
|
||||
let e = seam.edge;
|
||||
let uses = &record.edges[e].uses;
|
||||
if uses.len() != 2 || !uses.iter().all(|u| free_form(u.face)) {
|
||||
continue;
|
||||
|
||||
// Captured from the segmented build before continuous_flanks existed, including the old
|
||||
// groove-before-chamfer construction. Picks near both chamfered ends exercise its order.
|
||||
let cases = [
|
||||
("none", vec![("F2:face:12", Vec3::new(4.7828951862188696, -0.0057530612776590315, 1.6606274291037717))]),
|
||||
(
|
||||
"both",
|
||||
vec![
|
||||
("F2:face:41", Vec3::new(0.09242260275006192, 4.273234209721337, 0.23586874398281996)),
|
||||
("F2:face:7", Vec3::new(0.9625365002593353, -4.6849682261915895, 6.262443362555148)),
|
||||
("F2:face:51", Vec3::new(-0.0924226027501412, 4.273234209721336, 11.764131256017183)),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (tip, picks) in cases {
|
||||
let mut thread = json!({
|
||||
"designation": "M10x1.5", "face": [5, 0, 6], "face_names": ["F1:side"],
|
||||
"id": 2, "modeled": true, "name": "Thread1", "suppressed": false, "type": "thread"
|
||||
});
|
||||
if tip != "none" {
|
||||
thread["tip"] = json!(tip);
|
||||
}
|
||||
let angle = seam.normal_angle_deg.value;
|
||||
if angle < 0.5 {
|
||||
flank_joins += 1;
|
||||
worst = worst.max(angle);
|
||||
assert!(mesh.smooth_edges[e] && !mesh.shaded_edge_visible(e), "join {e} at {angle} deg is drawn");
|
||||
assert!(mesh.edge_visible(e) && mesh.edge_pickable(e), "join {e} stays in the wireframe and pickable");
|
||||
} else {
|
||||
assert!(mesh.shaded_edge_visible(e), "crease {e} at {angle} deg is hidden");
|
||||
let mut saved: Value = serde_json::from_str(&solidcraft_doc::Document::default().to_json()).unwrap();
|
||||
saved["features"] = json!([
|
||||
{
|
||||
"axis": [0, 0, 1], "base": [0, 0, 0], "body_names": ["Shaft"], "height": "12",
|
||||
"id": 1, "name": "Cylinder1", "operation": "new_body", "param_names": ["d1", "d2"],
|
||||
"radius": "5", "suppressed": false, "type": "cylinder"
|
||||
}, thread
|
||||
]);
|
||||
saved["next_id"] = json!(3);
|
||||
saved["params"] = json!([]);
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved.to_string()).unwrap());
|
||||
let check_picks = |session: &Session| {
|
||||
let rebuilt = session.model.state();
|
||||
let shaft = rebuilt.body("Shaft").unwrap();
|
||||
for (name, pick) in &picks {
|
||||
let (face, point) = solidcraft_doc::naming::face_on_body(shaft, name, *pick).unwrap();
|
||||
assert!(point.dist(*pick) < 0.01, "{tip}: {name} must stay on its original flank: {point:?}");
|
||||
assert!(
|
||||
matches!(shaft.body.record().faces[face].surface, Surface::Spline { .. } | Surface::RationalSpline { .. }),
|
||||
"{tip}: {name} must not select a cylinder"
|
||||
);
|
||||
}
|
||||
};
|
||||
check_picks(&reopened);
|
||||
run(
|
||||
&mut reopened,
|
||||
"timeline.redefine",
|
||||
json!({
|
||||
"feature": 2, "command": "solid.thread",
|
||||
"params": {"face": [5, 0, 6], "designation": "M10x1.5", "modeled": true, "tip": tip}
|
||||
}),
|
||||
);
|
||||
assert!(matches!(reopened.doc.feature(2).unwrap().kind, solidcraft_doc::FeatureKind::Thread { continuous_flanks: false, .. }));
|
||||
check_picks(&reopened);
|
||||
let saved_again = solidcraft_doc::Document::from_json(&reopened.doc.to_json()).unwrap();
|
||||
assert!(matches!(saved_again.feature(2).unwrap().kind, solidcraft_doc::FeatureKind::Thread { continuous_flanks: false, .. }));
|
||||
}
|
||||
}
|
||||
|
||||
/// A modelled M10 thread keeps each flank as one B-spline face across all turns. Cylinder seams
|
||||
/// must not split either flank into separate faces for selection, meshing or STEP export.
|
||||
#[test]
|
||||
fn a_modelled_thread_keeps_each_flank_as_one_face() {
|
||||
use solidcore_record::brep::Surface;
|
||||
// Include a partial final turn and a chamfered stock: neither may fragment the flanks.
|
||||
for (height, tip) in [(12.0, "none"), (12.345, "none"), (12.345, "both")] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5.0, "height": height, "body_name": "Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face": [5.0, 0, height / 2.0], "designation": "M10x1.5", "modeled": true, "tip": tip}));
|
||||
let st = s.model.state();
|
||||
let shaft = st.body("Shaft").unwrap();
|
||||
let body = &shaft.body;
|
||||
let mesh = body.mesh(&solidcraft_kernel::MeshTolerance::chord(0.01)).unwrap();
|
||||
let record = body.record();
|
||||
let flanks: Vec<usize> = record
|
||||
.faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| f.nurbs.is_none() && matches!(f.surface, Surface::Spline { .. } | Surface::RationalSpline { .. }))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert_eq!(flanks.len(), 2, "one selectable face per flank, independent of the turn count");
|
||||
let names = solidcraft_doc::naming::face_names(shaft);
|
||||
let picks: Vec<_> = flanks
|
||||
.iter()
|
||||
.map(|&flank| {
|
||||
assert!(mesh.tri_face.iter().any(|f| *f as usize == flank), "flank {flank} must tessellate");
|
||||
(names[flank].clone(), solidcraft_doc::naming::face_spot(shaft, flank).unwrap().point)
|
||||
})
|
||||
.collect();
|
||||
// The named picks downstream face commands store must still find the same kind of face
|
||||
// after the history is serialized and rebuilt, including the reordered chamfer cuts.
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let rebuilt = reopened.model.state();
|
||||
let shaft = rebuilt.body("Shaft").unwrap();
|
||||
for (name, point) in picks {
|
||||
let (face, found) = solidcraft_doc::naming::face_on_body(shaft, &name, point).unwrap();
|
||||
assert!(found.dist(point) < 1e-6, "the saved flank pick must remain on its named face: {name}");
|
||||
assert!(matches!(shaft.body.record().faces[face].surface, Surface::Spline { .. } | Surface::RationalSpline { .. }));
|
||||
}
|
||||
}
|
||||
// SMLib's flank pieces meet with normals about 1.5e-6 degrees apart (measured).
|
||||
assert!(flank_joins >= 32 && worst < 1e-4, "{flank_joins} flank joins, the worst {worst} deg apart");
|
||||
}
|
||||
|
||||
/// A pipe thread's profile for [`pipe_material`]: (flank half angle, thread height h, flat at crest
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ use std::process::Command;
|
||||
|
||||
/// The workspace crates a design's model is computed from: `doc` and every workspace crate it
|
||||
/// depends on (a test checks the list against their manifests).
|
||||
const EVALUATION_CRATES: [&str; 11] = ["color", "geom", "profile", "smlib", "step", "sketch", "kernel", "render", "derive", "doc", "viewport"];
|
||||
const EVALUATION_CRATES: [&str; 10] = ["color", "geom", "profile", "smlib", "sketch", "kernel", "render", "derive", "doc", "viewport"];
|
||||
/// The other files the saved model depends on, relative to the workspace root.
|
||||
const EVALUATION_FILES: [&str; 3] = ["crates/io/build.rs", "crates/io/src/design/native_cache.rs", ".cargo/config.toml"];
|
||||
/// The committed trees whose every file computes models, hashed as one tree id each: the vendored
|
||||
|
||||
@@ -15,7 +15,7 @@ solidcore-tess = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-smlib = { workspace = true }
|
||||
solidcraft-profile = { workspace = true }
|
||||
solidcraft-step = { workspace = true }
|
||||
solidcore-step = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -721,7 +721,7 @@ impl Body {
|
||||
|
||||
/// The record as solidcore-ops' body and the order of its entities, made on first use and
|
||||
/// kept for every later face; a conversion that fails is not kept. None for a mesh body.
|
||||
fn ops_body(&self) -> Option<&(solidcore_ops::Body, solidcore_ops::Order)> {
|
||||
pub(crate) fn ops_body(&self) -> Option<&(solidcore_ops::Body, solidcore_ops::Order)> {
|
||||
if self.is_mesh() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
|
||||
/// [`sweep_helix`] with the profile moved off the axis as it turns: at turn t it first moves
|
||||
/// `offset(t)` mm away from the axis (negative: towards it) in its half-plane, `offset` piecewise
|
||||
/// linear through `law`'s (turn, offset) pairs, turns ascending within [0, `turns`]. A thread's
|
||||
/// groove fading in or out over a runout: SMLib's radial screw sweep.
|
||||
/// groove fading in or out over a runout: SMLib's radial screw sweep. `continuous_flanks`
|
||||
/// joins the spline walls across cylinder seams; false retains the historical faces.
|
||||
pub fn sweep_helix_radial(
|
||||
plane: &Plane,
|
||||
region: &Region2,
|
||||
@@ -34,18 +35,16 @@ pub fn sweep_helix_radial(
|
||||
pitch: f64,
|
||||
turns: f64,
|
||||
law: &[(f64, f64)],
|
||||
continuous_flanks: bool,
|
||||
) -> Result<Body> {
|
||||
let _span = solidcraft_profile::span("kernel", "sweep_helix_radial");
|
||||
if law.is_empty() {
|
||||
return sweep_helix(plane, region, axis_origin, axis, pitch, turns);
|
||||
}
|
||||
let (k, profile) = helix_profile(plane, region, axis_origin, axis, pitch, turns)?;
|
||||
if !law.windows(2).all(|w| matches!(w, [a, b] if b.0 > a.0))
|
||||
|| law.iter().any(|(t, o)| !(t.is_finite() && o.is_finite() && *t >= 0.0 && *t <= turns))
|
||||
{
|
||||
return Err(KernelError::Invalid("helix: the radial law's turns must ascend within the sweep's turns".into()));
|
||||
}
|
||||
crate::smlib_sweeps::screw_radial(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns, law)
|
||||
crate::smlib_sweeps::screw_radial(plane, &crate::smlib_sweeps::pieces([&profile]), axis_origin, k, pitch, turns, law, continuous_flanks)
|
||||
}
|
||||
|
||||
/// A helical sweep's axis and profile (the region's outer loop), checked: finite turns and
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
//! with their faces, loops, edge and vertex lists, on analytic surfaces (planes, cylinders,
|
||||
//! cones, spheres, tori), surfaces of revolution and rational B-splines, with body names (406
|
||||
//! name properties) and colours (314). Geometry is the record's, exact, in the same STEP form
|
||||
//! the STEP export writes ([`solidcraft_step::write::topology`]); IGES loops run
|
||||
//! the STEP export writes ([`solidcore_step::write::topology`]); IGES loops run
|
||||
//! counter-clockwise about their surface's normal.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use solidcore_record::brep::{Brep, Loop, Shell};
|
||||
use solidcraft_step::geom::{Curve, Placement, Surface};
|
||||
use solidcraft_step::write::topology;
|
||||
use solidcore_step::geom::{Curve, Placement, Surface};
|
||||
use solidcore_step::write::topology;
|
||||
|
||||
use crate::{ExportBody, KernelError, Result, StepHeader};
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ pub enum BoolOp {
|
||||
/// SMLib's own tags, or none where a body's order cannot be matched to SMLib's. A cut by a tool
|
||||
/// apart from the body is the body itself (its faces and edges kept), and their intersection empty.
|
||||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// Stopped by its watch (an agent's request cancelled while it computes, #1989): no more
|
||||
// booleans. One SMLib already runs to its end.
|
||||
if crate::watch::cancelled() {
|
||||
return Err(crate::watch::cancelled_error("boolean"));
|
||||
}
|
||||
crate::profile::timed(crate::profile::Kind::Boolean, "boolean", || {
|
||||
// A tool apart from the body (their exact boxes, grown by the tolerances, do not meet)
|
||||
// leaves a cut the body itself and an intersection nothing, with no intersection work.
|
||||
@@ -47,6 +52,9 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
/// what the operands' faces stood for (their regularization protection and the planar roles on
|
||||
/// supports it kept).
|
||||
pub fn union_all(bodies: &[&Body]) -> Result<Body> {
|
||||
if crate::watch::cancelled() {
|
||||
return Err(crate::watch::cancelled_error("union"));
|
||||
}
|
||||
crate::profile::timed(crate::profile::Kind::Boolean, "union_all", || {
|
||||
if bodies.is_empty() {
|
||||
return Err(KernelError::Invalid("a union of no bodies".into()));
|
||||
|
||||
@@ -97,7 +97,6 @@ mod imp {
|
||||
|
||||
use super::*;
|
||||
use crate::history::FaceProv;
|
||||
use crate::provenance::{closest_on_tri, face_tris};
|
||||
use crate::record;
|
||||
|
||||
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
|
||||
@@ -342,88 +341,144 @@ mod imp {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The role of each face of `body` swept from the profile in `start` (see the module notes):
|
||||
/// `edges` lie on the walls' edges in the start plane, `inside` inside the walls; the face in
|
||||
/// `end` (or, with `end` unset and `rest_is_end`, a planar face nothing else claims) is the
|
||||
/// end cap.
|
||||
/// The role of each face of `body` swept from the profile in `start` (see the module notes),
|
||||
/// from the body's exact geometry: a face on the start plane is the start cap where a point
|
||||
/// of `edges` lies on it, one on `end` the end cap (with `end` unset and `rest_is_end`, a
|
||||
/// planar face nothing else claims); a wall comes from each piece whose point in `edges` lies
|
||||
/// on its edge in the start plane, or whose point in `inside` lies on it. Each point names
|
||||
/// the nearest edges or faces: one where SMLib split a curve or a wall lies on both.
|
||||
fn roles(body: &Body, start: &Plane, end: Option<&Plane>, edges: &[Seed], inside: &[Seed], rest_is_end: bool) -> Result<Vec<Role>> {
|
||||
use solidcore_record::brep::{Curve as RecordCurve, Surface};
|
||||
use solidcraft_geom::brep::eval::CurveEval;
|
||||
let r = body.record();
|
||||
let size = body.size().max(1e-9);
|
||||
let mesh_tol = (size * 2e-4).max(1e-6);
|
||||
let m = body.mesh(&crate::MeshTolerance::chord(mesh_tol))?;
|
||||
let faces = face_tris(&m);
|
||||
let n = body.face_count();
|
||||
// A cap's points lie on its plane to the evaluation's precision. Sized to the body, the
|
||||
// flatness tolerance can exceed the gap between parallel start and end planes (a box
|
||||
// 0.1 µm thick in a 14 mm body), which would put each cap in both: it stays under a
|
||||
// quarter of that gap. Planes that coincide (the ends of a half turn) are told apart by
|
||||
// the profile's edges instead.
|
||||
// A cap lies on its plane to the evaluation's precision. Sized to the body, that can exceed
|
||||
// the gap between parallel start and end planes (a box 0.1 µm thick in a 14 mm body), which
|
||||
// would put each cap on both: it stays under a quarter of that gap.
|
||||
let gap = end
|
||||
.filter(|e| e.normal().cross(start.normal()).len() < 1e-9)
|
||||
.map(|e| start.height(e.origin).abs())
|
||||
.filter(|g| *g > size * 1e-9)
|
||||
.filter(|g| end.is_some_and(|e| e.normal().cross(start.normal()).len() < 1e-9) && *g > size * 1e-9)
|
||||
.unwrap_or(f64::INFINITY);
|
||||
let flat_tol = (size * 1e-5 + 1e-9).min(0.25 * gap);
|
||||
let near = 3.0 * mesh_tol + flat_tol;
|
||||
let flat = |f: usize, pl: &Plane| {
|
||||
faces.get(f).is_some_and(|ft| !ft.tris.is_empty() && ft.tris.iter().all(|(t, _)| t.iter().all(|q| pl.height(*q).abs() <= flat_tol)))
|
||||
let tol = (size * 1e-5 + 1e-9).min(0.25 * gap);
|
||||
let vertex = |v: u32| r.vertices.get(v as usize).map(|v| crate::body::v3(v.point));
|
||||
// A planar face's point and unit normal.
|
||||
let flat = |f: &solidcore_record::brep::Face| {
|
||||
let (o, u, v) = match &f.surface {
|
||||
Surface::Plane { origin, u_axis, v_axis } => (crate::body::v3(*origin), crate::body::v3(*u_axis), crate::body::v3(*v_axis)),
|
||||
Surface::PlaneThrough { origin, u_point, v_point } => {
|
||||
let o = crate::body::v3(*origin);
|
||||
(o, crate::body::v3(*u_point) - o, crate::body::v3(*v_point) - o)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
u.cross(v).normalized().map(|n| (o, n))
|
||||
};
|
||||
let planar =
|
||||
|f: usize| faces.get(f).is_some_and(|ft| ft.tris.first().is_some_and(|(_, n0)| ft.tris.iter().all(|(_, nn)| nn.dot(*n0) > 1.0 - 1e-6)));
|
||||
// A face in the start plane is the start cap where the profile bounds it (a screw's far
|
||||
// end after whole turns lies in the same plane, elsewhere).
|
||||
let touches = |f: usize| {
|
||||
edges.is_empty()
|
||||
|| faces.get(f).is_some_and(|ft| edges.iter().any(|s| ft.tris.iter().any(|(t, _)| closest_on_tri(s.at, *t).dist(s.at) <= near)))
|
||||
let on = |f: &solidcore_record::brep::Face, pl: &Plane| {
|
||||
flat(f).is_some_and(|(o, n)| n.dot(pl.normal()).abs() >= 1.0 - 1e-9 && pl.height(o).abs() <= tol)
|
||||
};
|
||||
let start_cap: Vec<bool> = (0..n).map(|f| flat(f, start) && touches(f)).collect();
|
||||
let end_cap: Vec<bool> = (0..n).map(|f| end.is_some_and(|e| flat(f, e))).collect();
|
||||
let cap = |f: usize| start_cap.get(f).copied().unwrap_or(false) || end_cap.get(f).copied().unwrap_or(false);
|
||||
let mut swept: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
|
||||
// Each seed lies on its own piece's edge (within the mesh's sag on a curve); `near` is
|
||||
// sized to the body, so on a side shorter than it (a 0.3 mm wall of a 1 m label) the
|
||||
// neighbours' edges are near too. The seed marks only the edges nearest it: the one it
|
||||
// lies on, or both pieces of an edge split at its middle.
|
||||
let start_edges: Vec<(&Vec<Vec3>, &Vec<u32>)> = m
|
||||
.edges
|
||||
.iter()
|
||||
.zip(&m.edge_faces)
|
||||
.filter(|(poly, _)| poly.len() >= 2 && poly.iter().all(|q| start.height(*q).abs() <= flat_tol))
|
||||
.collect();
|
||||
// The edges in the start plane: their ends and middle on it.
|
||||
let mut base = Vec::new();
|
||||
for (i, e) in r.edges.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (vertex(e.vertices[0]), vertex(e.vertices[1])) else { continue };
|
||||
// An edge SMLib made within its own tolerance of the curve it stands for (a screw's
|
||||
// start) is taken to that tolerance.
|
||||
let reach = tol.max(e.tolerance);
|
||||
if start.height(a).abs() > reach || start.height(b).abs() > reach {
|
||||
continue;
|
||||
}
|
||||
let curve = CurveEval::new(e.parameters()).map_err(|err| KernelError::Failed(format!("extrude history: edge {i}: {err}")))?;
|
||||
let [t0, t1] = e.interval;
|
||||
let mid = curve.point(0.5 * (t0 + t1)).ok_or_else(|| KernelError::Failed(format!("extrude history: edge {i} has no middle")))?;
|
||||
if start.height(mid).abs() > reach {
|
||||
continue;
|
||||
}
|
||||
// The curve's control points bound it.
|
||||
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
|
||||
match e.parameters() {
|
||||
RecordCurve::Line { .. } => [a, b].into_iter().for_each(|q| bounds.add(q)),
|
||||
RecordCurve::Spline { points, .. } => points.iter().for_each(|q| bounds.add(crate::body::v3(*q))),
|
||||
RecordCurve::RationalSpline { homogeneous, .. } if homogeneous.iter().all(|h| h[3] > 0.0) => {
|
||||
homogeneous.iter().for_each(|h| bounds.add(Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])))
|
||||
}
|
||||
_ => {
|
||||
bounds = solidcraft_geom::Aabb3 { min: Vec3::new(-f64::MAX, -f64::MAX, -f64::MAX), max: Vec3::new(f64::MAX, f64::MAX, f64::MAX) }
|
||||
}
|
||||
}
|
||||
// A line by its ends; a curve searched from eight samples a knot span.
|
||||
let samples = match e.parameters() {
|
||||
RecordCurve::Line { .. } => None,
|
||||
RecordCurve::Spline { knots, .. } | RecordCurve::RationalSpline { knots, .. } => {
|
||||
Some(8 * knots.windows(2).filter(|w| w[1] > w[0]).count().max(8))
|
||||
}
|
||||
_ => Some(64),
|
||||
};
|
||||
base.push((i, curve, e.interval, [a, b], samples, reach, bounds.inflated(reach)));
|
||||
}
|
||||
// Each seed's nearest edges, and the faces on them.
|
||||
let mut matched: Vec<(usize, usize)> = Vec::new();
|
||||
for s in edges {
|
||||
let dist = |poly: &[Vec3]| poly.windows(2).map(|w| s.at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let ds: Vec<f64> = start_edges.iter().map(|(poly, _)| dist(poly)).collect();
|
||||
let nearest = ds.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
for ((_, fs), d) in start_edges.iter().zip(&ds) {
|
||||
if *d <= near && *d <= nearest + flat_tol {
|
||||
for f in fs.iter().map(|f| *f as usize).filter(|f| !cap(*f)) {
|
||||
if let Some(set) = swept.get_mut(f) {
|
||||
set.insert(s.piece);
|
||||
}
|
||||
}
|
||||
let ds: Vec<(usize, f64, f64)> = base
|
||||
.iter()
|
||||
.filter(|(.., bounds)| bounds.contains(s.at))
|
||||
.map(|(i, curve, interval, [a, b], samples, reach, _)| {
|
||||
let Some(samples) = samples else { return (*i, s.at.dist_to_segment(*a, *b), *reach) };
|
||||
let along = crate::surfaces::nearest_parameter_in(curve, *interval, s.at, *samples).map_or(f64::INFINITY, |(_, d)| d);
|
||||
(*i, along.min(s.at.dist(*a)).min(s.at.dist(*b)), *reach)
|
||||
})
|
||||
.collect();
|
||||
let nearest = ds.iter().map(|(_, d, _)| *d).fold(f64::INFINITY, f64::min);
|
||||
matched.extend(ds.iter().filter(|(_, d, reach)| *d <= *reach && *d <= nearest + tol).map(|(i, ..)| (*i, s.piece)));
|
||||
}
|
||||
let faces_on = |e: usize| r.edges.get(e).into_iter().flat_map(|e| e.uses.iter().map(|u| u.face as usize));
|
||||
let touched: BTreeSet<usize> = matched.iter().flat_map(|(e, _)| faces_on(*e)).collect();
|
||||
let n = r.faces.len();
|
||||
// How far a point may lie off a face SMLib made to its own tolerance.
|
||||
let reach = r.edges.iter().map(|e| e.tolerance).fold(tol, f64::max);
|
||||
let ops = || body.ops_body().ok_or_else(|| KernelError::Failed("sweep history: the body is no SMLib body".into()));
|
||||
let points = |seeds: &[Seed]| seeds.iter().map(|s| p(s.at)).collect::<Vec<_>>();
|
||||
// A face in the start plane is the start cap where a profile point lies on it: on its edges
|
||||
// (an extrusion's), or inside it (a screw's start section, SMLib's approximation of the profile).
|
||||
let mut start_cap = Vec::with_capacity(r.faces.len());
|
||||
for (f, face) in r.faces.iter().enumerate() {
|
||||
let on_it = on(face, start)
|
||||
&& (edges.is_empty() || touched.contains(&f) || {
|
||||
let (ops, order) = ops()?;
|
||||
let ds =
|
||||
ops.face_distances(order, f, &points(edges)).map_err(|e| KernelError::Failed(format!("sweep history: face {f}: {e}")))?;
|
||||
ds.iter().any(|d| d.is_some_and(|d| d <= reach))
|
||||
});
|
||||
start_cap.push(on_it);
|
||||
}
|
||||
let end_cap: Vec<bool> = r.faces.iter().map(|face| end.is_some_and(|e| on(face, e))).collect();
|
||||
let mut swept: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
|
||||
for (e, piece) in &matched {
|
||||
for f in faces_on(*e) {
|
||||
if !start_cap.get(f).copied().unwrap_or(false)
|
||||
&& !end_cap.get(f).copied().unwrap_or(false)
|
||||
&& let Some(set) = swept.get_mut(f)
|
||||
{
|
||||
set.insert(*piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inside the walls: the face nearest each point (bounded work).
|
||||
let tris: usize = faces.iter().map(|f| f.tris.len()).sum();
|
||||
if tris.saturating_mul(inside.len()) <= 50_000_000 {
|
||||
for s in inside {
|
||||
let mut best = (f64::INFINITY, usize::MAX);
|
||||
for (fi, ft) in faces.iter().enumerate() {
|
||||
if !ft.bounds.inflated(best.0.min(size)).contains(s.at) {
|
||||
continue;
|
||||
// Inside the walls: the faces nearest each point, as trimmed (a point on the edge between
|
||||
// two faces of one wall lies on both).
|
||||
if !inside.is_empty() {
|
||||
let (ops, order) = ops()?;
|
||||
let points = points(inside);
|
||||
let ds = (0..n)
|
||||
.map(|f| ops.face_distances(order, f, &points).map_err(|e| KernelError::Failed(format!("sweep history: face {f}: {e}"))))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
for (j, s) in inside.iter().enumerate() {
|
||||
let d = |f: usize| ds.get(f).and_then(|d| d.get(j).copied().flatten()).unwrap_or(f64::INFINITY);
|
||||
let nearest = (0..n).map(d).fold(f64::INFINITY, f64::min);
|
||||
for f in (0..n).filter(|f| d(*f) <= reach && d(*f) <= nearest + tol) {
|
||||
if !start_cap.get(f).copied().unwrap_or(false)
|
||||
&& !end_cap.get(f).copied().unwrap_or(false)
|
||||
&& let Some(set) = swept.get_mut(f)
|
||||
{
|
||||
set.insert(s.piece);
|
||||
}
|
||||
for (t, _) in &ft.tris {
|
||||
let d = closest_on_tri(s.at, *t).dist(s.at);
|
||||
if d < best.0 {
|
||||
best = (d, fi);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.0 <= 5.0 * near
|
||||
&& !cap(best.1)
|
||||
&& let Some(set) = swept.get_mut(best.1)
|
||||
{
|
||||
set.insert(s.piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,7 +492,7 @@ mod imp {
|
||||
Role::End
|
||||
} else if !set.is_empty() {
|
||||
Role::Wall(set)
|
||||
} else if end.is_none() && rest_is_end && planar(f) {
|
||||
} else if end.is_none() && rest_is_end && r.faces.get(f).is_some_and(|face| flat(face).is_some()) {
|
||||
Role::End
|
||||
} else {
|
||||
log::debug!(target: "smlib", "sweep history: face {f} comes from no profile segment");
|
||||
@@ -519,9 +574,7 @@ mod imp {
|
||||
let rule = draft.map_or(DraftCorner::default(), |d| corner(d.corner));
|
||||
let brep = Brep::extrude(&c, p(dir), distance, lean, rule, true).map_err(|e| failed("extrude", e))?;
|
||||
let body = body_of(brep, "extrude")?;
|
||||
// Half-way along a straight wall lies over its segment; a drafted one has moved.
|
||||
let inside = if draft.is_none() { seeds(&base, pieces, |q| q + dir * (distance * 0.5)) } else { Vec::new() };
|
||||
let roles = roles(&body, &base, Some(&top), &edges, &inside, false)?;
|
||||
let roles = roles(&body, &base, Some(&top), &edges, &[], false)?;
|
||||
let sources = (draft.is_none() && sign > 0.0).then(|| extrude_sources(&roles, pieces, region, plane, &base, &top));
|
||||
let body = body.with_face_sources(sources.map(std::sync::Arc::new));
|
||||
with_history(body, &roles, pieces)
|
||||
@@ -1837,11 +1890,14 @@ mod imp {
|
||||
pitch: f64,
|
||||
turns: f64,
|
||||
law: &[(f64, f64)],
|
||||
continuous_flanks: bool,
|
||||
) -> Result<Body> {
|
||||
let c = curves(plane, pieces)?;
|
||||
let tolerance = (pitch.abs() * 1e-4).clamp(1e-6, 1e-2);
|
||||
let options =
|
||||
solidcraft_smlib::sweeps::ScrewSweepOptions { law: (!law.is_empty()).then_some(law), join_spline_walls: continuous_flanks, tolerance };
|
||||
let brep =
|
||||
Brep::screw_sweep_radial(&c, p(origin), p(axis), pitch, turns, law, tolerance).map_err(|e| failed("screw sweep with a radial law", e))?;
|
||||
Brep::screw_sweep_with_options(&c, p(origin), p(axis), pitch, turns, options).map_err(|e| failed("screw sweep with a radial law", e))?;
|
||||
let body = body_of(brep, "screw sweep")?;
|
||||
let roles = roles(&body, plane, None, &seeds(plane, pieces, |q| q), &[], true)?;
|
||||
with_history(body, &roles, pieces)
|
||||
|
||||
@@ -76,6 +76,33 @@ fn an_extrusion_names_its_caps_and_walls_and_keeps_their_sources() {
|
||||
assert!(origins(&b).iter().filter(|o| o.contains("Profile")).count() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extrusion_of_one_curve_drawn_in_many_pieces_names_every_piece_on_its_wall() {
|
||||
// An ellipse in 256 cubic pieces, tangent through each joint, extruded as the one B-spline
|
||||
// they are: its one wall comes from every piece, each found on the wall's edge in the sketch
|
||||
// plane by its own middle (a search of that edge in too few places misses the last pieces).
|
||||
let (n, a, b) = (256usize, 20.0, 7.0);
|
||||
let h = TAU / n as f64;
|
||||
let at = |i: usize| {
|
||||
let t = (i % n) as f64 * h;
|
||||
(Vec2::new(a * t.cos(), b * t.sin()), Vec2::new(-a * t.sin(), b * t.cos()) * (h / 3.0))
|
||||
};
|
||||
let segs = (0..n)
|
||||
.map(|i| {
|
||||
let ((p0, d0), (p3, d3)) = (at(i), at(i + 1));
|
||||
Seg2::Cubic { p0, p1: p0 + d0, p2: p3 - d3, p3 }
|
||||
})
|
||||
.collect();
|
||||
let ellipse = region(Loop2 { segs });
|
||||
let mut pieces = pieces_of(&ellipse);
|
||||
pieces.iter_mut().skip(1).for_each(|x| x.joins_previous = true);
|
||||
let body = smlib_sweeps::extrude(&Plane::XY, &pieces, Some(&ellipse), 0.0, 3.0, 1.0, None).unwrap();
|
||||
let every = format!("{:?}", (0..n).map(|segment| FaceOrigin::Profile { segment }).collect::<Vec<_>>());
|
||||
let mut want = vec![cap(false), cap(true), every];
|
||||
want.sort();
|
||||
assert_eq!(origins(&body), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_revolution_names_its_walls_by_segment_and_skips_the_axis() {
|
||||
// A half disc on the y axis of the xy plane: a sphere, every wall from the arc.
|
||||
@@ -253,6 +280,26 @@ fn a_drafted_extrusion_stands_on_a_straight_foot() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drafted_extrusion_with_a_hole_names_each_side_on_its_foot_and_its_slope() {
|
||||
// A 20 x 20 square round a 6 x 6 hole, 2 straight, then sloping 3 degrees (taken from the
|
||||
// base) up to 10: the outer side grows to 20 + 2 x 10 tan 3, the hole's narrows to
|
||||
// 6 - 2 x 10 tan 3, and each of the eight sides gives a foot and a sloping wall.
|
||||
let outer = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 20.0), Vec2::new(0.0, 20.0)]);
|
||||
let hole = Loop2::polygon(&[Vec2::new(7.0, 7.0), Vec2::new(7.0, 13.0), Vec2::new(13.0, 13.0), Vec2::new(13.0, 7.0)]);
|
||||
let r = Region2 { outer, holes: vec![hole] };
|
||||
let taper = 3f64.to_radians();
|
||||
let b = crate::extrude_tapered_between(&Plane::XY, &r, 0.0, 10.0, 2.0, 1.0, taper, crate::DraftCorner::default()).unwrap();
|
||||
let frustum = |s1: f64, s2: f64| 8.0 / 3.0 * (s1 * s1 + s2 * s2 + s1 * s2);
|
||||
let grow = 2.0 * 10.0 * taper.tan();
|
||||
close(volume(&b), (400.0 - 36.0) * 2.0 + frustum(20.0, 20.0 + grow) - frustum(6.0, 6.0 - grow), 1e-9, "drafted ring");
|
||||
let o = origins(&b);
|
||||
assert!(o.contains(&cap(false)) && o.contains(&cap(true)), "{o:?}");
|
||||
for k in 0..8 {
|
||||
assert_eq!(o.iter().filter(|x| **x == profile(k)).count(), 2, "segment {k}: {o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_twisted_sweep_round_a_closed_path_joins_its_ends() {
|
||||
// An 8 x 8 square round a circle of radius 30, a quarter turn of twist (onto itself): Pappus.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! STEP import (ISO 10303-21 files, AP203/AP214/AP242 geometry).
|
||||
//!
|
||||
//! Our own reader, written from the public standards: [`p21`] parses the exchange structure,
|
||||
//! [`solidcraft_step::topology`] reads each solid into its schema 4 record, which SMLib takes
|
||||
//! [`solidcore_step::topology`] reads each solid into its schema 4 record, which SMLib takes
|
||||
//! as the body ([`parallel`]), and this module walks the product structure (products, shape
|
||||
//! representations, assemblies with their placements, units, colours). STEP files are
|
||||
//! untrusted: every count and nesting depth is capped, and what cannot be read becomes a
|
||||
@@ -14,12 +14,21 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_step::read::R;
|
||||
use solidcore_step::read::R;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result};
|
||||
use p21::{Entity, Exchange, Param};
|
||||
|
||||
/// The representation the file's unlisted topology is read as, under an id the reader keeps for
|
||||
/// it (it is used only when the file's representations hold nothing to read).
|
||||
const UNLISTED: u64 = u64::MAX;
|
||||
/// Whether an item of representation `rep` is read as a body: a solid or surface model, and in
|
||||
/// the file's unlisted topology ([`UNLISTED`]) a shell or face on its own too.
|
||||
fn body_item(rep: u64, name: &str) -> bool {
|
||||
solidcore_step::topology::is_solid_item(name) || (rep == UNLISTED && solidcore_step::topology::is_body_item(name))
|
||||
}
|
||||
|
||||
/// Deepest assembly nesting followed.
|
||||
const MAX_ASSEMBLY_DEPTH: usize = 32;
|
||||
/// Most body instances in one import.
|
||||
@@ -175,13 +184,13 @@ struct Batch {
|
||||
|
||||
/// The units of a representation context (GLOBAL_UNIT_ASSIGNED_CONTEXT) and its distance
|
||||
/// uncertainty (mm) as the file states it.
|
||||
type Units = solidcraft_step::units::Context;
|
||||
type Units = solidcore_step::units::Context;
|
||||
|
||||
/// Units of a representation context, read by [`solidcraft_step::units::context`] (millimetres
|
||||
/// Units of a representation context, read by [`solidcore_step::units::context`] (millimetres
|
||||
/// and radians when the file names none).
|
||||
fn context_units(ex: &Exchange, ctx: Option<u64>, cache: &mut HashMap<u64, Units>) -> Units {
|
||||
let Some(id) = ctx else { return Units::default() };
|
||||
*cache.entry(id).or_insert_with(|| solidcraft_step::units::context(ex, id))
|
||||
*cache.entry(id).or_insert_with(|| solidcore_step::units::context(ex, id))
|
||||
}
|
||||
|
||||
/// Items and context of a representation entity.
|
||||
@@ -251,7 +260,7 @@ fn translation(t: [f64; 3]) -> M4 {
|
||||
}
|
||||
|
||||
/// The matrix of an AXIS2_PLACEMENT: its frame's axes and origin.
|
||||
fn frame_matrix(p: &solidcraft_step::geom::Placement) -> M4 {
|
||||
fn frame_matrix(p: &solidcore_step::geom::Placement) -> M4 {
|
||||
let (x, y, z, o) = (p.ref_direction, p.y(), p.axis, p.origin);
|
||||
[[x[0], x[1], x[2], 0.0], [y[0], y[1], y[2], 0.0], [z[0], z[1], z[2], 0.0], [o[0], o[1], o[2], 1.0]]
|
||||
}
|
||||
@@ -619,7 +628,7 @@ impl Reader {
|
||||
/// omitted axis is the global one, an omitted scale 1, an omitted second or third scale the
|
||||
/// first).
|
||||
fn placement_matrix(&mut self, item: u64, u: Units) -> R<M4> {
|
||||
let reader = solidcraft_step::read::Reader::new(&self.ex, u.units);
|
||||
let reader = solidcore_step::read::Reader::new(&self.ex, u.units);
|
||||
let e = self.ex.get(item).ok_or_else(|| format!("missing entity #{item}"))?;
|
||||
if matches!(e.name(), "CARTESIAN_TRANSFORMATION_OPERATOR_3D" | "CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM") {
|
||||
let p = e.params();
|
||||
@@ -707,18 +716,12 @@ impl Reader {
|
||||
}
|
||||
let Some(e) = self.ex.get(item) else { continue };
|
||||
match e.name() {
|
||||
"MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL" => {
|
||||
name if body_item(rep, name) => {
|
||||
// Only batch consecutive solids: mapped items must retain traversal and
|
||||
// first-use unit/cache order. Bound temporary geometry independently of file size.
|
||||
let mut batch = vec![item];
|
||||
let capacity = MAX_BODIES.saturating_sub(self.bodies_so_far()).min(64);
|
||||
while batch.len() < capacity
|
||||
&& items.peek().is_some_and(|id| {
|
||||
self.ex.get(*id).is_some_and(|e| {
|
||||
matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL")
|
||||
})
|
||||
})
|
||||
{
|
||||
while batch.len() < capacity && items.peek().is_some_and(|id| self.ex.get(*id).is_some_and(|e| body_item(rep, e.name()))) {
|
||||
if let Some(next) = items.next() {
|
||||
batch.push(next);
|
||||
}
|
||||
@@ -737,7 +740,7 @@ impl Reader {
|
||||
// The node lists the planned solids (a surface model's closed shells
|
||||
// beside its sheet are solids of their own, in the model's colour); each
|
||||
// becomes a body index once converted.
|
||||
for piece in solidcraft_step::topology::items(&self.ex, item) {
|
||||
for piece in solidcore_step::topology::items(&self.ex, item) {
|
||||
if piece != item {
|
||||
if let Some(c) = self.colors.get(&item).copied() {
|
||||
self.colors.entry(piece).or_insert(c);
|
||||
@@ -1098,12 +1101,12 @@ impl Reader {
|
||||
/// Faces whose boundary runs the wrong way round their normal, as a strict reader sees them:
|
||||
/// each face's normal is its surface's own parametric normal (∂S/∂u × ∂S/∂v, ISO 10303-42),
|
||||
/// reversed when `same_sense` is false, and the outer loop must run counter-clockwise seen from
|
||||
/// that side. These are the faces the STEP reader turns over ([`solidcraft_step::topology`]),
|
||||
/// that side. These are the faces the STEP reader turns over ([`solidcore_step::topology`]),
|
||||
/// numbered as the solid's body numbers them. Returns one message per bad face; an error when
|
||||
/// the file does not parse or has no solid the reader can read.
|
||||
pub fn step_orientation_errors(text: &str) -> std::result::Result<Vec<String>, String> {
|
||||
let ex = p21::parse(text)?;
|
||||
let read = solidcraft_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
|
||||
let read = solidcore_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
|
||||
if read.solids.is_empty() {
|
||||
return Err(read.failures.first().map_or_else(|| "the file has no solids".into(), |f| format!("solid #{}: {}", f.item, f.message)));
|
||||
}
|
||||
@@ -1302,10 +1305,7 @@ impl StepReading {
|
||||
.iter()
|
||||
.flat_map(|(r, (items, _))| items.iter().map(move |i| (*r, *i)))
|
||||
.filter(|(_, i)| !rd.used_items.contains(i))
|
||||
.filter(|(_, i)| {
|
||||
ex.get(*i)
|
||||
.is_some_and(|e| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS" | "FACETED_BREP" | "SHELL_BASED_SURFACE_MODEL"))
|
||||
})
|
||||
.filter(|(_, i)| ex.get(*i).is_some_and(|e| solidcore_step::topology::is_solid_item(e.name())))
|
||||
.map(|(r, _)| r)
|
||||
.collect();
|
||||
loose.sort();
|
||||
@@ -1319,6 +1319,25 @@ impl StepReading {
|
||||
}
|
||||
tree.push(StepNode { name: "Import".into(), transform: identity, bodies, children: Vec::new() });
|
||||
}
|
||||
// Topology outside every representation (bare geometry files, or ones whose topology no
|
||||
// representation lists): its unlisted solids, shells and faces, read in the file's one
|
||||
// representation context where it has exactly one.
|
||||
if rd.plan.is_empty() {
|
||||
let roots = solidcore_step::topology::roots(&ex);
|
||||
if !roots.is_empty() {
|
||||
let mut contexts = ex.entities.iter().filter(|(_, e)| e.has("GEOMETRIC_REPRESENTATION_CONTEXT")).map(|(id, _)| *id);
|
||||
let context = match (contexts.next(), contexts.next()) {
|
||||
(Some(c), None) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
rd.reps.insert(UNLISTED, (roots, context));
|
||||
let mut bodies = Vec::new();
|
||||
rd.node_items = rd.used_items.clone();
|
||||
rd.visits = 0;
|
||||
rd.rep_bodies(UNLISTED, &identity, &[], "Import", None, &mut bodies, 0);
|
||||
tree.push(StepNode { name: "Import".into(), transform: identity, bodies, children: Vec::new() });
|
||||
}
|
||||
}
|
||||
let single = workers <= 1;
|
||||
Ok(StepReading { rd, tree, key, small: text.len() <= KEEP_SMALL_BYTES, single })
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! The Part 21 parser lives in `solidcraft-step`; this reads with the kernel's progress watch.
|
||||
//! The Part 21 parser is `solidcore-record`'s (re-exported by `solidcore-step`); this reads with the kernel's progress watch.
|
||||
|
||||
pub use solidcraft_step::p21::{Entity, Exchange, Param};
|
||||
pub use solidcore_step::p21::{Entity, Exchange, Param};
|
||||
|
||||
struct Watched(std::sync::Arc<dyn crate::watch::Watch>);
|
||||
|
||||
impl solidcraft_step::p21::Progress for Watched {
|
||||
impl solidcore_step::p21::Progress for Watched {
|
||||
fn cancelled(&self) -> bool {
|
||||
self.0.cancelled()
|
||||
}
|
||||
@@ -13,8 +13,8 @@ impl solidcraft_step::p21::Progress for Watched {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`solidcraft_step::p21::parse`] under the current [`crate::watch`].
|
||||
/// [`solidcore_step::p21::parse`] under the current [`crate::watch`].
|
||||
pub fn parse(text: &str) -> Result<Exchange, String> {
|
||||
let watch = crate::watch::current().map(Watched);
|
||||
solidcraft_step::p21::parse_with(text, watch.as_ref().map(|w| w as &dyn solidcraft_step::p21::Progress))
|
||||
solidcore_step::p21::parse_with(text, watch.as_ref().map(|w| w as &dyn solidcore_step::p21::Progress)).map_err(String::from)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ fn placed(body: &Body, m: &M4) -> crate::Result<Body> {
|
||||
/// refuses fails with the reason ([`surface_part`] then imports what it can of it).
|
||||
pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result::Result<(Part, Vec<Note>), Failure> {
|
||||
let solid = solidcraft_profile::scope("import", "step.read_solid", || {
|
||||
solidcraft_step::topology::read_solid_with(ex, item, units, &solidcraft_smlib::pcurves::SmlibTrims)
|
||||
solidcore_step::topology::read_solid_with(ex, item, units, &solidcraft_smlib::pcurves::SmlibTrims)
|
||||
})
|
||||
.map_err(|msg| Failure { read: true, msg })?;
|
||||
let part = solid_part(&solid)?;
|
||||
@@ -112,7 +112,7 @@ pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result:
|
||||
/// normal, so a sheet whose file uses some faces the other way is given to SMLib with those faces
|
||||
/// turned over (exactly, on the record) and they are turned back on SMLib's body: the body faces
|
||||
/// as the file does, and each of its faces is known to be the record's face it was.
|
||||
fn solid_part(solid: &solidcraft_step::topology::Solid) -> std::result::Result<Part, Failure> {
|
||||
fn solid_part(solid: &solidcore_step::topology::Solid) -> std::result::Result<Part, Failure> {
|
||||
let failed = |e: KernelError| Failure { read: false, msg: e.to_string().trim_start_matches("the operation failed: ").to_string() };
|
||||
let r = &solid.brep;
|
||||
let mut reversed = vec![false; r.faces.len()];
|
||||
@@ -138,13 +138,15 @@ fn solid_part(solid: &solidcraft_step::topology::Solid) -> std::result::Result<P
|
||||
// `natural`'s face j is the record's `to_record[j]`; SMLib's body's face k is `natural`'s
|
||||
// `of_natural[k]`; the body turned back's face i is SMLib's `back[i]`.
|
||||
let (natural, to_record) = turn(&as_read, &|f| reversed.get(f).copied().unwrap_or(false)).map_err(failed)?;
|
||||
let (smlib, of_natural) = smlib_body(natural.record())?;
|
||||
// SMLib names a face it refuses by its index in the record it was given, `natural`'s.
|
||||
let natural_origin: Vec<u64> = to_record.iter().map(|f| solid.face_origin.get(*f).copied()).collect::<Option<_>>().unwrap_or_default();
|
||||
let (smlib, of_natural) = smlib_body(natural.record()).map_err(|f| Failure { msg: name_record_face(&f.msg, &natural_origin), ..f })?;
|
||||
let record_of = |k: usize| of_natural.get(k).and_then(|j| to_record.get(*j)).copied();
|
||||
let (back, from) = turn(&smlib, &|k| record_of(k).and_then(|f| reversed.get(f)).copied().unwrap_or(false)).map_err(failed)?;
|
||||
let of_record = from.iter().map(|k| record_of(*k)).collect::<Option<Vec<usize>>>().ok_or_else(renumbered)?;
|
||||
(back.without_history(), of_record)
|
||||
} else {
|
||||
smlib_body(r)?
|
||||
smlib_body(r).map_err(|f| Failure { msg: name_record_face(&f.msg, &solid.face_origin), ..f })?
|
||||
};
|
||||
let face_origin = of_record.iter().map(|f| solid.face_origin.get(*f).copied()).collect::<Option<Vec<u64>>>().ok_or_else(renumbered)?;
|
||||
Ok(Part { body, closed: !solid.sheet, file_faces: solid.face_origin.len(), face_origin, refused: None })
|
||||
@@ -243,7 +245,7 @@ const MAX_LEFT_OUT_ROUNDS: usize = 8;
|
||||
|
||||
/// A solid the reader or SMLib refuses, imported as an open surface body of its exact faces, the
|
||||
/// faces that make it refused left out and named in the notes; `refused` says why the solid was.
|
||||
/// Each face is read on its own first ([`solidcraft_step::topology::read_faces_with`]); then:
|
||||
/// Each face is read on its own first ([`solidcore_step::topology::read_faces_with`]); then:
|
||||
/// 1. the solid is read again without the faces that cannot be read alone and the face the
|
||||
/// refusal names, as one sheet whose faces share their edges as the file has them, and made
|
||||
/// by SMLib; a refusal of that naming another face of the solid leaves it out too (up to
|
||||
@@ -256,7 +258,7 @@ const MAX_LEFT_OUT_ROUNDS: usize = 8;
|
||||
fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std::result::Result<(Part, Vec<Note>), Failure> {
|
||||
let _span = solidcraft_profile::span("import", "step.refused_surfaces");
|
||||
let trims = &solidcraft_smlib::pcurves::SmlibTrims;
|
||||
let Ok(faces) = solidcraft_step::topology::read_faces_with(ex, item, units, trims) else { return Err(refused) };
|
||||
let Ok(faces) = solidcore_step::topology::read_faces_with(ex, item, units, trims) else { return Err(refused) };
|
||||
let of_solid: std::collections::HashSet<u64> = faces.iter().map(|f| f.face).collect();
|
||||
// The faces left out, each with the refusal that names it, in the order they were found.
|
||||
let mut left_out: Vec<(u64, String)> = faces.iter().filter_map(|f| f.record.as_ref().err().map(|m| (f.face, m.clone()))).collect();
|
||||
@@ -264,7 +266,7 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
|
||||
let mut rounds = 0;
|
||||
loop {
|
||||
// The face the last refusal names, if it is one of the solid's still in, is left out too.
|
||||
let named = solidcraft_step::topology::named_face(&why).filter(|f| of_solid.contains(f) && !left_out.iter().any(|(g, _)| g == f));
|
||||
let named = solidcore_step::topology::named_face(&why).filter(|f| of_solid.contains(f) && !left_out.iter().any(|(g, _)| g == f));
|
||||
match named {
|
||||
Some(f) => left_out.push((f, why.clone())),
|
||||
None if rounds > 0 => break,
|
||||
@@ -276,11 +278,11 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
|
||||
rounds += 1;
|
||||
let skip: std::collections::HashSet<u64> = left_out.iter().map(|(f, _)| *f).collect();
|
||||
let read =
|
||||
solidcraft_profile::scope("import", "step.read_without", || solidcraft_step::topology::read_solid_without(ex, item, units, trims, &skip));
|
||||
match read
|
||||
.map_err(|msg| Failure { read: true, msg })
|
||||
.and_then(|solid| Ok((solid_part(&solid)?, solid.notes.into_iter().map(Note::Repaired).collect::<Vec<_>>())))
|
||||
{
|
||||
solidcraft_profile::scope("import", "step.read_without", || solidcore_step::topology::read_solid_without(ex, item, units, trims, &skip));
|
||||
match read.map_err(|msg| Failure { read: true, msg }).and_then(|solid| {
|
||||
let part = solid_part(&solid)?;
|
||||
Ok((part, solid.notes.into_iter().map(Note::Repaired).collect::<Vec<_>>()))
|
||||
}) {
|
||||
Ok((mut part, mut notes)) => {
|
||||
part.file_faces = faces.len();
|
||||
part.refused = Some(refused.msg);
|
||||
@@ -297,11 +299,29 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
|
||||
let mut notes = Vec::new();
|
||||
let mut pieces = Vec::with_capacity(faces.len());
|
||||
for f in &faces {
|
||||
// A face the solid's shells use more than once (an outer shell and its voids written
|
||||
// with one face) is one piece, once.
|
||||
if pieces.iter().any(|(g, _)| *g == f.face) {
|
||||
notes.push(Note::LeftOut(format!("left out: face #{}: a further use of it by another shell of the solid", f.face)));
|
||||
continue;
|
||||
}
|
||||
match f.record.clone().map_err(KernelError::Failed).and_then(Body::from_record) {
|
||||
Ok(b) => pieces.push((f.face, b)),
|
||||
Err(e) => notes.push(Note::LeftOut(format!("left out: {}", e.to_string().trim_start_matches("the operation failed: ")))),
|
||||
}
|
||||
}
|
||||
// A solid of one face that cannot be made has nothing else to give: the refusal says it is the
|
||||
// only face, which face, and why (an edge's distance from the surface, a vertex's from its
|
||||
// curve), never only that the import failed.
|
||||
let distinct: std::collections::HashSet<u64> = faces.iter().map(|f| f.face).collect();
|
||||
if pieces.is_empty()
|
||||
&& distinct.len() == 1
|
||||
&& let Some(f) = faces.first()
|
||||
{
|
||||
let why = left_out.iter().find(|(g, _)| *g == f.face).map_or(refused.msg.as_str(), |(_, m)| m.as_str());
|
||||
let why = why.split_once("first: ").map_or(why, |(_, rest)| rest);
|
||||
return Err(Failure { msg: format!("the solid's only face, #{}, cannot be made: {why}", f.face), ..refused });
|
||||
}
|
||||
// Where SMLib cannot stitch them either, the solid is refused with the reason it was.
|
||||
let part = stitched_part(&pieces, faces.len(), &refused.msg).map_err(|e| {
|
||||
log::debug!(target: "step", "item #{item}: its faces not stitched ({e})");
|
||||
@@ -310,12 +330,29 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
|
||||
Ok((part, notes))
|
||||
}
|
||||
|
||||
/// A refusal of SMLib's that names a face of the record it was given by its index there
|
||||
/// ("flat arrays: face 12: …"), with that face named as the file's entity too ("face #1167"), so
|
||||
/// the face can be left out and the import say which it was.
|
||||
pub(super) fn name_record_face(msg: &str, face_origin: &[u64]) -> String {
|
||||
let Some(at) = msg.find("flat arrays: face ") else { return msg.to_string() };
|
||||
let Some(rest) = msg.get(at + "flat arrays: face ".len()..) else { return msg.to_string() };
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
match digits.parse::<usize>().ok().and_then(|i| face_origin.get(i)) {
|
||||
Some(step) => format!("face #{step}: {msg}"),
|
||||
None => msg.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-face surface bodies `pieces` (each with its file face) stitched by SMLib into one
|
||||
/// surface body, where their edges coincide within ten times the pieces' tolerance.
|
||||
fn stitched_part(pieces: &[(u64, Body)], file_faces: usize, refused: &str) -> crate::Result<Part> {
|
||||
if pieces.is_empty() {
|
||||
return Err(KernelError::Failed("no face read".into()));
|
||||
}
|
||||
// One face read alone is the sheet: nothing to stitch it to.
|
||||
if let [(face, body)] = pieces {
|
||||
return Ok(Part { body: body.clone(), closed: false, file_faces, face_origin: vec![*face], refused: Some(refused.to_string()) });
|
||||
}
|
||||
let tol = 10.0 * pieces.iter().map(|(_, b)| b.record().tolerance).fold(0.0, f64::max);
|
||||
let bodies: Vec<&Body> = pieces.iter().map(|(_, b)| b).collect();
|
||||
let body = crate::surfaces::stitch(&bodies, tol)?;
|
||||
|
||||
@@ -219,7 +219,7 @@ fn benchmark_independent_step_solids() {
|
||||
let mut items: Vec<_> = ex.entities.iter().filter(|(_, e)| e.name() == "MANIFOLD_SOLID_BREP").map(|(id, _)| *id).collect();
|
||||
items.sort();
|
||||
let start = std::time::Instant::now();
|
||||
let solids: Vec<_> = items.iter().map(|id| solidcraft_step::topology::read_solid(&ex, *id, Units::default()).unwrap()).collect();
|
||||
let solids: Vec<_> = items.iter().map(|id| solidcore_step::topology::read_solid(&ex, *id, Units::default()).unwrap()).collect();
|
||||
let read = start.elapsed();
|
||||
let start = std::time::Instant::now();
|
||||
for solid in solids {
|
||||
@@ -1675,7 +1675,7 @@ fn knot_multiplicities_are_checked_before_they_are_expanded() {
|
||||
};
|
||||
let read = |text: &str| {
|
||||
let ex = p21::parse(text).unwrap();
|
||||
solidcraft_step::read::Reader::new(&ex, Default::default()).curve(3).map(|_| ())
|
||||
solidcore_step::read::Reader::new(&ex, Default::default()).curve(3).map(|_| ())
|
||||
};
|
||||
// These diagnostics come from the checks before expansion; wall-clock time depends on CI load.
|
||||
let error = read(&curve(1_000_000, 64)).unwrap_err();
|
||||
@@ -2120,11 +2120,11 @@ fn step_conformance_parts_read_back_their_exact_shape() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume of the one solid `solidcraft_step::topology` reads from `text` into schema 4,
|
||||
/// The volume of the one solid `solidcore_step::topology` reads from `text` into schema 4,
|
||||
/// measured as the body of that record.
|
||||
pub(super) fn schema_4_volume(text: &str) -> f64 {
|
||||
let ex = solidcraft_step::p21::parse(text).unwrap();
|
||||
let r = solidcraft_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
|
||||
let ex = solidcore_step::p21::parse(text).unwrap();
|
||||
let r = solidcore_step::topology::read_solids_with(&ex, &solidcraft_smlib::pcurves::SmlibTrims);
|
||||
assert!(r.failures.is_empty() && r.solids.len() == 1, "{:?}", r.failures);
|
||||
measure(&Body::from_record(r.solids[0].brep.clone()).unwrap()).unwrap().volume
|
||||
}
|
||||
@@ -2149,7 +2149,7 @@ fn step_conformance_parts_read_into_smlib() {
|
||||
];
|
||||
for (name, volume, faces) in parts {
|
||||
let text = std::fs::read_to_string(dir.join(format!("{name}.step"))).unwrap();
|
||||
let ex = solidcraft_step::p21::parse(&text).unwrap();
|
||||
let ex = solidcore_step::p21::parse(&text).unwrap();
|
||||
// The file's one representation context, as the import reads it.
|
||||
let ctx = ex.entities.iter().find(|(_, e)| e.record("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some()).map(|(id, _)| *id);
|
||||
let units = context_units(&ex, ctx, &mut HashMap::new());
|
||||
@@ -2432,6 +2432,104 @@ fn can_with_its_lid_at(lid_z: f64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// A file whose only face cannot be made (its circle 1.5 mm off its plane) gives nothing to
|
||||
/// open; the error names the face, why and how far, not only that the import failed.
|
||||
#[test]
|
||||
fn a_file_whose_only_face_cannot_be_made_names_it_and_how_far_off() {
|
||||
let text = format!(
|
||||
"{HEAD}
|
||||
#1=CARTESIAN_POINT('',(0.,0.,10.));
|
||||
#2=DIRECTION('',(0.,0.,1.));
|
||||
#3=DIRECTION('',(1.,0.,0.));
|
||||
#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);
|
||||
#5=CARTESIAN_POINT('',(5.,0.,10.));
|
||||
#6=VERTEX_POINT('',#5);
|
||||
#7=CIRCLE('',#4,5.);
|
||||
#8=EDGE_CURVE('',#6,#6,#7,.T.);
|
||||
#9=ORIENTED_EDGE('',*,*,#8,.T.);
|
||||
#10=EDGE_LOOP('',(#9));
|
||||
#11=FACE_OUTER_BOUND('',#10,.T.);
|
||||
#12=CARTESIAN_POINT('',(0.,0.,11.5));
|
||||
#13=AXIS2_PLACEMENT_3D('',#12,#2,#3);
|
||||
#14=PLANE('',#13);
|
||||
#15=ADVANCED_FACE('Lid',(#11),#14,.T.);
|
||||
#16=OPEN_SHELL('',(#15));
|
||||
#17=SHELL_BASED_SURFACE_MODEL('Sheet',(#16));
|
||||
#18=SHAPE_REPRESENTATION('',(#17),$);
|
||||
{TAIL}"
|
||||
);
|
||||
let err = step_import(&text).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err.contains("only face, #15") && err.contains("1.5") && err.contains("off the face's surface"), "{err}");
|
||||
}
|
||||
|
||||
/// solidcore's step-brep read this Discord fillet operand (a reported part, its fillet the
|
||||
/// subject of #2595) as one closed solid; SolidCraft's reader does too, its spline faces trimmed
|
||||
/// by SMLib. Ported from step-brep's `tests/discord_fillet.rs`, with its fixture, now
|
||||
/// solidcore-step's.
|
||||
#[test]
|
||||
fn discord_fillet_operand_reads_as_one_closed_solid() {
|
||||
let text = include_str!("../../../../solidcore/crates/solidcore-step/tests/fixtures/discord-2026-10-30-fillet.step");
|
||||
let imp = step_import(text).unwrap();
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
let b = &imp.bodies[0];
|
||||
assert!(b.closed && b.refused.is_none(), "{:?}", imp.warnings);
|
||||
assert_eq!(b.body.face_count(), b.file_faces, "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
/// A face no shell lists (a file of a bare face, as cadmpeg's root fixtures and STEP Tools'
|
||||
/// faceted samples hold) is a one-face sheet. Ported from step-brep's
|
||||
/// `tests/issue_2581_faceted_brep.rs` (`standalone_face_without_shell_is_single_face_sheet`).
|
||||
#[test]
|
||||
fn a_face_no_shell_lists_is_a_one_face_sheet() {
|
||||
let text = format!(
|
||||
"{HEAD}
|
||||
#1=(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.));
|
||||
#2=(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNIT_ASSIGNED_CONTEXT((#1)) REPRESENTATION_CONTEXT('model','3D'));
|
||||
#3=CARTESIAN_POINT('',(0.,0.,0.));
|
||||
#4=CARTESIAN_POINT('',(9.,0.,0.));
|
||||
#5=CARTESIAN_POINT('',(0.,7.,0.));
|
||||
#6=POLY_LOOP('',(#3,#4,#5));
|
||||
#7=FACE_OUTER_BOUND('',#6,.T.);
|
||||
#8=DIRECTION('',(0.,0.,1.));
|
||||
#9=DIRECTION('',(1.,0.,0.));
|
||||
#10=AXIS2_PLACEMENT_3D('',#3,#8,#9);
|
||||
#11=PLANE('',#10);
|
||||
#12=FACE_SURFACE('',(#7),#11,.T.);
|
||||
{TAIL}"
|
||||
);
|
||||
let imp = step_import(&text).unwrap();
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
let b = &imp.bodies[0];
|
||||
assert!(!b.closed && b.body.face_count() == 1, "{:?}", imp.warnings);
|
||||
assert!(rel(measure(&b.body).unwrap().area, 31.5) < 1e-9, "{}", measure(&b.body).unwrap().area);
|
||||
// Styled and on a layer, it is still a face no shell lists: a presentation's reference is not
|
||||
// the topology's.
|
||||
let styled = text.replace(
|
||||
"#12=FACE_SURFACE('',(#7),#11,.T.);",
|
||||
"#12=FACE_SURFACE('',(#7),#11,.T.);
|
||||
#13=PRESENTATION_STYLE_ASSIGNMENT(());
|
||||
#14=STYLED_ITEM('',(#13),#12);
|
||||
#15=PRESENTATION_LAYER_ASSIGNMENT('1','',(#12));",
|
||||
);
|
||||
let imp = step_import(&styled).unwrap();
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
assert_eq!(imp.bodies[0].body.face_count(), 1, "{:?}", imp.warnings);
|
||||
}
|
||||
|
||||
/// SMLib's refusal of a face by its index in the record (NIST CTC 02's face whose NURBS form is
|
||||
/// not its analytic surface, which SMLib numbers 90) names the file's face, so the face is left
|
||||
/// out and the import's warning says which: "face #1202".
|
||||
#[test]
|
||||
fn a_face_smlib_refuses_by_its_index_is_named_as_the_files_face() {
|
||||
let msg = "SMLib: solidcore: flat arrays: face 2: a NURBS form that is not its analytic surface";
|
||||
let named = super::parallel::name_record_face(msg, &[15, 20, 1202]);
|
||||
assert_eq!(named, format!("face #1202: {msg}"));
|
||||
assert_eq!(solidcore_step::topology::named_face(&named), Some(1202));
|
||||
// An index past the record's faces, or no index, leaves the message as it is.
|
||||
assert_eq!(super::parallel::name_record_face(msg, &[15]), msg);
|
||||
assert_eq!(super::parallel::name_record_face("SMLib: no face here", &[15]), "SMLib: no face here");
|
||||
}
|
||||
|
||||
/// A solid no B-rep can be made of comes in as an open surface body of its exact faces, the
|
||||
/// faces that make it refused left out and named, as Poppy Humanoid's stock head (a face around
|
||||
/// an open surface of revolution with one bound) and Congé19 (an edge further off its face than
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! STEP (ISO 10303-21) export: AP242 product structure, assemblies, names and colours.
|
||||
//!
|
||||
//! Each body's record is written as it is, the exact B-rep, by
|
||||
//! [`solidcraft_step::write::topology`]: its material regions as solids, its open shells as a
|
||||
//! [`solidcore_step::write::topology`]: its material regions as solids, its open shells as a
|
||||
//! surface model. Around it this writes one header and context, a product per part with its
|
||||
//! shape representation, named solids, surface and face colours, and assemblies as
|
||||
//! next-assembly-usage occurrences placed by item-defined transformations (the structure the
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use solidcraft_step::p21::Param;
|
||||
use solidcraft_step::write::{GeomWriter, Writer, string, topology};
|
||||
use solidcore_step::p21::Param;
|
||||
use solidcore_step::write::{GeomWriter, Writer, string, topology};
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result};
|
||||
@@ -161,7 +161,7 @@ pub fn step_export_products(products: &[ExportProduct], root: usize, header: &St
|
||||
("GLOBAL_UNIT_ASSIGNED_CONTEXT", vec![refs(&[mm, rad, sr])]),
|
||||
("REPRESENTATION_CONTEXT", vec![s("Context #1"), s("3D Context with UNIT and UNCERTAINTY")]),
|
||||
]);
|
||||
let origin = GeomWriter::new(&mut w).placement(&solidcraft_step::geom::Placement {
|
||||
let origin = GeomWriter::new(&mut w).placement(&solidcore_step::geom::Placement {
|
||||
origin: [0.0; 3],
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
ref_direction: [1.0, 0.0, 0.0],
|
||||
@@ -318,9 +318,9 @@ pub fn step_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result<
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcore_step::p21;
|
||||
use solidcore_step::write::real;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_step::p21;
|
||||
use solidcraft_step::write::real;
|
||||
|
||||
#[test]
|
||||
fn islands_are_solids_of_their_own_not_voids() {
|
||||
|
||||
@@ -701,11 +701,18 @@ const SAMPLES: usize = 64;
|
||||
|
||||
/// The parameter of the curve's point nearest `p` inside `interval`, and the distance: the
|
||||
/// nearest of evenly spaced samples, then a golden-section search between its neighbours.
|
||||
pub(crate) fn nearest_parameter(c: &CurveEval, [a, b]: [f64; 2], p: Vec3) -> Option<(f64, f64)> {
|
||||
pub(crate) fn nearest_parameter(c: &CurveEval, interval: [f64; 2], p: Vec3) -> Option<(f64, f64)> {
|
||||
nearest_parameter_in(c, interval, p, SAMPLES)
|
||||
}
|
||||
|
||||
/// [`nearest_parameter`] from `samples` evenly spaced samples: a curve of many spans needs
|
||||
/// several in each, or the search can settle beside the point on the wrong span.
|
||||
pub(crate) fn nearest_parameter_in(c: &CurveEval, [a, b]: [f64; 2], p: Vec3, samples: usize) -> Option<(f64, f64)> {
|
||||
let samples = samples.max(1);
|
||||
let dist = |t: f64| c.point(t).map_or(f64::INFINITY, |q| q.dist(p));
|
||||
let at = |k: usize| a + (b - a) * k as f64 / SAMPLES as f64;
|
||||
let best = (0..=SAMPLES).min_by(|&i, &j| dist(at(i)).total_cmp(&dist(at(j))))?;
|
||||
let (mut lo, mut hi) = (at(best.saturating_sub(1)), at((best + 1).min(SAMPLES)));
|
||||
let at = |k: usize| a + (b - a) * k as f64 / samples as f64;
|
||||
let best = (0..=samples).min_by(|&i, &j| dist(at(i)).total_cmp(&dist(at(j))))?;
|
||||
let (mut lo, mut hi) = (at(best.saturating_sub(1)), at((best + 1).min(samples)));
|
||||
let ratio = 0.5 * (5f64.sqrt() - 1.0);
|
||||
for _ in 0..80 {
|
||||
let (x, y) = (hi - ratio * (hi - lo), lo + ratio * (hi - lo));
|
||||
|
||||
@@ -2246,6 +2246,27 @@ fn boolean_identity_on_tangent_primitives() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1989: a boolean watched by someone who cancelled (an agent's request cancelled while it
|
||||
/// computes off the window thread) does not start, saying so; unwatched, or watched and not
|
||||
/// cancelled, it runs as before.
|
||||
#[test]
|
||||
fn a_cancelled_watch_stops_the_boolean() {
|
||||
struct Cancelled(bool);
|
||||
impl crate::watch::Watch for Cancelled {
|
||||
fn cancelled(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
fn progress(&self, _: &'static str, _: usize, _: usize) {}
|
||||
}
|
||||
let a = cylinder(Vec3::new(0.0, 0.0, -10.0), Vec3::Z, 3.0, 20.0).unwrap();
|
||||
let b = cylinder(Vec3::new(-10.0, 0.0, 0.0), Vec3::X, 2.0, 20.0).unwrap();
|
||||
let e = crate::watch::watching(std::sync::Arc::new(Cancelled(true)), || boolean(&a, &b, BoolOp::Intersect)).unwrap_err();
|
||||
assert!(e.to_string().contains("cancelled"), "{e}");
|
||||
let r = crate::watch::watching(std::sync::Arc::new(Cancelled(false)), || boolean(&a, &b, BoolOp::Intersect));
|
||||
assert!(r.is_ok_and(|r| r.is_some()));
|
||||
assert!(boolean(&a, &b, BoolOp::Intersect).unwrap().is_some());
|
||||
}
|
||||
|
||||
/// A body in two pieces: booleans go a piece at a time.
|
||||
#[test]
|
||||
fn boolean_of_a_body_in_pieces() {
|
||||
|
||||
@@ -38,6 +38,11 @@ pub fn current() -> Option<Arc<dyn Watch>> {
|
||||
CURRENT.with(|c| c.borrow().clone())
|
||||
}
|
||||
|
||||
/// Whether the watch over this thread's work asked it to stop.
|
||||
pub(crate) fn cancelled() -> bool {
|
||||
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|w| w.cancelled()))
|
||||
}
|
||||
|
||||
/// The error long work returns when its watch cancelled it.
|
||||
pub(crate) fn cancelled_error(what: &str) -> crate::KernelError {
|
||||
crate::KernelError::Failed(format!("{what} cancelled"))
|
||||
|
||||
@@ -3,16 +3,12 @@ use super::*;
|
||||
use solidcraft_theme::{AccentRamp, TabStyle, TabTable};
|
||||
use solidcraft_ui_kit::{icons as kit_icons, theme_files, type_face};
|
||||
|
||||
/// The host's tab table, which is not the look's: one naming the tabs the built-in themes key
|
||||
/// per-tab surfaces by (Onyx's `tabs`), which they are read against.
|
||||
/// The host's tab table, which is not the look's: it has none of the tabs the built-in themes
|
||||
/// key per-tab surfaces by (Onyx's `tabs`), and every built-in loads under it.
|
||||
const TABS: TabTable = TabTable::new(&[
|
||||
TabStyle { name: "MODEL", ramp: AccentRamp::Main, icon_accent: None },
|
||||
TabStyle { name: "SKETCH", ramp: AccentRamp::Sketch, icon_accent: Some("sketch") },
|
||||
TabStyle { name: "SURFACE", ramp: AccentRamp::Surface, icon_accent: Some("surface") },
|
||||
TabStyle { name: "MESH", ramp: AccentRamp::Mesh, icon_accent: Some("mesh") },
|
||||
TabStyle { name: "FORM", ramp: AccentRamp::Form, icon_accent: Some("form") },
|
||||
TabStyle { name: "SHEET METAL", ramp: AccentRamp::Sheet, icon_accent: None },
|
||||
TabStyle { name: "RENDER", ramp: AccentRamp::Render, icon_accent: None },
|
||||
TabStyle { name: "DRAW", ramp: AccentRamp::Main, icon_accent: None },
|
||||
TabStyle { name: "SHAPE", ramp: AccentRamp::Surface, icon_accent: Some("surface") },
|
||||
TabStyle { name: "PAINT", ramp: AccentRamp::Render, icon_accent: None },
|
||||
]);
|
||||
|
||||
#[test]
|
||||
@@ -43,3 +39,16 @@ fn install_gives_the_kit_solidcrafts_icons_glyphs_fonts_and_themes() {
|
||||
let heading = ctx.global_style().text_styles[&egui::TextStyle::Heading].family.clone();
|
||||
assert_eq!(heading, type_face::semibold(1.0).family);
|
||||
}
|
||||
|
||||
/// Under a host whose tab table has none of SolidCraft's tabs, every built-in theme loads, Onyx
|
||||
/// without the per-tab surfaces it keys by SolidCraft's tab names.
|
||||
#[test]
|
||||
fn every_builtin_theme_loads_under_a_host_with_other_tabs() {
|
||||
solidcraft_theme::install_tabs(TABS);
|
||||
// The look's icon set, which the built-ins' icon palettes are read against.
|
||||
install_tables();
|
||||
let read = theme_files::read_builtin(themes::BUILTIN).unwrap_or_else(|e| panic!("the built-in themes read: {e}"));
|
||||
assert_eq!(read.len(), themes::BUILTIN.len());
|
||||
let onyx = read.iter().find(|theme| theme.id == "onyx").expect("Onyx is built in");
|
||||
assert!(onyx.tabs.is_empty(), "{:?}", onyx.tabs.keys());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! SolidCraft's built-in themes: the theme files in `themes/`, embedded at build time (`build.rs`),
|
||||
//! for the kit's theme files (`solidcraft_ui_kit::theme_files`).
|
||||
//! for the kit's theme files (`solidcraft_ui_kit::theme_files`). Their per-tab surfaces (`tabs`) are
|
||||
//! keyed by SolidCraft's tab names; under a host whose tab table lacks one, that tab's surfaces are
|
||||
//! left out and the theme loads.
|
||||
|
||||
// `BUILTIN`, `(id, JSON text)` sorted by id.
|
||||
include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs"));
|
||||
|
||||
@@ -358,6 +358,24 @@ impl Remote {
|
||||
outcome(&v).inspect_err(|_| self.drop_conn())
|
||||
}
|
||||
|
||||
/// `control.cancel` for the request still running on this connection (a timed-out call's,
|
||||
/// docs/control-protocol.md). Sent at once, without waiting for that request first, and never
|
||||
/// on a fresh connection, which has nothing to cancel. Once the app no longer holds it
|
||||
/// (removed, stopped or finished), later calls don't wait for it.
|
||||
fn cancel_outstanding(&mut self, params: Value) -> Result<Value, String> {
|
||||
if self.conn.is_none() {
|
||||
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this bridge is running in the app"}));
|
||||
}
|
||||
let line = self.request_line("control.cancel", params);
|
||||
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
let r = outcome(&v);
|
||||
if r.is_ok() {
|
||||
self.outstanding = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
|
||||
/// saying on stderr how long it takes. An error: the connection, and so the reply, was lost.
|
||||
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, String> {
|
||||
@@ -644,6 +662,8 @@ impl Backend for Remote {
|
||||
"engine.execute" if self.param_check != ParamCheck::Off => self.execute_checked(params),
|
||||
"engine.batch" if self.param_check != ParamCheck::Off => self.batch_checked(params),
|
||||
"engine.script" if self.param_check != ParamCheck::Off => self.call_once(method, self.at_level(params), true),
|
||||
// Not behind the request it cancels, as other calls are.
|
||||
"control.cancel" => self.cancel_outstanding(params),
|
||||
_ => self.call_once(method, params, true),
|
||||
}
|
||||
}
|
||||
@@ -860,6 +880,7 @@ impl Headless {
|
||||
// is ever left running.
|
||||
"control.running" => Ok(json!({"running": []})),
|
||||
"control.pending" => Ok(json!({"pending": null})),
|
||||
"control.cancel" => Err("nothing can be cancelled in headless mode: each call runs to its end before it returns".into()),
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3165,6 +3165,59 @@ fn a_bridge_made_before_the_app_runs_connects_once_it_does() {
|
||||
app.join().unwrap();
|
||||
}
|
||||
|
||||
/// #1989: the `cancel` tool sends `control.cancel` on the bridge's own connection, where the
|
||||
/// request still running waits, and reports what the app did: stopped, or why it can't be
|
||||
/// cancelled (a tool error). A bridge with no connection has nothing to cancel and sends nothing;
|
||||
/// headless calls always run to their end, so it refuses there.
|
||||
#[test]
|
||||
fn cancel_forwards_to_the_app_and_reports() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
let text = |r: &Value| r["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||||
let mut never = crate::Remote::new("127.0.0.1:9", Some("0123456789abcdef0123456789abcdef"));
|
||||
let r = crate::call_tool(&mut never, "cancel", &json!({}));
|
||||
assert!(r["isError"] != json!(true) && text(&r).contains("nothing to cancel"), "{r}");
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let app = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut lines = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut asked = Vec::new();
|
||||
for reply in [
|
||||
json!({"ok": true, "result": {"cancelled": 7, "method": "engine.execute", "outcome": "stopped", "note": "stopped: request 7 (`engine.execute`) was computing off the window thread; nothing was applied"}}),
|
||||
json!({"ok": false, "cancellable": false, "error": "cannot be cancelled: request 8 (`engine.execute`) already runs in the app"}),
|
||||
] {
|
||||
let mut line = String::new();
|
||||
lines.read_line(&mut line).unwrap();
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
asked.push((req["method"].clone(), req["token"].clone()));
|
||||
let mut reply = reply;
|
||||
reply["id"] = req["id"].clone();
|
||||
writeln!(&stream, "{reply}").unwrap();
|
||||
}
|
||||
asked
|
||||
});
|
||||
let mut remote = crate::Remote::connect_with_token(&addr, Some("0123456789abcdef0123456789abcdef")).unwrap();
|
||||
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
|
||||
assert!(r["isError"] != json!(true), "{r}");
|
||||
let body: Value = serde_json::from_str(&text(&r)).unwrap();
|
||||
assert_eq!((&body["cancelled"], &body["outcome"]), (&json!(7), &json!("stopped")), "{body}");
|
||||
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
|
||||
assert!(r["isError"] == json!(true) && text(&r).starts_with("cannot be cancelled"), "{r}");
|
||||
let asked = app.join().unwrap();
|
||||
assert!(asked.iter().all(|(m, t)| m == "control.cancel" && t == "0123456789abcdef0123456789abcdef"), "{asked:?}");
|
||||
|
||||
let mut s = server();
|
||||
let r = raw_tool(&mut s, "cancel", json!({}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
assert!(r["result"]["content"][0]["text"].as_str().is_some_and(|t| t.contains("headless")), "{r}");
|
||||
let tools = crate::tools::tool_definitions();
|
||||
let cancel = tools.iter().find(|t| t["name"] == "cancel").unwrap();
|
||||
let a = &cancel["annotations"];
|
||||
assert_eq!((&a["readOnlyHint"], &a["destructiveHint"], &a["openWorldHint"]), (&json!(false), &json!(false), &json!(false)), "{a}");
|
||||
}
|
||||
|
||||
/// An app that quits while it runs a request may have applied it: the retry then finds nothing
|
||||
/// listening, and the error says the call may have run, not just that the app is not running.
|
||||
#[test]
|
||||
@@ -3454,6 +3507,48 @@ fn running_sent_right_behind_a_batch_is_not_queued_behind_it() {
|
||||
assert!(held, "the app was asked `control.running` while it held the batch");
|
||||
}
|
||||
|
||||
/// #1989 with #1618: `cancel` goes out at once, not behind the request still running as other
|
||||
/// calls are; once the app has removed or stopped it, later calls are sent without waiting for it
|
||||
/// and `pending` has nothing. One that can't be cancelled is still waited for.
|
||||
#[test]
|
||||
fn cancel_frees_the_bridge_from_a_request_still_running() {
|
||||
use crate::backend::{Backend, STILL_RUNNING};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let stuck = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let on_window = stuck.clone();
|
||||
let (addr, seen) = scripted_app(move |req| match req["method"].as_str() {
|
||||
Some("engine.execute") if req["params"]["command"] == "solid.fillet" => running(),
|
||||
Some("control.pending") => json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": false}}),
|
||||
Some("control.cancel") if on_window.load(Ordering::SeqCst) => {
|
||||
json!({"ok": false, "cancellable": false, "error": "cannot be cancelled: request 1 already runs in the app"})
|
||||
}
|
||||
Some("control.cancel") => json!({"ok": true, "result": {"cancelled": 1, "method": "engine.execute", "outcome": "stopped"}}),
|
||||
_ => json!({"ok": true, "result": "ran"}),
|
||||
});
|
||||
let mut remote = crate::Remote::connect(&addr).unwrap();
|
||||
remote.set_waits(quick_waits(20, 20));
|
||||
let methods = |seen: &std::sync::Mutex<(usize, Vec<String>)>| std::mem::take(&mut seen.lock().unwrap().1);
|
||||
assert!(remote.call("engine.execute", json!({"command": "solid.fillet"})).unwrap_err().starts_with(STILL_RUNNING));
|
||||
methods(&seen);
|
||||
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
|
||||
assert_ne!(r["isError"], true, "{r}");
|
||||
assert_eq!(methods(&seen), ["control.cancel"], "sent at once");
|
||||
assert_eq!(remote.call("document.inspect", json!({})).unwrap(), "ran");
|
||||
assert_eq!(methods(&seen), ["document.inspect"], "not behind the stopped request");
|
||||
assert_eq!(remote.call("control.pending", json!({})).unwrap(), json!({"pending": null}));
|
||||
|
||||
// One the app can't cancel stays outstanding: the next call still waits for it.
|
||||
stuck.store(true, Ordering::SeqCst);
|
||||
assert!(remote.call("engine.execute", json!({"command": "solid.fillet"})).is_err());
|
||||
methods(&seen);
|
||||
let r = crate::call_tool(&mut remote, "cancel", &json!({}));
|
||||
assert_eq!(r["isError"], true, "{r}");
|
||||
let e = remote.call("document.inspect", json!({})).unwrap_err();
|
||||
assert!(e.starts_with(STILL_RUNNING) && e.contains("earlier request"), "{e}");
|
||||
assert!(!methods(&seen).iter().any(|m| m == "document.inspect"));
|
||||
assert_eq!(seen.lock().unwrap().0, 1, "one connection throughout");
|
||||
}
|
||||
|
||||
/// A request still running after the wait is left to the `pending` tool: the error says so
|
||||
/// (and that it will still apply, or only write its file), later calls are not sent behind it, and `pending` returns
|
||||
/// its result once it lands, on the same connection.
|
||||
|
||||
+17
-1
@@ -363,10 +363,23 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
"Bridged to the app: the result of a request that was still running when its tool call returned (an error \
|
||||
starting \"still running:\"). Waits for it up to 60 s: then its own result (or error), or done: false while \
|
||||
it still runs (call again). pending: null when nothing is running. A request still running was not \
|
||||
cancelled: don't send it again; other calls wait for it before they are sent.",
|
||||
cancelled (`cancel` cancels it): don't send it again; other calls wait for it before they are sent.",
|
||||
object(json!({}), &[]),
|
||||
Effect::ReadOnly,
|
||||
),
|
||||
tool(
|
||||
"cancel",
|
||||
"Cancel",
|
||||
"Bridged: cancel the request that was still running in the app when its call returned (an error starting \
|
||||
\"still running:\"), without waiting for it. One still waiting its turn will not run; a design change or \
|
||||
export computing off the app's window thread is stopped (Preferences > AI): the design stays as it was, \
|
||||
though a stopped export may still write its file. One already applying on the window thread can't be \
|
||||
cancelled: the error says so, and it will still finish. Only \
|
||||
this server's own request can be cancelled. Headless calls always run to their end, so there is nothing to \
|
||||
cancel.",
|
||||
object(json!({}), &[]),
|
||||
Effect::Edits,
|
||||
),
|
||||
tool("undo", "Undo", "Undo the last change to the design.", object(json!({}), &[]), Effect::Edits),
|
||||
tool("redo", "Redo", "Redo the last undone change.", object(json!({}), &[]), Effect::Edits),
|
||||
tool(
|
||||
@@ -909,6 +922,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
},
|
||||
"skill_reference" => crate::skill_reference::read(a),
|
||||
"running" => b.call("control.running", json!({})),
|
||||
"cancel" => b.call("control.cancel", json!({})),
|
||||
"undo" => exec(b, "edit.undo", json!({})),
|
||||
"redo" => exec(b, "edit.redo", json!({})),
|
||||
"new_design" => exec(b, "file.new", pick(a, &["name"])),
|
||||
@@ -1125,6 +1139,7 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
|
||||
"pending" => Err("`pending` is a tool, not a command: call it on its own".into()),
|
||||
"running" => Err("`running` is a tool, not a command: call it on its own".into()),
|
||||
"skill_reference" => Err("`skill_reference` is a tool, not a command: call it on its own".into()),
|
||||
"cancel" => Err("`cancel` is a tool, not a command: call it on its own".into()),
|
||||
// An engine command: the step goes as it is.
|
||||
_ => Ok((String::new(), Value::Null, None)),
|
||||
}
|
||||
@@ -1134,6 +1149,7 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
|
||||
const TOOL_STEPS: &[&str] = &[
|
||||
"batch",
|
||||
"body_topology",
|
||||
"cancel",
|
||||
"check_parameters",
|
||||
"compare_picture",
|
||||
"crash_reports",
|
||||
|
||||
@@ -24,6 +24,15 @@ const MERGE_TOL: f64 = 1e-6;
|
||||
/// within it join only when each is the other's unique nearest end, so a third end as near
|
||||
/// leaves the gap open.
|
||||
pub const ENDPOINT_JOIN_TOL: f64 = MERGE_TOL * 100.0;
|
||||
/// How far apart (mm) the ends of a line and an arc, or of two arcs, may be and still join when
|
||||
/// each end lies on the other curve. Where a curve meets an arc tangentially, a point held on
|
||||
/// both by "point on curve" constraints is only fixed to about `sqrt(2 * radius * residual)`
|
||||
/// along the tangent: the solver's 1e-9 mm residual leaves the two ends up to 2.2e-4 mm apart
|
||||
/// on a 25 mm arc (#14256). Half of [`ISLAND_MARGIN`], so joined curves share an island.
|
||||
const TANGENT_JOIN_TOL: f64 = ISLAND_MARGIN * 0.5;
|
||||
/// How near (mm) an end must lie to another curve's line or circle to count as on it for
|
||||
/// [`TANGENT_JOIN_TOL`]: well above the solver's residual, well below any visible gap.
|
||||
const ON_CARRIER_TOL: f64 = MERGE_TOL * 0.1;
|
||||
const MAX_EDGES: usize = 50_000;
|
||||
|
||||
/// A closed region usable by features, with the ids of the curves bounding it.
|
||||
@@ -484,10 +493,63 @@ fn close_line_joints(mut segs: Vec<Seg2>, tol: f64) -> Vec<Seg2> {
|
||||
segs
|
||||
}
|
||||
|
||||
/// Joins line and arc ends that lie close together. Returns false when there were too many ends
|
||||
/// close together to look at them all (nothing is joined then).
|
||||
/// The line or circle a profile line or arc lies on, from its exact geometry.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Carrier {
|
||||
Line { a: Vec2, b: Vec2 },
|
||||
Circle { c: Vec2, r: f64 },
|
||||
}
|
||||
|
||||
impl Carrier {
|
||||
fn of(sk: &Sketch, ci: usize) -> Option<Carrier> {
|
||||
match sk.curves.get(ci)?.kind {
|
||||
CurveKind::Line { a, b } => {
|
||||
let (a, b) = (sk.point(a)?, sk.point(b)?);
|
||||
(a.dist(b) > MERGE_TOL).then_some(Carrier::Line { a, b })
|
||||
}
|
||||
CurveKind::Arc { .. } => match sk.segs(ci).into_iter().next()? {
|
||||
Seg2::Arc { center, radius, .. } if center.is_finite() && radius.is_finite() => Some(Carrier::Circle { c: center, r: radius }),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn distance(&self, p: Vec2) -> f64 {
|
||||
match *self {
|
||||
Carrier::Line { a, b } => {
|
||||
let d = b - a;
|
||||
(d.cross(p - a) / d.len().max(1e-12)).abs()
|
||||
}
|
||||
Carrier::Circle { c, r } => (p.dist(c) - r).abs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the end `p` of a curve lying on `pc` and the end `q` of another curve lying on `qc`
|
||||
/// meet where the two curves touch: each end lies on the other's line or circle. Two lines never
|
||||
/// meet this way (an end slides freely along a line it lies on, so ends on a common line can be
|
||||
/// genuinely apart), nor do two arcs of one circle.
|
||||
fn ends_on_each_other(p: Vec2, pc: Carrier, q: Vec2, qc: Carrier) -> bool {
|
||||
let distinct = match (pc, qc) {
|
||||
(Carrier::Line { .. }, Carrier::Line { .. }) => false,
|
||||
(Carrier::Circle { c, r }, Carrier::Circle { c: c2, r: r2 }) => !same_circle(c, r, c2, r2, MERGE_TOL),
|
||||
_ => true,
|
||||
};
|
||||
distinct && qc.distance(p) <= ON_CARRIER_TOL && pc.distance(q) <= ON_CARRIER_TOL
|
||||
}
|
||||
|
||||
/// Joins line and arc ends that lie close together: within [`ENDPOINT_JOIN_TOL`], or within
|
||||
/// [`TANGENT_JOIN_TOL`] where each end lies on the other curve ([`ends_on_each_other`]). Returns
|
||||
/// false when there were too many ends close together to look at them all (nothing is joined
|
||||
/// then).
|
||||
fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mut [usize], max_edges: usize) -> bool {
|
||||
let tol = ENDPOINT_JOIN_TOL;
|
||||
let tol = TANGENT_JOIN_TOL;
|
||||
let mut carriers = std::collections::HashMap::<usize, Option<Carrier>>::new();
|
||||
for &(_, curve_index) in endpoints {
|
||||
carriers.entry(curve_index).or_insert_with(|| Carrier::of(sk, curve_index));
|
||||
}
|
||||
let carrier = |ci: usize| carriers.get(&ci).copied().flatten();
|
||||
let cell = |p: Vec2| ((p.x / tol).floor() as i64, (p.y / tol).floor() as i64);
|
||||
let mut curve_endpoints = std::collections::HashMap::<usize, Vec<usize>>::new();
|
||||
for &(point_index, curve_index) in endpoints {
|
||||
@@ -500,19 +562,19 @@ fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mu
|
||||
Some(((*a).min(*b), (*a).max(*b)))
|
||||
})
|
||||
.collect();
|
||||
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> = std::collections::HashMap::new();
|
||||
let mut grid: std::collections::HashMap<(i64, i64), Vec<(usize, usize)>> = std::collections::HashMap::new();
|
||||
let mut joins = std::collections::HashMap::<(usize, usize), f64>::new();
|
||||
let mut checks = 0usize;
|
||||
let check_limit = max_edges.saturating_mul(16).max(16);
|
||||
let mut exceeded_limit = false;
|
||||
'points: for &(point_index, _) in endpoints {
|
||||
'points: for &(point_index, curve_index) in endpoints {
|
||||
let Some(point) = sk.point(point_index).filter(|point| point.is_finite()) else { continue };
|
||||
let (cx, cy) = cell(point);
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
let (Some(x), Some(y)) = (cx.checked_add(dx), cy.checked_add(dy)) else { continue };
|
||||
if let Some(candidates) = grid.get(&(x, y)) {
|
||||
for &other_index in candidates {
|
||||
for &(other_index, other_curve) in candidates {
|
||||
if other_index == point_index {
|
||||
continue;
|
||||
}
|
||||
@@ -521,7 +583,13 @@ fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mu
|
||||
exceeded_limit = true;
|
||||
break 'points;
|
||||
}
|
||||
if let Some(distance) = sk.point(other_index).map(|other| point.dist(other)).filter(|distance| *distance <= tol) {
|
||||
let Some(other) = sk.point(other_index) else { continue };
|
||||
let distance = point.dist(other);
|
||||
let touching = || match (carrier(curve_index), carrier(other_curve)) {
|
||||
(Some(pc), Some(qc)) => curve_index != other_curve && ends_on_each_other(point, pc, other, qc),
|
||||
_ => false,
|
||||
};
|
||||
if distance <= ENDPOINT_JOIN_TOL || (distance <= tol && touching()) {
|
||||
let pair = (point_index.min(other_index), point_index.max(other_index));
|
||||
joins.entry(pair).and_modify(|best| *best = best.min(distance)).or_insert(distance);
|
||||
}
|
||||
@@ -529,7 +597,7 @@ fn merge_nearby_endpoints(sk: &Sketch, endpoints: &[(usize, usize)], groups: &mu
|
||||
}
|
||||
}
|
||||
}
|
||||
grid.entry((cx, cy)).or_default().push(point_index);
|
||||
grid.entry((cx, cy)).or_default().push((point_index, curve_index));
|
||||
}
|
||||
if !exceeded_limit {
|
||||
let mut nearest = std::collections::HashMap::<usize, (f64, Option<usize>)>::new();
|
||||
@@ -1610,7 +1678,7 @@ fn island_loops(sk: &Sketch, curves: &[usize], max_edges: usize) -> Island {
|
||||
if !ok || h != start {
|
||||
continue;
|
||||
}
|
||||
let segs = close_graph_joints(segs, &edge_nodes, &edge_analytic, ENDPOINT_JOIN_TOL);
|
||||
let segs = close_graph_joints(segs, &edge_nodes, &edge_analytic, TANGENT_JOIN_TOL);
|
||||
let lp = Loop2 { segs: close_line_joints(restore_exact(segs, &ptags, &exact), ENDPOINT_JOIN_TOL) };
|
||||
if lp.signed_area() > 1e-9 {
|
||||
let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0);
|
||||
|
||||
@@ -540,6 +540,77 @@ fn profiles_join_unconstrained_nearby_line_arc_endpoints() {
|
||||
assert!((profiles[0].area - (200.0 + PI * 25.0)).abs() < 1e-6, "{}", profiles[0].area);
|
||||
}
|
||||
|
||||
/// The end plate of #14256: an outline whose 25 mm arc meets a horizontal and a vertical line
|
||||
/// tangentially, each joint held by two "point on curve" constraints instead of one shared point.
|
||||
/// The solver leaves the arc's ends on the lines only to its 1e-9 mm residual, which along the
|
||||
/// tangent puts them 5e-5 mm and 2.25e-4 mm from the lines' ends. The horizontal line is raised
|
||||
/// by `lift`.
|
||||
fn tangent_joint_end_plate(lift: f64) -> Sketch {
|
||||
let mut sk = Sketch::new();
|
||||
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
|
||||
let mut point = |x: f64, y: f64| sk.add_point(v(x, y), None).unwrap();
|
||||
let top_left = point(-14.0, 90.0);
|
||||
let top_right = point(14.0, 90.0);
|
||||
let shoulder = point(14.0, 48.0);
|
||||
let knee = point(knee, 11.125);
|
||||
let right_top = point(90.0, 11.125);
|
||||
let right_bottom = point(90.0, -11.125);
|
||||
let step_bottom = point(25.0, -11.125);
|
||||
let step_top = point(25.0, 0.0);
|
||||
let arc_start = point(24.999999999949498, 5.025035674136865e-5);
|
||||
let arc_end = point(0.0002252128472149822, 24.999999999324313);
|
||||
let ledge_right = point(0.0, 25.0 + lift);
|
||||
let ledge_left = point(-14.0, 25.0 + lift);
|
||||
let big_centre = point(0.0, 0.0);
|
||||
let small_centre = point(0.0, 0.0);
|
||||
sk.add_line_pts(top_left, top_right, None).unwrap();
|
||||
sk.add_line_pts(top_right, shoulder, None).unwrap();
|
||||
sk.add_arc(v(0.0, 0.0), sk.points[knee].pos, sk.points[shoulder].pos, [Some(big_centre), Some(knee), Some(shoulder)], None).unwrap();
|
||||
sk.add_line_pts(right_top, knee, None).unwrap();
|
||||
sk.add_line_pts(right_bottom, right_top, None).unwrap();
|
||||
sk.add_line_pts(step_bottom, right_bottom, None).unwrap();
|
||||
sk.add_line_pts(step_top, step_bottom, None).unwrap();
|
||||
sk.add_arc(v(0.0, 0.0), sk.points[arc_start].pos, sk.points[arc_end].pos, [Some(small_centre), Some(arc_start), Some(arc_end)], None).unwrap();
|
||||
sk.add_line_pts(ledge_left, ledge_right, None).unwrap();
|
||||
sk.add_line_pts(top_left, ledge_left, None).unwrap();
|
||||
sk
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_join_line_and_arc_ends_that_lie_on_each_other_at_a_tangent_joint() {
|
||||
let profiles = find_profiles(&tangent_joint_end_plate(0.0));
|
||||
assert_eq!(profiles.len(), 1, "{profiles:?}");
|
||||
assert!(profiles[0].region.outer.is_closed(1e-9), "{:?}", profiles[0].region.outer.segs);
|
||||
assert_eq!(profiles[0].outer_curves.len(), 10, "{:?}", profiles[0].outer_curves);
|
||||
// The polygon through the corners, plus the segment the 50 mm arc adds, less the quarter
|
||||
// disc's segment the 25 mm arc takes away.
|
||||
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
|
||||
let corners = [
|
||||
(25.0, -11.125),
|
||||
(90.0, -11.125),
|
||||
(90.0, 11.125),
|
||||
(knee, 11.125),
|
||||
(14.0, 48.0),
|
||||
(14.0, 90.0),
|
||||
(-14.0, 90.0),
|
||||
(-14.0, 25.0),
|
||||
(0.0, 25.0),
|
||||
(25.0, 0.0),
|
||||
];
|
||||
let polygon = (0..corners.len()).map(|i| {
|
||||
let ((x0, y0), (x1, y1)) = (corners[i], corners[(i + 1) % corners.len()]);
|
||||
x0 * y1 - x1 * y0
|
||||
});
|
||||
let segment = |r: f64, sweep: f64| r * r / 2.0 * (sweep - sweep.sin());
|
||||
let area = polygon.sum::<f64>() / 2.0 + segment(50.0, 48.0_f64.atan2(14.0) - 11.125_f64.atan2(knee)) - segment(25.0, FRAC_PI_2);
|
||||
assert!((profiles[0].area - area).abs() < 1e-6, "{} vs {area}", profiles[0].area);
|
||||
|
||||
// Raised 1e-5 mm, the line's end is off the arc's circle and the arc's end off the line:
|
||||
// the 2.5e-4 mm gap is a real one and the outline stays open.
|
||||
let open = find_profiles(&tangent_joint_end_plate(1e-5));
|
||||
assert!(open.is_empty(), "{open:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_and_references() {
|
||||
let mut sk = Sketch::new();
|
||||
|
||||
@@ -88,7 +88,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
f.proto_vertex_miss_is_false = true;
|
||||
f.check_loop_vertex_tolerance = true;
|
||||
f.check_loop_projected_seam_ends = true;
|
||||
f.trim_boundary_seam_wrap = true;
|
||||
f.bspline_check_min_radius = true;
|
||||
f.oscillating_skips_failed_guesses = true;
|
||||
f.refine_cc_range_ends = true;
|
||||
@@ -104,7 +103,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
f.offset_walls_touch_refused = true;
|
||||
f.offset_analytic_mate_edges = true;
|
||||
f.nurb_cylinder_inside_out_frame = true;
|
||||
f.shell_inner_shell_of_statuses = true;
|
||||
f.local_operation_refuses_loose_tangent_edges = true;
|
||||
f.torus_major_radius_from_origin = true;
|
||||
f.fillet_corner_sphere_from_four_arcs = true;
|
||||
@@ -159,7 +157,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
f.found_torus_sphere_keep_tested_nurb = true;
|
||||
f.boolean_skips_faces_apart = true;
|
||||
f.uv_trim_checks_on_surface = true;
|
||||
f.loop_orientation_floor_from_loop_box = true;
|
||||
f.local_operation_box_holds_new_surfaces = true;
|
||||
f.uv_trim_joins_drop_pieces = true;
|
||||
f.shell_edges_on_analytics_exact = true;
|
||||
@@ -197,7 +194,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
f.loops_go_on_round_the_vertex = true;
|
||||
f.revolve_splits_curves_on_axis = true;
|
||||
f.cover_wraps_across_closed_seam = true;
|
||||
f.imprint_extension_keeps_analytic_curve = true;
|
||||
f.fillet_trace_from_tangent_point_toward_next = true;
|
||||
f.fillet_tangent_point_one_sided_step = true;
|
||||
f.fillet_rails_meet_at_one_end_point = true;
|
||||
@@ -306,7 +302,6 @@ pub(crate) fn fixes() -> UpstreamFixes {
|
||||
f.curve_sweep_closed_path_joins_ends = true;
|
||||
f.validate_tolerances_fixes_reported_gaps = true;
|
||||
f.edgeuse_convexity_propagates_step_off_failure = true;
|
||||
f.is_iso_curve_at_propagates_canonical_failure = true;
|
||||
f.database_io_byte_swaps_read_boolean_and_u16 = true;
|
||||
f.is_topological_vertex_rejects_cusps_and_tangent_errors = true;
|
||||
f.cone_create_canonical_preserves_inside_out_and_checks_height = true;
|
||||
|
||||
@@ -79,6 +79,7 @@ pub(super) fn status(e: SolidError) -> Error {
|
||||
e if is_b_spline_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
|
||||
e if is_intersect_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
|
||||
e if is_primitive_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
|
||||
e if is_mass_heal_or_geom_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
|
||||
SolidError::InvalidInput { .. } => Error::Status(INVALID_INPUT),
|
||||
SolidError::Unsupported { reason, .. } if reason == MIXED_CORNER_RAILS_CROSS => Error::Unported(MIXED_CORNER_UNSUPPORTED),
|
||||
e if is_boolean_failure(&e) => boolean_status(e),
|
||||
@@ -126,6 +127,13 @@ fn is_primitive_failure(e: &SolidError) -> bool {
|
||||
e.primitive_failure().is_some()
|
||||
}
|
||||
|
||||
/// A mass-property, healing or geometry-utility failure (solidcore's `MassFailure`,
|
||||
/// `HealFailure`, `GeomFailure`): it stands for the status the routine returned (`Upstream`
|
||||
/// before solidcore described it), and passes on as that status.
|
||||
fn is_mass_heal_or_geom_failure(e: &SolidError) -> bool {
|
||||
e.mass_failure().is_some() || e.heal_failure().is_some() || e.geom_failure().is_some()
|
||||
}
|
||||
|
||||
/// `size_ok`.
|
||||
fn size_ok(x: f64) -> bool {
|
||||
x.is_finite() && x > 0.0 && x <= 1e6
|
||||
|
||||
@@ -562,4 +562,18 @@ fn a_boolean_failure_from_another_operation_keeps_its_status() {
|
||||
assert!(matches!(status(primitive(PrimitiveFailure::AxisInsideFace)), Error::Status(1025)));
|
||||
let helix = PrimitiveFailure::HelixDeviates { deviation: 0.5, tolerance: 0.25 };
|
||||
assert!(matches!(status(primitive(helix)), Error::Status(1021)));
|
||||
// a mass-property, healing or geometry-utility failure passes on as the status it replaced
|
||||
use solidcore::error::geom::GeomFailure;
|
||||
use solidcore::error::heal::HealFailure;
|
||||
use solidcore::error::mass::MassFailure;
|
||||
let mass = |f| Failure::new("mass::integrator", f).into_error();
|
||||
assert!(matches!(status(mass(MassFailure::StepControl)), Error::Status(1001)));
|
||||
assert!(matches!(status(mass(MassFailure::Missing("the edge's curve"))), Error::Status(1006)));
|
||||
let heal = |f| Failure::new("heal::stitch::p1", f).into_error();
|
||||
assert!(matches!(status(heal(HealFailure::Failed("the edges could not be glued"))), Error::Status(1001)));
|
||||
assert!(matches!(status(heal(HealFailure::NotYetHealable)), Error::Status(1040)));
|
||||
let geom = |f| Failure::new("geom::matrix", f).into_error();
|
||||
assert!(matches!(status(geom(GeomFailure::Failed("the matrix is singular"))), Error::Status(1001)));
|
||||
assert!(matches!(status(geom(GeomFailure::CoincidentPoints)), Error::Status(0)));
|
||||
assert!(matches!(status(geom(GeomFailure::ToleranceTooSmall { tolerance: 0.0 })), Error::Status(1007)));
|
||||
}
|
||||
|
||||
@@ -2274,7 +2274,8 @@ fn flat_to_brep_data(k: &Kernel, f: &In<'_>, s: &Shape) -> Result<BrepData> {
|
||||
i_cone += usize::from(ty == SURFACE_CONE_I);
|
||||
i_torus += usize::from(ty == SURFACE_TORUS_I);
|
||||
let nurbs = bspline_surface_3d(f, &mut nurbs_cursor)?;
|
||||
analytic_of_nurbs(k, &nurbs, ty, xsect)?.ok_or_else(|| bad("a NURBS form that is not its analytic surface"))?
|
||||
analytic_of_nurbs(k, &nurbs, ty, xsect)?
|
||||
.ok_or_else(|| Error::Solidcore(format!("flat arrays: face {i}: a NURBS form that is not its analytic surface")))?
|
||||
} else if ty == SURFACE_SPHERE_I {
|
||||
let j = i_sphere;
|
||||
i_sphere += 1;
|
||||
|
||||
@@ -1467,20 +1467,30 @@ fn cylinder_piece(k: &mut Kernel, b: &mut Brep, m: &Screw, a: Point3, e: Point3,
|
||||
})
|
||||
}
|
||||
|
||||
/// `screw_wall`: the wall one profile curve sweeps over `st`: across the curve its NURBS, across
|
||||
/// the turns each control point's path, the weights the curve's times the arcs'.
|
||||
fn screw_wall(m: &Screw, c: &CurveCanonical, st: &Stations) -> Out<Surface> {
|
||||
let across = 2 * st.spans() + 1;
|
||||
/// `screw_wall`: the wall one profile curve sweeps over consecutive station pieces: across the
|
||||
/// curve its NURBS, across the turns each control point's path. Cylinder seams split the pieces,
|
||||
/// not this wall: join their existing nets at the shared endpoint, without refitting the spans.
|
||||
fn screw_wall(m: &Screw, c: &CurveCanonical, pieces: &[Stations]) -> Out<Surface> {
|
||||
let across = 2 * pieces.iter().map(Stations::spans).sum::<usize>() + 1;
|
||||
let mut net = Vec::with_capacity(c.control_points.len() * across);
|
||||
let mut weights = Vec::with_capacity(net.capacity());
|
||||
for (l, &p) in c.control_points.iter().enumerate() {
|
||||
let w = c.weights.get(l).copied().unwrap_or(1.0);
|
||||
for (j, q) in screw_path(m, p, st).into_iter().enumerate() {
|
||||
net.push(q);
|
||||
weights.push(w * st.weight(j));
|
||||
for (i, st) in pieces.iter().enumerate() {
|
||||
for (j, q) in screw_path(m, p, st).into_iter().enumerate().skip(usize::from(i > 0)) {
|
||||
net.push(q);
|
||||
weights.push(w * st.weight(j));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (vknots, vmult) = st.knots();
|
||||
let vknots: Vec<f64> = pieces.iter().enumerate().flat_map(|(i, st)| st.t.iter().copied().skip(usize::from(i > 0))).collect();
|
||||
let mut vmult = vec![2; vknots.len()];
|
||||
if let Some(first) = vmult.first_mut() {
|
||||
*first = 3;
|
||||
}
|
||||
if let Some(last) = vmult.last_mut() {
|
||||
*last = 3;
|
||||
}
|
||||
let data = CanonicalSurfaceData {
|
||||
u_degree: c.degree,
|
||||
v_degree: 2,
|
||||
@@ -1531,7 +1541,7 @@ fn screw_placed(m: &Screw, c: &Profile, a: f64) -> Profile {
|
||||
/// by span (SM_API's curve sweep carries the profile on its own frame, which along a helix turns
|
||||
/// it away from the screw motion), lines parallel to the axis as exact cylinder pieces, the caps
|
||||
/// where the motion starts and ends, all stitched and oriented.
|
||||
fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, tolerance: f64) -> solidcore::Result<Out<Brep>> {
|
||||
fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, tolerance: f64, join_spline_walls: bool) -> solidcore::Result<Out<Brep>> {
|
||||
let curves = match parse(k, profile) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
@@ -1576,7 +1586,8 @@ fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, toleranc
|
||||
}
|
||||
}
|
||||
// Walls of lines parallel to the axis are exact cylinders, which cannot turn on themselves:
|
||||
// then every wall comes in pieces of half a turn (the last up to an eighth longer).
|
||||
// their faces come in pieces of half a turn (the last up to an eighth longer). All walls use
|
||||
// these stations so their shared boundary curves agree, but spline walls keep one face.
|
||||
let mut radius = Vec::with_capacity(curves.len());
|
||||
for c in &curves {
|
||||
radius.push(axial_radius(k, &m, c, tolerance)?);
|
||||
@@ -1622,17 +1633,22 @@ fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, toleranc
|
||||
pieces.push(Stations::new(from, to, n as usize));
|
||||
}
|
||||
let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
for st in &pieces {
|
||||
// A radial law's knots can change the wall's tangent, so retain those face boundaries. Half
|
||||
// turns are only cylinder seams: join the other walls' spans when explicitly requested.
|
||||
for group in pieces.chunk_by(|a, _| join_spline_walls && !m.angles.contains(&a.last())) {
|
||||
for (i, (c, wall)) in curves.iter().zip(&walls).enumerate() {
|
||||
let r = radius.get(i).copied().unwrap_or(0.0);
|
||||
if r > 0.0 && m.offset(st.first()) == m.offset(st.last()) {
|
||||
let (Some(first), Some(last)) = (group.first(), group.last()) else { continue };
|
||||
if r > 0.0 && m.offset(first.first()) == m.offset(last.last()) {
|
||||
let (a, e) = ends(k, c)?;
|
||||
if let Err(err) = cylinder_piece(k, &mut b, &m, a, e, r + m.offset(st.first()), st)? {
|
||||
return Ok(Err(err));
|
||||
for st in group {
|
||||
if let Err(err) = cylinder_piece(k, &mut b, &m, a, e, r + m.offset(st.first()), st)? {
|
||||
return Ok(Err(err));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let surface = match screw_wall(&m, wall, st) {
|
||||
let surface = match screw_wall(&m, wall, group) {
|
||||
Ok(s) => s,
|
||||
Err(err) => return Ok(Err(err)),
|
||||
};
|
||||
@@ -1671,6 +1687,7 @@ pub(crate) fn screw_sweep(
|
||||
turns: f64,
|
||||
law: Option<&[(f64, f64)]>,
|
||||
tolerance: f64,
|
||||
join_spline_walls: bool,
|
||||
) -> Result<Body> {
|
||||
if !screw_ok(origin, pitch, turns, tolerance) {
|
||||
return Err(Error::Argument);
|
||||
@@ -1698,7 +1715,7 @@ pub(crate) fn screw_sweep(
|
||||
angles: law.iter().map(|(t, _)| TAU * t).collect(),
|
||||
offsets: law.iter().map(|(_, o)| *o).collect(),
|
||||
};
|
||||
screw(k, profile, m, turns, tolerance)
|
||||
screw(k, profile, m, turns, tolerance, join_spline_walls)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -299,8 +299,8 @@ fn on_core(op: &Op) -> crate::Result<Body> {
|
||||
Op::Loft(s, d, cap) => loft(s, *d, *cap),
|
||||
Op::LoftToPoint(s, apex) => loft_to_point(s, *apex),
|
||||
Op::Stations(p, s, e) => sweep_stations(p, s, *e),
|
||||
Op::Screw(p, o, a, pitch, turns, tol) => screw_sweep(p, *o, *a, *pitch, *turns, None, *tol),
|
||||
Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => screw_sweep(p, *o, *a, *pitch, *turns, Some(law), *tol),
|
||||
Op::Screw(p, o, a, pitch, turns, tol) => screw_sweep(p, *o, *a, *pitch, *turns, None, *tol, false),
|
||||
Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => screw_sweep(p, *o, *a, *pitch, *turns, Some(law), *tol, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,10 +610,14 @@ fn hostile_arguments_are_refused() {
|
||||
let flat = Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], t: [0.0; 3] };
|
||||
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3]), flat], Ends::Capped).err(), Some(Error::Argument), "a singular map");
|
||||
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3])], Ends::Capped).err(), Some(Error::Argument), "one station");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 0.0, 1.0, None, 1e-3).err(), Some(Error::Argument), "no pitch");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 300.0, None, 1e-3).err(), Some(Error::Argument), "too many turns");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[(0.5, 0.0), (0.2, 0.0)]), 1e-3).err(), Some(Error::Argument), "a law going back");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[]), 1e-3).err(), Some(Error::Argument), "an empty law");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 0.0, 1.0, None, 1e-3, false).err(), Some(Error::Argument), "no pitch");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 300.0, None, 1e-3, false).err(), Some(Error::Argument), "too many turns");
|
||||
assert_eq!(
|
||||
screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[(0.5, 0.0), (0.2, 0.0)]), 1e-3, false).err(),
|
||||
Some(Error::Argument),
|
||||
"a law going back"
|
||||
);
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[]), 1e-3, false).err(), Some(Error::Argument), "an empty law");
|
||||
let b = make_box([0.0; 3], [1.0; 3]).unwrap();
|
||||
assert_eq!(project_curves(&b, [0.5; 3], 0.01, &sq, [0.0; 3], 1e-6).err(), Some(Error::Argument), "no direction");
|
||||
assert_eq!(project_curves(&b, [0.5; 3], 0.0, &sq, Z, 1e-6).err(), Some(Error::Argument), "no pick tolerance");
|
||||
|
||||
@@ -11,6 +11,17 @@ use serde_json::json;
|
||||
use crate::trace;
|
||||
use crate::{Brep, Result, core};
|
||||
|
||||
/// Screw-sweep topology and its approximation tolerance.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScrewSweepOptions<'a> {
|
||||
/// Optional radial law as `(turn, offset)` pairs.
|
||||
pub law: Option<&'a [(f64, f64)]>,
|
||||
/// Join spline walls across cylinder seams; false retains the historical faces.
|
||||
pub join_spline_walls: bool,
|
||||
/// Maximum wall approximation error, mm.
|
||||
pub tolerance: f64,
|
||||
}
|
||||
|
||||
/// A 3D curve of a profile, path or section.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Curve {
|
||||
@@ -247,7 +258,7 @@ impl Brep {
|
||||
/// `turns` right-handed turns about it, rising `pitch` (signed) per turn, capped at both ends,
|
||||
/// its walls within about `tolerance` of the true screw surfaces.
|
||||
pub fn screw_sweep(profile: &[Curve], origin: [f64; 3], direction: [f64; 3], pitch: f64, turns: f64, tolerance: f64) -> Result<Brep> {
|
||||
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, None, tolerance).map(Brep::made_core);
|
||||
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, None, tolerance, false).map(Brep::made_core);
|
||||
trace::made(
|
||||
"screw_sweep",
|
||||
&[],
|
||||
@@ -272,13 +283,35 @@ impl Brep {
|
||||
law: &[(f64, f64)],
|
||||
tolerance: f64,
|
||||
) -> Result<Brep> {
|
||||
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, Some(law), tolerance).map(Brep::made_core);
|
||||
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, Some(law), tolerance, false).map(Brep::made_core);
|
||||
trace::made("screw_sweep", &[], &r, || {
|
||||
json!({"profile": trace::curves(profile), "origin": origin, "direction": direction, "pitch": pitch, "turns": turns,
|
||||
"law": law, "tolerance": tolerance})
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
/// A screw sweep with explicit face layout. The historical entry points retain
|
||||
/// half-turn faces; new thread features request continuous spline walls here.
|
||||
pub fn screw_sweep_with_options(
|
||||
profile: &[Curve],
|
||||
origin: [f64; 3],
|
||||
direction: [f64; 3],
|
||||
pitch: f64,
|
||||
turns: f64,
|
||||
options: ScrewSweepOptions<'_>,
|
||||
) -> Result<Brep> {
|
||||
let r = core::sweep::screw_sweep(profile, origin, direction, pitch, turns, options.law, options.tolerance, options.join_spline_walls)
|
||||
.map(Brep::made_core);
|
||||
trace::made("screw_sweep", &[], &r, || {
|
||||
json!({
|
||||
"profile": trace::curves(profile), "origin": origin, "direction": direction,
|
||||
"pitch": pitch, "turns": turns, "law": options.law,
|
||||
"tolerance": options.tolerance, "join_spline_walls": options.join_spline_walls,
|
||||
})
|
||||
});
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep frame's name in a trace.
|
||||
|
||||
@@ -96,9 +96,41 @@ fn sweeps_lofts_and_screws() {
|
||||
let tooth = polygon(&[[4.0, 0.0, 0.0], [4.4, 0.0, 0.0], [4.4, 0.0, 0.4], [4.0, 0.0, 0.4]]);
|
||||
let b = Brep::screw_sweep(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, 1e-4).unwrap();
|
||||
assert_close(solid_volume(&b, "screw"), 0.16 * TAU * 4.2 * 3.0, 1e-4, "screw sweep");
|
||||
let historical = b.to_native().unwrap();
|
||||
assert!(historical.faces.iter().filter(|f| matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. })).count() > 2);
|
||||
let options = crate::sweeps::ScrewSweepOptions { law: None, join_spline_walls: true, tolerance: 1e-4 };
|
||||
let b = Brep::screw_sweep_with_options(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, options).unwrap();
|
||||
let record = b.to_native().unwrap();
|
||||
let flanks = record.faces.iter().filter(|f| matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. })).count();
|
||||
assert_eq!(flanks, 2, "each screw flank must remain one face across the cylinder seams");
|
||||
// The tooth moved out 1 mm over its three turns, linearly: A 2 pi (c turns + int offset dt).
|
||||
let b = Brep::screw_sweep_radial(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, &[(0.0, 0.0), (3.0, 1.0)], 1e-4).unwrap();
|
||||
let b = Brep::screw_sweep_with_options(
|
||||
&tooth,
|
||||
[0.0; 3],
|
||||
[0.0, 0.0, 1.0],
|
||||
1.0,
|
||||
3.0,
|
||||
crate::sweeps::ScrewSweepOptions { law: Some(&[(0.0, 0.0), (3.0, 1.0)]), join_spline_walls: true, tolerance: 1e-4 },
|
||||
)
|
||||
.unwrap();
|
||||
assert_close(solid_volume(&b, "radial screw"), 0.16 * TAU * (4.2 * 3.0 + 1.5), 1e-4, "radial screw sweep");
|
||||
assert_eq!(b.to_native().unwrap().faces.len(), 6, "a linear radial law needs four continuous walls and two caps");
|
||||
// Two constant intervals around a sloping interval, with a partial last turn: keep the
|
||||
// law's corners as face boundaries, while joining the half-turn pieces within each interval.
|
||||
let law = [(0.0, 0.0), (0.65, 0.0), (1.9, 0.4), (3.2, 0.4)];
|
||||
let b = Brep::screw_sweep_with_options(
|
||||
&tooth,
|
||||
[0.0; 3],
|
||||
[0.0, 0.0, 1.0],
|
||||
1.0,
|
||||
3.2,
|
||||
crate::sweeps::ScrewSweepOptions { law: Some(&law), join_spline_walls: true, tolerance: 1e-4 },
|
||||
)
|
||||
.unwrap();
|
||||
assert!(b.check(CheckLevel::All).unwrap().is_valid());
|
||||
let record = b.to_native().unwrap();
|
||||
let splines = record.faces.iter().filter(|f| matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. })).count();
|
||||
assert_eq!(splines, 8, "two flanks across three law intervals and two sloping radial walls");
|
||||
assert_eq!(Brep::screw_sweep_radial(&tooth, [0.0; 3], [0.0, 0.0, 1.0], 1.0, 3.0, &[(2.0, 0.0), (1.0, 1.0)], 1e-4).err(), Some(Error::Argument));
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ const NO_KERNEL: [&str; 14] = [
|
||||
"hold_a_kernel",
|
||||
];
|
||||
|
||||
const ANSWERING: [&str; 63] = [
|
||||
const ANSWERING: [&str; 64] = [
|
||||
"set_dump_dir",
|
||||
"current_call",
|
||||
"crash_test",
|
||||
@@ -159,6 +159,7 @@ const ANSWERING: [&str; 63] = [
|
||||
"loft_to_point",
|
||||
"screw_sweep",
|
||||
"screw_sweep_radial",
|
||||
"screw_sweep_with_options",
|
||||
];
|
||||
|
||||
/// Every `pub fn` of the files that hold the crate's kernel entry points is accounted for.
|
||||
@@ -278,6 +279,17 @@ fn every_entry_point_answers() {
|
||||
says("loft_to_point", Brep::loft_to_point(&lines, p(5.0, 5.0, 12.0)));
|
||||
says("screw_sweep", Brep::screw_sweep(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, 0.01));
|
||||
says("screw_sweep_radial", Brep::screw_sweep_radial(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, &[(0.0, 0.0), (1.0, 0.0)], 0.01));
|
||||
says(
|
||||
"screw_sweep_with_options",
|
||||
Brep::screw_sweep_with_options(
|
||||
&lines,
|
||||
p(30.0, 0.0, 0.0),
|
||||
p(0.0, 0.0, 1.0),
|
||||
12.0,
|
||||
1.0,
|
||||
crate::sweeps::ScrewSweepOptions { law: None, join_spline_walls: true, tolerance: 0.01 },
|
||||
),
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "solidcraft-step"
|
||||
description = "SolidCraft STEP (ISO 10303) exchange: the Part 21 exchange structure and Part 42 geometry as plain data, with no modelling backend"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcore-ops = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-profile = { workspace = true }
|
||||
@@ -1,18 +0,0 @@
|
||||
//! STEP (ISO 10303) for SolidCraft, independent of any modelling backend. [`p21`] reads the
|
||||
//! exchange structure, [`read`] turns its geometry entities into the plain curves and surfaces
|
||||
//! of [`geom`], and [`write`] writes them back. Written from the public ISO 10303-21 and -42
|
||||
//! specifications. [`topology`] reads solids (vertices to shells) into the kernel-neutral B-rep,
|
||||
//! schema 4, and [`units`] the units of a representation context.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod eval;
|
||||
pub mod geom;
|
||||
pub mod p21;
|
||||
pub mod read;
|
||||
pub mod topology;
|
||||
pub mod units;
|
||||
pub mod write;
|
||||
|
||||
#[cfg(test)]
|
||||
mod geom_tests;
|
||||
@@ -1,735 +0,0 @@
|
||||
//! ISO 10303-21 ("Part 21") exchange structure parser: the HEADER and the DATA section's entity
|
||||
//! instances, simple (`#1=NAME(...)`) and complex (`#2=(A(...) B(...))`). Written from the
|
||||
//! public standard. Input is hostile: sizes, entity counts and nesting depth are capped and every
|
||||
//! error is a message, never a panic.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Largest STEP text we parse.
|
||||
pub const MAX_BYTES: usize = 512 << 20;
|
||||
/// Most entity instances in one file.
|
||||
pub const MAX_ENTITIES: usize = 20_000_000;
|
||||
/// Deepest parameter list nesting.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// A parameter value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Param {
|
||||
Int(i64),
|
||||
Real(f64),
|
||||
Str(String),
|
||||
/// `.NAME.` (`.T.`/`.F.` included).
|
||||
Enum(String),
|
||||
Ref(u64),
|
||||
List(Vec<Param>),
|
||||
/// `NAME(params)` (a typed value such as `LENGTH_MEASURE(1.)`).
|
||||
Typed(String, Vec<Param>),
|
||||
Binary(String),
|
||||
/// `$`
|
||||
Null,
|
||||
/// `*`
|
||||
Derived,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
Param::Real(x) => Some(*x).filter(|x| x.is_finite()),
|
||||
Param::Int(i) => Some(*i as f64),
|
||||
Param::Typed(_, v) if v.len() == 1 => v.first().and_then(Param::as_f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
Param::Int(i) => Some(*i),
|
||||
Param::Real(x) if x.fract() == 0.0 && x.abs() < 9e15 => Some(*x as i64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_ref_id(&self) -> Option<u64> {
|
||||
match self {
|
||||
Param::Ref(r) => Some(*r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Param::Str(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_enum(&self) -> Option<&str> {
|
||||
match self {
|
||||
Param::Enum(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `.T.` → true, `.F.` → false (`.U.` and anything else → None).
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self.as_enum()? {
|
||||
"T" => Some(true),
|
||||
"F" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_list(&self) -> Option<&[Param]> {
|
||||
match self {
|
||||
Param::List(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One record of an entity instance: a simple instance has one, a complex one several.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Record {
|
||||
pub name: String,
|
||||
pub params: Vec<Param>,
|
||||
}
|
||||
|
||||
/// An entity instance.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entity {
|
||||
pub records: Vec<Record>,
|
||||
}
|
||||
|
||||
impl Entity {
|
||||
pub fn is_complex(&self) -> bool {
|
||||
self.records.len() > 1
|
||||
}
|
||||
/// Name of a simple instance (the first record of a complex one).
|
||||
pub fn name(&self) -> &str {
|
||||
self.records.first().map(|r| r.name.as_str()).unwrap_or("")
|
||||
}
|
||||
/// Parameters of the record called `name`.
|
||||
pub fn record(&self, name: &str) -> Option<&[Param]> {
|
||||
self.records.iter().find(|r| r.name == name).map(|r| r.params.as_slice())
|
||||
}
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.records.iter().any(|r| r.name == name)
|
||||
}
|
||||
/// Parameters of a simple instance.
|
||||
pub fn params(&self) -> &[Param] {
|
||||
self.records.first().map(|r| r.params.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed exchange file.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Exchange {
|
||||
/// FILE_SCHEMA names.
|
||||
pub schemas: Vec<String>,
|
||||
/// FILE_NAME originating system.
|
||||
pub originating_system: String,
|
||||
/// FILE_NAME preprocessor version.
|
||||
pub preprocessor: String,
|
||||
/// FILE_DESCRIPTION text, its lines joined by a space.
|
||||
pub description: String,
|
||||
pub entities: HashMap<u64, Entity>,
|
||||
/// Ids defined more than once (the first definition is kept).
|
||||
pub duplicates: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Exchange {
|
||||
pub fn get(&self, id: u64) -> Option<&Entity> {
|
||||
self.entities.get(&id)
|
||||
}
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
/// The last place `line` counted to and the newlines before it, so errors met one after
|
||||
/// another while skipping bad header records count each byte once.
|
||||
counted: Cell<(usize, usize)>,
|
||||
}
|
||||
|
||||
fn err<T>(msg: impl Into<String>) -> Result<T, String> {
|
||||
Err(msg.into())
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
fn line(&self) -> usize {
|
||||
let (at, before) = self.counted.get();
|
||||
let (from, before) = if self.i >= at { (at, before) } else { (0, 0) };
|
||||
let Some(b) = self.s.get(from..self.i) else { return 0 };
|
||||
let newlines = before + b.iter().filter(|c| **c == b'\n').count();
|
||||
self.counted.set((self.i, newlines));
|
||||
newlines + 1
|
||||
}
|
||||
fn fail<T>(&self, msg: &str) -> Result<T, String> {
|
||||
err(format!("STEP syntax error at line {}: {msg}", self.line()))
|
||||
}
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.s.get(self.i).copied()
|
||||
}
|
||||
/// Skip whitespace and comments.
|
||||
fn ws(&mut self) -> Result<(), String> {
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some(c) if c.is_ascii_whitespace() => self.i += 1,
|
||||
Some(b'/') if self.s.get(self.i + 1) == Some(&b'*') => {
|
||||
let rest = self.s.get(self.i + 2..).unwrap_or(&[]);
|
||||
match rest.windows(2).position(|w| w == b"*/") {
|
||||
Some(p) => self.i += p + 4,
|
||||
None => return self.fail("unterminated comment"),
|
||||
}
|
||||
}
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn eat(&mut self, c: u8) -> Result<bool, String> {
|
||||
self.ws()?;
|
||||
if self.peek() == Some(c) {
|
||||
self.i += 1;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
fn expect(&mut self, c: u8) -> Result<(), String> {
|
||||
if self.eat(c)? { Ok(()) } else { self.fail(&format!("expected `{}`", c as char)) }
|
||||
}
|
||||
/// Where the next line after `from` that starts with a keyword begins.
|
||||
fn next_header_line(&self, from: usize) -> Option<usize> {
|
||||
let rest = self.s.get(from..)?;
|
||||
let mut at = rest.iter().position(|&c| c == b'\n')? + 1;
|
||||
loop {
|
||||
let line = at + rest.get(at..)?.iter().take_while(|c| matches!(c, b' ' | b'\t' | b'\r')).count();
|
||||
if rest.get(line)?.is_ascii_uppercase() {
|
||||
return Some(from + line);
|
||||
}
|
||||
at += rest.get(at..)?.iter().position(|&c| c == b'\n')? + 1;
|
||||
}
|
||||
}
|
||||
fn keyword(&mut self) -> Result<String, String> {
|
||||
self.ws()?;
|
||||
let st = self.i;
|
||||
if self.peek() == Some(b'!') {
|
||||
self.i += 1;
|
||||
}
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_ascii_alphanumeric() || c == b'_' || c == b'-' {
|
||||
self.i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.i == st {
|
||||
return self.fail("expected a keyword");
|
||||
}
|
||||
Ok(String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).to_ascii_uppercase())
|
||||
}
|
||||
fn number(&mut self) -> Result<Param, String> {
|
||||
let st = self.i;
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.' | b'E' | b'e') {
|
||||
self.i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
|
||||
if !t.contains(['.', 'E', 'e'])
|
||||
&& let Ok(i) = t.parse::<i64>()
|
||||
{
|
||||
return Ok(Param::Int(i));
|
||||
}
|
||||
// STEP allows `1.` and `1.E-3`.
|
||||
let fixed = t.replace(".E", ".0E").replace(".e", ".0e");
|
||||
let fixed = if fixed.ends_with('.') { format!("{fixed}0") } else { fixed };
|
||||
match fixed.parse::<f64>() {
|
||||
Ok(x) => Ok(Param::Real(x)),
|
||||
Err(_) => self.fail(&format!("bad number `{t}`")),
|
||||
}
|
||||
}
|
||||
fn string(&mut self) -> Result<String, String> {
|
||||
// Opening quote consumed by the caller.
|
||||
let mut raw = Vec::new();
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return self.fail("unterminated string"),
|
||||
Some(b'\'') => {
|
||||
if self.s.get(self.i + 1) == Some(&b'\'') {
|
||||
raw.push(b'\'');
|
||||
self.i += 2;
|
||||
} else {
|
||||
self.i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(b'\n') | Some(b'\r') => self.i += 1,
|
||||
Some(c) => {
|
||||
raw.push(c);
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(decode_string(&raw))
|
||||
}
|
||||
fn param(&mut self, depth: usize) -> Result<Param, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return self.fail("parameters nested too deeply");
|
||||
}
|
||||
self.ws()?;
|
||||
let Some(c) = self.peek() else { return self.fail("unexpected end of file") };
|
||||
match c {
|
||||
b'$' => {
|
||||
self.i += 1;
|
||||
Ok(Param::Null)
|
||||
}
|
||||
b'*' => {
|
||||
self.i += 1;
|
||||
Ok(Param::Derived)
|
||||
}
|
||||
b'\'' => {
|
||||
self.i += 1;
|
||||
Ok(Param::Str(self.string()?))
|
||||
}
|
||||
b'"' => {
|
||||
self.i += 1;
|
||||
let st = self.i;
|
||||
while self.peek().is_some_and(|c| c != b'"') {
|
||||
self.i += 1;
|
||||
}
|
||||
let v = String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).to_string();
|
||||
self.expect(b'"')?;
|
||||
Ok(Param::Binary(v))
|
||||
}
|
||||
b'#' => {
|
||||
self.i += 1;
|
||||
let st = self.i;
|
||||
while self.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = std::str::from_utf8(self.s.get(st..self.i).unwrap_or(&[])).unwrap_or("");
|
||||
t.parse::<u64>().map(Param::Ref).or_else(|_| self.fail("bad entity reference"))
|
||||
}
|
||||
b'.' if self.s.get(self.i + 1).is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') => {
|
||||
self.i += 1;
|
||||
let st = self.i;
|
||||
while self.peek().is_some_and(|c| c != b'.') {
|
||||
self.i += 1;
|
||||
}
|
||||
let v = String::from_utf8_lossy(self.s.get(st..self.i).unwrap_or(&[])).trim().to_ascii_uppercase();
|
||||
self.expect(b'.')?;
|
||||
Ok(Param::Enum(v))
|
||||
}
|
||||
b'(' => {
|
||||
self.i += 1;
|
||||
Ok(Param::List(self.list_rest(depth + 1)?))
|
||||
}
|
||||
c if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') => self.number(),
|
||||
c if c.is_ascii_alphabetic() || c == b'!' => {
|
||||
let name = self.keyword()?;
|
||||
self.expect(b'(')?;
|
||||
Ok(Param::Typed(name, self.list_rest(depth + 1)?))
|
||||
}
|
||||
_ => self.fail(&format!("unexpected `{}`", c as char)),
|
||||
}
|
||||
}
|
||||
/// Parameters after an opening `(` up to and including the `)`.
|
||||
fn list_rest(&mut self, depth: usize) -> Result<Vec<Param>, String> {
|
||||
let mut v = Vec::new();
|
||||
if self.eat(b')')? {
|
||||
return Ok(v);
|
||||
}
|
||||
loop {
|
||||
v.push(self.param(depth)?);
|
||||
if self.eat(b',')? {
|
||||
continue;
|
||||
}
|
||||
self.expect(b')')?;
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
fn record(&mut self) -> Result<Record, String> {
|
||||
let name = self.keyword()?;
|
||||
self.expect(b'(')?;
|
||||
Ok(Record { name, params: self.list_rest(1)? })
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the Part 21 string escapes (`\X\hh`, `\X2\…\X0\`, `\X4\…\X0\`, `\S\c`, `\\`).
|
||||
fn decode_string(raw: &[u8]) -> String {
|
||||
decode_string_with_end(raw, |rest| rest.windows(4).position(|w| w == b"\\X0\\"))
|
||||
}
|
||||
|
||||
/// The decoder with its terminator search supplied, so tests can bound search work directly.
|
||||
fn decode_string_with_end(raw: &[u8], mut find_end: impl FnMut(&[u8]) -> Option<usize>) -> String {
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
let mut no_more_run_ends = false;
|
||||
let hex = |b: &[u8]| std::str::from_utf8(b).ok().and_then(|t| u32::from_str_radix(t, 16).ok());
|
||||
while let Some(&c) = raw.get(i) {
|
||||
if c == b'\\' {
|
||||
let rest = raw.get(i..).unwrap_or(&[]);
|
||||
if rest.starts_with(b"\\\\") {
|
||||
out.push('\\');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with(b"\\X\\")
|
||||
&& let Some(v) = rest.get(3..5).and_then(hex)
|
||||
{
|
||||
out.push(char::from_u32(v).unwrap_or('?'));
|
||||
i += 5;
|
||||
continue;
|
||||
}
|
||||
let mut run = false;
|
||||
for (tag, width) in [(&b"\\X2\\"[..], 4usize), (&b"\\X4\\"[..], 8)] {
|
||||
if rest.starts_with(tag) && !no_more_run_ends {
|
||||
let Some(end) = find_end(rest) else {
|
||||
// No terminator exists anywhere in the remaining input. Keep the
|
||||
// existing literal recovery, but never scan this tail again. A
|
||||
// successful search consumes its run, so all searches together
|
||||
// inspect at most the input's length.
|
||||
no_more_run_ends = true;
|
||||
break;
|
||||
};
|
||||
let body = rest.get(4..end).unwrap_or(&[]);
|
||||
let units: Vec<u32> = body.chunks(width).filter_map(hex).collect();
|
||||
if width == 4 {
|
||||
let u16s: Vec<u16> = units.iter().map(|u| *u as u16).collect();
|
||||
out.push_str(&String::from_utf16_lossy(&u16s));
|
||||
} else {
|
||||
out.extend(units.iter().map(|u| char::from_u32(*u).unwrap_or('?')));
|
||||
}
|
||||
i += end + 4;
|
||||
run = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A run may be followed straight away by the backslash of the next escape (#2344).
|
||||
if run {
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with(b"\\S\\")
|
||||
&& let Some(&ch) = rest.get(3)
|
||||
{
|
||||
out.push(char::from_u32(ch as u32 + 128).unwrap_or('?'));
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
out.push('\\');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Plain bytes: keep UTF-8 runs intact.
|
||||
let st = i;
|
||||
while raw.get(i).is_some_and(|c| *c != b'\\') {
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&String::from_utf8_lossy(raw.get(st..i).unwrap_or(&[])));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a Part 21 file.
|
||||
/// Someone waiting for a long parse: told how far it is, and able to stop it.
|
||||
pub trait Progress {
|
||||
/// Stop at the next check.
|
||||
fn cancelled(&self) -> bool;
|
||||
/// `done` of `total` bytes read.
|
||||
fn progress(&self, stage: &'static str, done: usize, total: usize);
|
||||
}
|
||||
|
||||
/// Parse an exchange structure.
|
||||
pub fn parse(text: &str) -> Result<Exchange, String> {
|
||||
parse_with(text, None)
|
||||
}
|
||||
|
||||
/// [`parse`], reporting progress to `watch` and stopping when it cancels.
|
||||
pub fn parse_with(text: &str, watch: Option<&dyn Progress>) -> Result<Exchange, String> {
|
||||
if text.len() > MAX_BYTES {
|
||||
return err(format!("STEP file too large ({} MB, limit {} MB)", text.len() >> 20, MAX_BYTES >> 20));
|
||||
}
|
||||
let mut lx = Lexer { s: text.as_bytes(), i: 0, counted: Cell::new((0, 0)) };
|
||||
let magic = lx.keyword()?;
|
||||
if magic != "ISO-10303-21" {
|
||||
return err("not a STEP file (no ISO-10303-21 header)");
|
||||
}
|
||||
lx.expect(b';')?;
|
||||
let mut ex = Exchange::default();
|
||||
loop {
|
||||
lx.ws()?;
|
||||
if lx.peek().is_none() {
|
||||
return err("STEP file ends without END-ISO-10303-21");
|
||||
}
|
||||
let kw = lx.keyword()?;
|
||||
match kw.as_str() {
|
||||
"HEADER" => {
|
||||
lx.expect(b';')?;
|
||||
loop {
|
||||
// The header only describes the file, and other CAD systems read past a
|
||||
// record that does not parse (#11650): skip to the next line that starts
|
||||
// with a keyword. If there is none, the file is not readable.
|
||||
let start = lx.i;
|
||||
// A header without its `ENDSEC;` must not read the DATA section as
|
||||
// header records to skip.
|
||||
if lx.keyword().as_deref() == Ok("DATA") {
|
||||
return lx.fail("expected `ENDSEC;` before `DATA`");
|
||||
}
|
||||
lx.i = start;
|
||||
let record = (|| {
|
||||
let k = lx.keyword()?;
|
||||
if k == "ENDSEC" {
|
||||
lx.expect(b';')?;
|
||||
return Ok(None);
|
||||
}
|
||||
lx.expect(b'(')?;
|
||||
let params = lx.list_rest(1)?;
|
||||
lx.expect(b';')?;
|
||||
Ok(Some((k, params)))
|
||||
})();
|
||||
let (k, params) = match record {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let Some(next) = lx.next_header_line(start) else { return Err(e) };
|
||||
lx.i = next;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match k.as_str() {
|
||||
"FILE_SCHEMA" => {
|
||||
if let Some(Param::List(v)) = params.first() {
|
||||
ex.schemas = v.iter().filter_map(|p| p.as_str().map(str::to_string)).collect();
|
||||
}
|
||||
}
|
||||
"FILE_DESCRIPTION" => {
|
||||
if let Some(Param::List(v)) = params.first() {
|
||||
ex.description = v.iter().filter_map(|p| p.as_str()).collect::<Vec<_>>().join(" ");
|
||||
}
|
||||
}
|
||||
"FILE_NAME" => {
|
||||
ex.preprocessor = params.get(4).and_then(Param::as_str).unwrap_or("").to_string();
|
||||
ex.originating_system = params.get(5).and_then(Param::as_str).unwrap_or("").to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"DATA" => {
|
||||
// Optional `DATA('name', ('schema'));`
|
||||
if lx.eat(b'(')? {
|
||||
lx.list_rest(1)?;
|
||||
}
|
||||
lx.expect(b';')?;
|
||||
loop {
|
||||
lx.ws()?;
|
||||
if lx.peek() != Some(b'#') {
|
||||
let k = lx.keyword()?;
|
||||
if k == "ENDSEC" {
|
||||
lx.expect(b';')?;
|
||||
break;
|
||||
}
|
||||
return lx.fail(&format!("unexpected `{k}` in DATA"));
|
||||
}
|
||||
lx.i += 1;
|
||||
let st = lx.i;
|
||||
while lx.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||
lx.i += 1;
|
||||
}
|
||||
let id: u64 = match std::str::from_utf8(lx.s.get(st..lx.i).unwrap_or(&[])).ok().and_then(|t| t.parse().ok()) {
|
||||
Some(id) => id,
|
||||
None => return lx.fail("bad entity id"),
|
||||
};
|
||||
lx.expect(b'=')?;
|
||||
let records = if lx.eat(b'(')? {
|
||||
let mut rs = Vec::new();
|
||||
while !lx.eat(b')')? {
|
||||
if rs.len() > 64 {
|
||||
return lx.fail("complex entity with too many parts");
|
||||
}
|
||||
rs.push(lx.record()?);
|
||||
}
|
||||
rs
|
||||
} else {
|
||||
vec![lx.record()?]
|
||||
};
|
||||
lx.expect(b';')?;
|
||||
if ex.entities.len() % 4096 == 0
|
||||
&& let Some(w) = watch
|
||||
{
|
||||
if w.cancelled() {
|
||||
return err("STEP import cancelled");
|
||||
}
|
||||
w.progress("reading", lx.i, lx.s.len());
|
||||
}
|
||||
if ex.entities.len() >= MAX_ENTITIES {
|
||||
return err(format!("STEP file has more than {MAX_ENTITIES} entities"));
|
||||
}
|
||||
// A duplicate id is invalid; some writers emit them for geometry nobody
|
||||
// reads. Keep the first definition and report it.
|
||||
match ex.entities.entry(id) {
|
||||
std::collections::hash_map::Entry::Occupied(_) => ex.duplicates.push(id),
|
||||
std::collections::hash_map::Entry::Vacant(v) => {
|
||||
v.insert(Entity { records });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"END-ISO-10303-21" => {
|
||||
lx.expect(b';')?;
|
||||
return Ok(ex);
|
||||
}
|
||||
// ANCHOR / REFERENCE / SIGNATURE sections (Part 21 edition 3): skip to ENDSEC.
|
||||
"ANCHOR" | "REFERENCE" | "SIGNATURE" => {
|
||||
let rest = lx.s.get(lx.i..).unwrap_or(&[]);
|
||||
match rest.windows(7).position(|w| w == b"ENDSEC;") {
|
||||
Some(p) => lx.i += p + 7,
|
||||
None => return lx.fail("unterminated section"),
|
||||
}
|
||||
}
|
||||
_ => return lx.fail(&format!("unexpected `{kw}`")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unterminated_unicode_escape_search_is_linear() {
|
||||
// A separator keeps the closing backslash from pairing with the next opening
|
||||
// backslash as an escaped `\\`. Without it, only the first tag is searched.
|
||||
for (tag, literal) in [(b"\\X2\\ ", "\\X2\\ "), (b"\\X4\\ ", "\\X4\\ ")] {
|
||||
let raw = tag.repeat(100_000);
|
||||
let mut comparisons = 0;
|
||||
let decoded = decode_string_with_end(&raw, |rest| {
|
||||
rest.windows(4).position(|w| {
|
||||
comparisons += 1;
|
||||
assert!(comparisons <= raw.len(), "the decoder searched the same tail repeatedly");
|
||||
w == b"\\X0\\"
|
||||
})
|
||||
});
|
||||
assert_eq!(decoded, literal.repeat(100_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminated_runs_and_literal_recovery_keep_their_text() {
|
||||
let raw = b"\\X2\\00E9\\X0\\".repeat(1_000);
|
||||
let mut comparisons = 0;
|
||||
let decoded = decode_string_with_end(&raw, |rest| {
|
||||
rest.windows(4).position(|w| {
|
||||
comparisons += 1;
|
||||
assert!(comparisons <= raw.len());
|
||||
w == b"\\X0\\"
|
||||
})
|
||||
});
|
||||
assert_eq!(decoded, "\u{e9}".repeat(1_000));
|
||||
assert_eq!(decode_string(b"\\X2\\oops \\X\\41 \\S\\a"), "\\X2\\oops A \u{e1}");
|
||||
assert_eq!(decode_string(b"\\X2\\\\X2\\"), "\\X2\\X2\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_simple_and_complex() {
|
||||
let t = "ISO-10303-21;\nHEADER;FILE_DESCRIPTION((''),'2;1');\nFILE_NAME('a','',(''),(''),'','sys','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN'));ENDSEC;\nDATA;\n#1=CARTESIAN_POINT('p',(0.,1.5E1,-2));\n/* c */ #2=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));\n#3=X('it''s','\\X2\\00E9\\X0\\',$,#1,LENGTH_MEASURE(1.E-3),\"0F\");\nENDSEC;\nEND-ISO-10303-21;\n";
|
||||
let ex = parse(t).unwrap();
|
||||
assert_eq!(ex.schemas, vec!["AUTOMOTIVE_DESIGN"]);
|
||||
assert_eq!(ex.originating_system, "sys");
|
||||
let p = ex.get(1).unwrap();
|
||||
assert_eq!(p.name(), "CARTESIAN_POINT");
|
||||
let xyz: Vec<f64> = p.params()[1].as_list().unwrap().iter().filter_map(Param::as_f64).collect();
|
||||
assert_eq!(xyz, vec![0.0, 15.0, -2.0]);
|
||||
let u = ex.get(2).unwrap();
|
||||
assert!(u.is_complex() && u.has("SI_UNIT"));
|
||||
assert_eq!(u.record("SI_UNIT").unwrap()[0].as_enum(), Some("MILLI"));
|
||||
let x = ex.get(3).unwrap().params();
|
||||
assert_eq!(x[0].as_str(), Some("it's"));
|
||||
assert_eq!(x[1].as_str(), Some("\u{e9}"));
|
||||
assert_eq!(x[2], Param::Null);
|
||||
assert_eq!(x[3].as_ref_id(), Some(1));
|
||||
assert_eq!(x[4].as_f64(), Some(1e-3));
|
||||
}
|
||||
|
||||
/// #11650: the header is metadata, so a malformed record in it must not stop the import.
|
||||
#[test]
|
||||
fn a_malformed_header_record_is_skipped() {
|
||||
let t = "ISO-10303-21;
|
||||
HEADER;
|
||||
FILE_DESCRIPTION((''), '2;1');1');
|
||||
|
||||
FILE_NAME('','',(''),(''),'','sys','');
|
||||
|
||||
FILE_SCHEMA (('AUTOMOTIVE_DESIGN'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=CARTESIAN_POINT('p',(0.,1.,2.));
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
";
|
||||
let ex = parse(t).unwrap();
|
||||
// The records after the broken one are still read.
|
||||
assert_eq!(ex.originating_system, "sys");
|
||||
assert_eq!(ex.schemas, vec!["AUTOMOTIVE_DESIGN"]);
|
||||
assert_eq!(ex.get(1).unwrap().name(), "CARTESIAN_POINT");
|
||||
// A header with nothing after the junk to resume at is still an error.
|
||||
assert!(
|
||||
parse(
|
||||
"ISO-10303-21;
|
||||
HEADER;
|
||||
FILE_NAME('a');junk').;ENDSEC;DATA;ENDSEC;END-ISO-10303-21;"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// #11650: skipping bad header records stops at DATA when the header's `ENDSEC;` is
|
||||
/// missing, so the entities are not skipped as header records and the file is an error.
|
||||
#[test]
|
||||
fn a_header_without_endsec_does_not_swallow_the_data_section() {
|
||||
for data in ["DATA;", "DATA('d', ('AUTOMOTIVE_DESIGN'));"] {
|
||||
let t = format!("ISO-10303-21;\nHEADER;\nBAD;\n{data}\n#1=A();\nENDSEC;\nEND-ISO-10303-21;\n");
|
||||
let e = parse(&t).unwrap_err();
|
||||
assert!(e.contains("line 4"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #11650: many bad header records in a row are skipped in time that grows with the file,
|
||||
/// and the error after them still names the right line.
|
||||
#[test]
|
||||
fn many_bad_header_records_are_skipped_in_one_pass() {
|
||||
let n = 100_000;
|
||||
let bad = "BAD;\n".repeat(n);
|
||||
let ok = format!("ISO-10303-21;\nHEADER;\n{bad}ENDSEC;\nDATA;\n#1=A();\nENDSEC;\nEND-ISO-10303-21;\n");
|
||||
assert_eq!(parse(&ok).unwrap().get(1).unwrap().name(), "A");
|
||||
let broken = format!("ISO-10303-21;\nHEADER;\n{bad}DATA;\n#1=A();\nENDSEC;\nEND-ISO-10303-21;\n");
|
||||
let e = parse(&broken).unwrap_err();
|
||||
assert!(e.contains(&format!("line {}", n + 3)), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_is_an_error() {
|
||||
for t in [
|
||||
"",
|
||||
"garbage",
|
||||
"ISO-10303-21;",
|
||||
"ISO-10303-21;DATA;#1=A(",
|
||||
"ISO-10303-21;DATA;#1=A('x",
|
||||
"ISO-10303-21;DATA;#1=A(/*",
|
||||
"ISO-10303-21;DATA;#=A();ENDSEC;END-ISO-10303-21;",
|
||||
"ISO-10303-21;DATA;#99999999999999999999999=A();ENDSEC;END-ISO-10303-21;",
|
||||
"ISO-10303-21;DATA;#1=A(1e999999.);ENDSEC;END-ISO-10303-21;",
|
||||
] {
|
||||
let _ = parse(t);
|
||||
}
|
||||
let deep = format!("ISO-10303-21;DATA;#1=A({}{});ENDSEC;END-ISO-10303-21;", "(".repeat(10_000), ")".repeat(10_000));
|
||||
assert!(parse(&deep).is_err());
|
||||
let dup = parse("ISO-10303-21;DATA;#1=A(1);#1=B();ENDSEC;END-ISO-10303-21;").unwrap();
|
||||
assert_eq!(dup.duplicates, vec![1]);
|
||||
assert_eq!(dup.get(1).unwrap().name(), "A");
|
||||
assert_eq!(decode_string(b"\\X\\E9"), "\u{e9}");
|
||||
assert_eq!(decode_string(b"a\\X2\\00E900E8\\X0\\b"), "a\u{e9}\u{e8}b");
|
||||
assert_eq!(decode_string(b"\\"), "\\");
|
||||
// One run per character, as `step_out` writes them (#2344).
|
||||
assert_eq!(decode_string(b"\\X2\\5706\\X0\\\\X2\\89D2\\X0\\20"), "\u{5706}\u{89d2}20");
|
||||
assert_eq!(decode_string(b"\\X4\\0001F600\\X0\\\\X2\\00E9\\X0\\\\S\\a"), "\u{1f600}\u{e9}\u{e1}");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user