Compare commits

..

1 Commits

Author SHA1 Message Date
bherbruck 3fffb8a9b9 offset: the executive marks how it made each face; faces_continue; ops history (#3138)
SolidCraft decides whether a shell's face is a face or its offset copy by mesh
distance (offset_backend.rs with_offsets), because the tag a copy carries is its
source's. The executive already relates each face it makes to what made it.
mark_offset_roles, called first in piecewise_merge where those relations are
final, marks each such face with ids::OFFSET_ROLE: an offset copy, a face along
an edge (with the closing faces, a sheet's side walls), a face at a vertex.
It adds no relation and changes no decision.

query::faces_continue proves two faces across an edge lie on one surface with
the same outward side only on identical definitions, bit for bit, and is
Undecided otherwise; it never samples. solidcore-ops' history::recorded tags the
operand's faces and edges by record index, runs the call, and reads each result
face's origin and change and each edge's operand edges, in record order. The
tags and roles are removed before it returns.

offset.offset_executive_p2: 30 rows, the attributes block only (the role now on
the offset faces). A/B: 30 with the pass, 0 without. Tests: a shelled box
(424), a thickened sheet (100), a moved face (1200), and #865's plate, whose
crossing-loops-rebuilt top is the top's offset.

Part of #3138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:24:19 -05:00
273 changed files with 1879 additions and 11025 deletions
Generated
+33 -33
View File
@@ -4259,7 +4259,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4302,11 +4302,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4322,11 +4322,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"image",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"proc-macro2",
"quote",
@@ -4354,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde",
@@ -4376,7 +4376,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4413,7 +4413,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4451,7 +4451,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4479,7 +4479,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"proptest",
@@ -4500,7 +4500,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"log",
@@ -4512,7 +4512,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde_json",
@@ -4526,14 +4526,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
"web-time",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"image",
@@ -4564,7 +4564,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"harfrust",
"serde",
@@ -4578,7 +4578,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"log",
@@ -4592,18 +4592,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"serde",
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
@@ -4678,14 +4678,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde",
@@ -4707,7 +4707,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"egui-wgpu",
@@ -4718,7 +4718,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"naga",
@@ -4734,7 +4734,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"eframe",
"egui",
@@ -6220,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"proc-macro2",
+1 -1
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+6 -37
View File
@@ -361,54 +361,23 @@ const UNSTABLE: [(&str, &str); 1] = [(
"the halves of each swept wall lie level along every axis, so which is `#1` and which `#2` follows the order the kernel gives the faces in, which changes from one build to the next",
)];
/// The designs that take longest to build, heaviest first, with one build's time (ci profile,
/// CLI `run`, one design at a time, on P-cores, 2026-11-10). The test hands them out first, and
/// each design's two builds go to two workers, so a heavy design is never built twice in a row on
/// one worker while the rest wait. Every other design took under 3.6 s. Only the order of the
/// work depends on this list: a stale entry makes the test slower, never wrong. Re-measure when a
/// design here gets much faster, or one not listed takes over 5 s.
const HEAVIEST: [&str; 6] = [
"oracle/77-fillet-variable-linear", // 35.6 s
"oracle/83-boundary-loop-thicken", // 12.4 s
"regressions/closed_sweep_torus_cut", // 6.8 s
"oracle/41-tube-radial-holes", // 4.6 s
"oracle/42-overlapping-patterns", // 4.2 s
"oracle/35-fillet-curved-curved", // 3.2 s
];
#[test]
fn corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone() {
let designs = designs();
// Every design is built twice, as two jobs: (design, which build). The heaviest go first.
for heavy in HEAVIEST {
assert!(designs.iter().any(|(name, _)| name == heavy), "HEAVIEST lists `{heavy}`, which is not in the corpus any more");
}
let rank = |name: &str| HEAVIEST.iter().position(|h| *h == name).unwrap_or(HEAVIEST.len());
let mut jobs: Vec<(usize, usize)> = (0..designs.len()).flat_map(|d| [(d, 0), (d, 1)]).collect();
jobs.sort_by_key(|&(d, build)| (rank(&designs[d].0), d, build));
let next = std::sync::atomic::AtomicUsize::new(0);
let builds = std::sync::Mutex::new(std::collections::BTreeMap::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(jobs.len().max(1));
let results = std::sync::Mutex::new(Vec::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(designs.len().max(1));
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
while let Some(&(d, which)) = jobs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(&designs[d].1).map(|s| build(&s));
builds.lock().unwrap_or_else(|e| e.into_inner()).insert((d, which), got);
while let Some((name, source)) = designs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(source).and_then(|first| Ok((build(&first), build(&load(source)?))));
results.lock().unwrap_or_else(|e| e.into_inner()).push((name.clone(), got));
}
});
}
});
let mut builds = builds.into_inner().unwrap_or_else(|e| e.into_inner());
let mut results: Vec<(String, Result<(Build, Build), String>)> = designs
.iter()
.enumerate()
.map(|(d, (name, _))| {
let first = builds.remove(&(d, 0)).unwrap();
let second = builds.remove(&(d, 1)).unwrap();
(name.clone(), first.and_then(|first| Ok((first, second?))))
})
.collect();
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
results.sort_by(|a, b| a.0.cmp(&b.0));
let mut problems = Vec::new();
for (name, got) in &results {
+1 -4
View File
@@ -149,12 +149,9 @@ self.addEventListener("activate", (event) => {
return self.registration.unregister();
}
const older = (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE);
// Claim first: from the moment this worker is the active one, a page the old worker controlled has
// no controller until it is claimed, and a request it makes then goes straight to the network, which
// serves the newest build, not the page's. Nothing slow goes before this.
await self.clients.claim();
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
await sweep();
await self.clients.claim();
// 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
@@ -1,83 +0,0 @@
{
"en": {
"ui.agent_chat_answered_in": "Answered in {time}",
"ui.agent_chat_ended_after": "Ended after {time}",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_thinking_for": "Thinking for {time}",
"ui.agent_chat_tool_running_for": "running for {time}"
},
"ar": {
"ui.agent_chat_answered_in": "أُجيب خلال {time}",
"ui.agent_chat_ended_after": "انتهى بعد {time}",
"ui.agent_chat_span_h": "{h} ساعة {min} دقيقة",
"ui.agent_chat_span_min": "{n} دقيقة",
"ui.agent_chat_span_s": "{n} ث",
"ui.agent_chat_thinking_for": "يفكر منذ {time}",
"ui.agent_chat_tool_running_for": "قيد التشغيل منذ {time}"
},
"de": {
"ui.agent_chat_answered_in": "Beantwortet in {time}",
"ui.agent_chat_ended_after": "Beendet nach {time}",
"ui.agent_chat_span_h": "{h} Std. {min} Min.",
"ui.agent_chat_span_min": "{n} Min.",
"ui.agent_chat_span_s": "{n} Sek.",
"ui.agent_chat_thinking_for": "Denkt seit {time} nach",
"ui.agent_chat_tool_running_for": "läuft seit {time}"
},
"fr": {
"ui.agent_chat_answered_in": "Répondu en {time}",
"ui.agent_chat_ended_after": "Arrêté après {time}",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_thinking_for": "Réfléchit depuis {time}",
"ui.agent_chat_tool_running_for": "en cours depuis {time}"
},
"he": {
"ui.agent_chat_answered_in": "נענה תוך {time}",
"ui.agent_chat_ended_after": "הסתיים אחרי {time}",
"ui.agent_chat_span_h": "{h} שע' {min} דק'",
"ui.agent_chat_span_min": "{n} דק'",
"ui.agent_chat_span_s": "{n} שנ'",
"ui.agent_chat_thinking_for": "חושב כבר {time}",
"ui.agent_chat_tool_running_for": "רץ כבר {time}"
},
"ja": {
"ui.agent_chat_answered_in": "{time}で回答",
"ui.agent_chat_ended_after": "{time}後に終了",
"ui.agent_chat_span_h": "{h} 時間 {min} 分",
"ui.agent_chat_span_min": "{n} 分",
"ui.agent_chat_span_s": "{n} 秒",
"ui.agent_chat_thinking_for": "{time}考え中",
"ui.agent_chat_tool_running_for": "実行中 ({time})"
},
"ko": {
"ui.agent_chat_answered_in": "{time} 만에 답변",
"ui.agent_chat_ended_after": "{time} 후 종료",
"ui.agent_chat_span_h": "{h}시간 {min}분",
"ui.agent_chat_span_min": "{n}분",
"ui.agent_chat_span_s": "{n}초",
"ui.agent_chat_thinking_for": "{time}째 생각 중",
"ui.agent_chat_tool_running_for": "{time}째 실행 중"
},
"vi": {
"ui.agent_chat_answered_in": "Đã trả lời trong {time}",
"ui.agent_chat_ended_after": "Đã dừng sau {time}",
"ui.agent_chat_span_h": "{h} giờ {min} phút",
"ui.agent_chat_span_min": "{n} phút",
"ui.agent_chat_span_s": "{n} giây",
"ui.agent_chat_thinking_for": "Đang suy nghĩ {time}",
"ui.agent_chat_tool_running_for": "đang chạy {time}"
},
"zh": {
"ui.agent_chat_answered_in": "用时 {time} 完成回答",
"ui.agent_chat_ended_after": "{time} 后结束",
"ui.agent_chat_span_h": "{h} 小时 {min} 分钟",
"ui.agent_chat_span_min": "{n} 分钟",
"ui.agent_chat_span_s": "{n} 秒",
"ui.agent_chat_thinking_for": "已思考 {time}",
"ui.agent_chat_tool_running_for": "已运行 {time}"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"cmd.assembly_constraint_create": "Constrain"
},
"de": {
"cmd.assembly_constraint_create": "Abhängigkeit"
},
"ja": {
"cmd.assembly_constraint_create": "拘束"
},
"zh": {
"cmd.assembly_constraint_create": "约束"
},
"ko": {
"cmd.assembly_constraint_create": "구속"
},
"vi": {
"cmd.assembly_constraint_create": "Ràng buộc"
},
"ar": {
"cmd.assembly_constraint_create": "تقييد"
},
"he": {
"cmd.assembly_constraint_create": "אילוץ"
}
}
@@ -1,23 +0,0 @@
{
"en": {
"dialog.component_origin": "{placement} · {item}"
},
"ar": {
"dialog.component_origin": "{placement} · {item}"
},
"de": {
"dialog.component_origin": "{placement} · {item}"
},
"he": {
"dialog.component_origin": "{placement} · {item}"
},
"ja": {
"dialog.component_origin": "{placement} · {item}"
},
"vi": {
"dialog.component_origin": "{placement} · {item}"
},
"zh": {
"dialog.component_origin": "{placement} · {item}"
}
}
@@ -1,80 +0,0 @@
{
"en": {
"cmd.construct.axis": "Axis",
"cmd.construct.plane": "Plane",
"cmd.construct.point": "Point",
"help.construct.axis": "Creates a construction axis with the method your picks call for: along an edge or line, through a hole or round face, where two planes meet, through two points, or square to a face through a point. Use it when you know the references and not the method.",
"help.construct.plane": "Creates a construction plane with the method your picks call for: offset from a plane, through a point, between two planes, through lines or points, tangent to a curved face, or square to a path. Use it when you know the references and not the method.",
"help.construct.point": "Creates a construction point with the method your picks call for: at a vertex, at the centre of a circle or sphere, along an edge, where two lines or three planes meet, or where a line crosses a plane. Use it when you know the references and not the method.",
"ui.construct_auto.chosen": "Chosen from your picks. Pick another reference, or take one out, to change the method.",
"ui.construct_auto.no_fit": "Pick other references: no method builds from these.",
"ui.construct_auto.pick": "Pick what to build from: the method follows from your picks.",
"ui.construct_auto.refs": "References",
"ui.construct_auto.select": "Select references to build from first"
},
"ja": {
"cmd.construct.axis": "軸",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "選択に応じた方法で構築軸を作ります。エッジや線に沿って、穴や丸い面を通って、2つの平面の交線に、2点を通って、または点を通り面に垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.plane": "選択に応じた方法で構築平面を作ります。平面からのオフセット、点を通る、2つの平面の中間、線や点を通る、曲面に接する、またはパスに垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.point": "選択に応じた方法で構築点を作ります。頂点、円や球の中心、エッジ上、2本の線や3つの平面の交点、または線が平面と交わる位置に作ります。参照は決まっているが方法を選びたくないときに使います。",
"ui.construct_auto.chosen": "選択から方法を選びました。参照を追加または削除すると方法が変わります。",
"ui.construct_auto.no_fit": "別の参照を選択してください。これらから作れる方法がありません。",
"ui.construct_auto.pick": "作成元を選択してください。方法は選択から決まります。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "先に作成元の参照を選択してください"
},
"zh": {
"cmd.construct.axis": "轴",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "按所选对象对应的方法创建构造轴:沿边或直线、穿过孔或圆形面、两平面的交线、穿过两点,或过一点垂直于面。在已知参照但不想选择方法时使用。",
"help.construct.plane": "按所选对象对应的方法创建构造平面:偏移自平面、过一点、两平面之间、过直线或点、与曲面相切,或垂直于路径。在已知参照但不想选择方法时使用。",
"help.construct.point": "按所选对象对应的方法创建构造点:在顶点、圆或球的中心、沿边、两直线或三个平面的交点,或直线与平面的交点。在已知参照但不想选择方法时使用。",
"ui.construct_auto.chosen": "已根据所选对象选定方法。添加或移除参照即可更换方法。",
"ui.construct_auto.no_fit": "请选择其他参照:没有方法能由这些对象创建。",
"ui.construct_auto.pick": "选择创建依据:方法由所选对象决定。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "请先选择创建依据的参照"
},
"ar": {
"cmd.construct.axis": "محور",
"cmd.construct.plane": "مستوى",
"cmd.construct.point": "نقطة",
"help.construct.axis": "ينشئ محورًا إنشائيًا بالطريقة التي تناسب ما تختاره: على امتداد حافة أو خط، أو عبر ثقب أو وجه دائري، أو عند تقاطع مستويين، أو عبر نقطتين، أو عموديًا على وجه عبر نقطة. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.plane": "ينشئ مستوى إنشائيًا بالطريقة التي تناسب ما تختاره: إزاحة عن مستوى، أو عبر نقطة، أو بين مستويين، أو عبر خطوط أو نقاط، أو مماسًا لوجه منحنٍ، أو عموديًا على مسار. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.point": "ينشئ نقطة إنشائية بالطريقة التي تناسب ما تختاره: عند رأس، أو في مركز دائرة أو كرة، أو على امتداد حافة، أو عند تقاطع خطين أو ثلاثة مستويات، أو حيث يقطع خط مستوى. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"ui.construct_auto.chosen": "اختيرت الطريقة من اختياراتك. اختر مرجعًا آخر أو أزل مرجعًا لتغيير الطريقة.",
"ui.construct_auto.no_fit": "اختر مراجع أخرى: لا توجد طريقة تبني من هذه المراجع.",
"ui.construct_auto.pick": "اختر ما تبني منه: الطريقة تتبع اختياراتك.",
"ui.construct_auto.refs": "المراجع",
"ui.construct_auto.select": "اختر أولًا المراجع التي تبني منها"
},
"he": {
"cmd.construct.axis": "ציר",
"cmd.construct.plane": "מישור",
"cmd.construct.point": "נקודה",
"help.construct.axis": "יוצר ציר עזר בשיטה שמתאימה למה שבחרת: לאורך קצה או קו, דרך חור או פאה עגולה, במפגש של שני מישורים, דרך שתי נקודות, או ניצב לפאה דרך נקודה. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.plane": "יוצר מישור עזר בשיטה שמתאימה למה שבחרת: בהיסט ממישור, דרך נקודה, בין שני מישורים, דרך קווים או נקודות, משיק לפאה מעוקלת, או ניצב למסלול. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.point": "יוצר נקודת עזר בשיטה שמתאימה למה שבחרת: בקודקוד, במרכז של מעגל או כדור, לאורך קצה, במפגש של שני קווים או שלושה מישורים, או במקום שבו קו חוצה מישור. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"ui.construct_auto.chosen": "השיטה נבחרה לפי הבחירות שלך. בחר הפניה נוספת, או הסר אחת, כדי לשנות את השיטה.",
"ui.construct_auto.no_fit": "בחר הפניות אחרות: אין שיטה שבונה מאלה.",
"ui.construct_auto.pick": "בחר ממה לבנות: השיטה נקבעת לפי הבחירות שלך.",
"ui.construct_auto.refs": "הפניות",
"ui.construct_auto.select": "בחר קודם את ההפניות לבנייה"
},
"vi": {
"cmd.construct.axis": "Trục",
"cmd.construct.plane": "Mặt phẳng",
"cmd.construct.point": "Điểm",
"help.construct.axis": "Tạo trục dựng theo phương pháp phù hợp với những gì bạn chọn: dọc theo cạnh hoặc đường, qua lỗ hoặc mặt tròn, nơi hai mặt phẳng giao nhau, qua hai điểm, hoặc vuông góc với mặt qua một điểm. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.plane": "Tạo mặt phẳng dựng theo phương pháp phù hợp với những gì bạn chọn: cách một mặt phẳng, qua một điểm, giữa hai mặt phẳng, qua các đường hoặc điểm, tiếp xúc với mặt cong, hoặc vuông góc với đường dẫn. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.point": "Tạo điểm dựng theo phương pháp phù hợp với những gì bạn chọn: tại đỉnh, tại tâm đường tròn hoặc mặt cầu, dọc theo cạnh, nơi hai đường hoặc ba mặt phẳng giao nhau, hoặc nơi một đường cắt mặt phẳng. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"ui.construct_auto.chosen": "Đã chọn phương pháp theo lựa chọn của bạn. Chọn thêm hoặc bỏ một tham chiếu để đổi phương pháp.",
"ui.construct_auto.no_fit": "Hãy chọn tham chiếu khác: không phương pháp nào dựng được từ các tham chiếu này.",
"ui.construct_auto.pick": "Chọn những gì để dựng: phương pháp theo lựa chọn của bạn.",
"ui.construct_auto.refs": "Tham chiếu",
"ui.construct_auto.select": "Hãy chọn tham chiếu để dựng trước"
}
}
@@ -1,34 +0,0 @@
{
"en": {
"prefs.dialog_first_letter_buttons": "Dialog buttons answer their first letter",
"prefs.dialog_first_letter_buttons_help": "With a dialog open, typing the first letter of a button's label presses it: O for OK, C for Cancel. A letter that fits two buttons does nothing, and a focused text field keeps the keystroke. Off: typing does nothing to the buttons, as before."
},
"ar": {
"prefs.dialog_first_letter_buttons": "أزرار نوافذ الحوار تستجيب لحرفها الأول",
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، كتابة الحرف الأول من تسمية الزر تضغطه: حرف «ا» يضغط زر «إلغاء» مثلًا. الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص المركَّز عليه يحتفظ بالتضغطة. عند الإيقاف: الكتابة لا تفعل شيئًا للأزرار، كما كان سابقًا."
},
"de": {
"prefs.dialog_first_letter_buttons": "Dialogschaltflächen reagieren auf ihren Anfangsbuchstaben",
"prefs.dialog_first_letter_buttons_help": "Ist ein Dialog geöffnet, drückt der Anfangsbuchstabe einer Beschriftung die zugehörige Schaltfläche: O für OK, A für Abbrechen. Ein Buchstabe, der zu zwei Schaltflächen passt, tut nichts, und ein fokussiertes Textfeld behält die Tasteneingabe. Aus: Eingaben drücken keine Schaltfläche, wie bisher."
},
"he": {
"prefs.dialog_first_letter_buttons": "כפתורי תיבות דו-שיח נענים לאות הראשונה שלהם",
"prefs.dialog_first_letter_buttons_help": "כשתיבת דו-שיח פתוחה, הקלדת האות הראשונה של שם הכפתור לוחצת עליו: האות «ב» לוחצת על «ביטול», למשל. אות שמתאימה לשני כפתורים אינה עושה דבר, ושדה טקסט שבו נמצא הסמן שומר את ההקלדה. כבוי: הקלדה אינה משפיעה על הכפתורים, כמו קודם."
},
"ja": {
"prefs.dialog_first_letter_buttons": "ダイアログのボタンはラベルの先頭文字で押せる",
"prefs.dialog_first_letter_buttons_help": "ダイアログが開いているとき、ボタンのラベルの先頭文字を入力するとそのボタンが押されます:「キ」で「キャンセル」など。2 つのボタンに当てはまる文字は何もしません。フォーカス中のテキストフィールドはそのまま入力を受け取ります。オフ:従来どおり、入力ではボタンは押されません。"
},
"ko": {
"prefs.dialog_first_letter_buttons": "다이얼로그 단추가 첫 글자에 반응",
"prefs.dialog_first_letter_buttons_help": "다이얼로그가 열려 있을 때 단추 레이블의 첫 글자를 입력하면 그 단추를 누릅니다: 예를 들어 「ㅊ」은 「취소」를 누릅니다. 두 단추에 모두 해당하는 글자는 아무 동작을 하지 않으며, 포커스된 텍스트 필드는 입력을 그대로 받습니다. 끔: 기존처럼 입력이 단추에 영향을 주지 않습니다."
},
"vi": {
"prefs.dialog_first_letter_buttons": "Nút trong hộp thoại trả lời chữ cái đầu của nhãn",
"prefs.dialog_first_letter_buttons_help": "Khi một hộp thoại đang mở, gõ chữ cái đầu của nhãn một nút sẽ bấm nút đó: chữ H bấm nút Hủy bỏ, chẳng hạn. Một chữ khớp với hai nút không thực hiện gì, và trường văn bản đang tiêu điểm giữ lại phím gõ. Tắt: gõ phím không bấm nút, như trước đây."
},
"zh": {
"prefs.dialog_first_letter_buttons": "对话框按钮响应其首字母",
"prefs.dialog_first_letter_buttons_help": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
}
}
-38
View File
@@ -1,38 +0,0 @@
{
"ar": {
"cmd.note_add": "إضافة ملاحظة",
"cmd.note_delete": "حذف ملاحظة",
"cmd.note_list": "عرض الملاحظات",
"cmd.note_update": "تحرير الملاحظة"
},
"en": {
"cmd.note_add": "Add Note",
"cmd.note_delete": "Delete Note",
"cmd.note_list": "List Notes",
"cmd.note_update": "Edit Note"
},
"he": {
"cmd.note_add": "הוספת הערה",
"cmd.note_delete": "מחיקת הערה",
"cmd.note_list": "הצגת הערות",
"cmd.note_update": "עריכת הערה"
},
"ja": {
"cmd.note_add": "メモを追加",
"cmd.note_delete": "メモを削除",
"cmd.note_list": "メモを一覧表示",
"cmd.note_update": "メモを編集"
},
"vi": {
"cmd.note_add": "Thêm ghi chú",
"cmd.note_delete": "Xóa ghi chú",
"cmd.note_list": "Liệt kê ghi chú",
"cmd.note_update": "Chỉnh sửa ghi chú"
},
"zh": {
"cmd.note_add": "添加备注",
"cmd.note_delete": "删除备注",
"cmd.note_list": "列出备注",
"cmd.note_update": "编辑备注"
}
}
@@ -1,240 +0,0 @@
{
"en": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Baseline",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Middle",
"sketch_text.valign_bottom": "Bottom"
},
"ar": {
"Vertical": "عمودي",
"sketch_text.valign_baseline": "خط الأساس",
"sketch_text.valign_top": "أعلى",
"sketch_text.valign_middle": "وسط",
"sketch_text.valign_bottom": "أسفل"
},
"bg": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Базова линия",
"sketch_text.valign_top": "Горе",
"sketch_text.valign_middle": "Среда",
"sketch_text.valign_bottom": "Долу"
},
"bs": {
"Vertical": "Vertikalno",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"ca": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línia base",
"sketch_text.valign_top": "Dalt",
"sketch_text.valign_middle": "Mig",
"sketch_text.valign_bottom": "Baix"
},
"cs": {
"Vertical": "Svisle",
"sketch_text.valign_baseline": "Účaří",
"sketch_text.valign_top": "Nahoře",
"sketch_text.valign_middle": "Uprostřed",
"sketch_text.valign_bottom": "Dole"
},
"da": {
"Vertical": "Lodret",
"sketch_text.valign_baseline": "Grundlinje",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Midte",
"sketch_text.valign_bottom": "Bund"
},
"de": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Grundlinie",
"sketch_text.valign_top": "Oben",
"sketch_text.valign_middle": "Mitte",
"sketch_text.valign_bottom": "Unten"
},
"es": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línea base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Abajo"
},
"fi": {
"Vertical": "Pystysuunta",
"sketch_text.valign_baseline": "Perusviiva",
"sketch_text.valign_top": "Ylä",
"sketch_text.valign_middle": "Keski",
"sketch_text.valign_bottom": "Ala"
},
"fr": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Ligne de base",
"sketch_text.valign_top": "Haut",
"sketch_text.valign_middle": "Milieu",
"sketch_text.valign_bottom": "Bas"
},
"gl": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Liña base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Medio",
"sketch_text.valign_bottom": "Abaixo"
},
"he": {
"Vertical": "אנכי",
"sketch_text.valign_baseline": "קו בסיס",
"sketch_text.valign_top": "למעלה",
"sketch_text.valign_middle": "אמצע",
"sketch_text.valign_bottom": "למטה"
},
"hr": {
"Vertical": "Okomito",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"hu": {
"Vertical": "Függőleges",
"sketch_text.valign_baseline": "Alapvonal",
"sketch_text.valign_top": "Fent",
"sketch_text.valign_middle": "Középen",
"sketch_text.valign_bottom": "Lent"
},
"id": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"it": {
"Vertical": "Verticale",
"sketch_text.valign_baseline": "Linea di base",
"sketch_text.valign_top": "Alto",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Basso"
},
"ja": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "ベースライン",
"sketch_text.valign_top": "上",
"sketch_text.valign_middle": "中央",
"sketch_text.valign_bottom": "下"
},
"ka": {
"Vertical": "ვერტიკალური",
"sketch_text.valign_baseline": "საბაზისო ხაზი",
"sketch_text.valign_top": "ზემოთ",
"sketch_text.valign_middle": "შუა",
"sketch_text.valign_bottom": "ქვემოთ"
},
"ko": {
"Vertical": "수직",
"sketch_text.valign_baseline": "기준선",
"sketch_text.valign_top": "위",
"sketch_text.valign_middle": "가운데",
"sketch_text.valign_bottom": "아래"
},
"ms": {
"Vertical": "Menegak",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"nb": {
"Vertical": "Loddrett",
"sketch_text.valign_baseline": "Grunnlinje",
"sketch_text.valign_top": "Topp",
"sketch_text.valign_middle": "Midt",
"sketch_text.valign_bottom": "Bunn"
},
"nl": {
"Vertical": "Verticaal",
"sketch_text.valign_baseline": "Basislijn",
"sketch_text.valign_top": "Boven",
"sketch_text.valign_middle": "Midden",
"sketch_text.valign_bottom": "Onder"
},
"pl": {
"Vertical": "Pionowo",
"sketch_text.valign_baseline": "Linia bazowa",
"sketch_text.valign_top": "Góra",
"sketch_text.valign_middle": "Środek",
"sketch_text.valign_bottom": "Dół"
},
"pt-BR": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linha de base",
"sketch_text.valign_top": "Topo",
"sketch_text.valign_middle": "Meio",
"sketch_text.valign_bottom": "Base"
},
"ro": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linie de bază",
"sketch_text.valign_top": "Sus",
"sketch_text.valign_middle": "Mijloc",
"sketch_text.valign_bottom": "Jos"
},
"ru": {
"Vertical": "По вертикали",
"sketch_text.valign_baseline": "Базовая линия",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"sr": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Основна линија",
"sketch_text.valign_top": "Врх",
"sketch_text.valign_middle": "Средина",
"sketch_text.valign_bottom": "Дно"
},
"sv": {
"Vertical": "Lodrät",
"sketch_text.valign_baseline": "Baslinje",
"sketch_text.valign_top": "Överkant",
"sketch_text.valign_middle": "Mitten",
"sketch_text.valign_bottom": "Nederkant"
},
"tr": {
"Vertical": "Dikey",
"sketch_text.valign_baseline": "Taban çizgisi",
"sketch_text.valign_top": "Üst",
"sketch_text.valign_middle": "Orta",
"sketch_text.valign_bottom": "Alt"
},
"uk": {
"Vertical": "По вертикалі",
"sketch_text.valign_baseline": "Базова лінія",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"vi": {
"Vertical": "Dọc",
"sketch_text.valign_baseline": "Đường cơ sở",
"sketch_text.valign_top": "Trên",
"sketch_text.valign_middle": "Giữa",
"sketch_text.valign_bottom": "Dưới"
},
"zh-TW": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基線",
"sketch_text.valign_top": "頂端",
"sketch_text.valign_middle": "置中",
"sketch_text.valign_bottom": "底端"
},
"zh": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基线",
"sketch_text.valign_top": "顶端",
"sketch_text.valign_middle": "居中",
"sketch_text.valign_bottom": "底端"
}
}
@@ -1,3 +0,0 @@
| File | Author | Source | Licence |
|---|---|---|---|
| crates/look/icons/design/svg/model/plane_auto.svg | SolidCraft contributors | original construction icon master (64 grid) | SolidCraft Brand and Interface Licence (LICENSE-BRAND) |
+1 -1
View File
@@ -797,7 +797,7 @@ impl Hub {
}
};
// Subscribed once the window named the design (not when it refused or timed out).
let sent = match joining.zip(document_of(&reply)) {
let sent = match joining.and_then(|counted| document_of(&reply).map(|seen| (counted, seen))) {
Some((counted, seen)) => match Subscription::start(documents, self.protocol.change_event(), counted, &out, seen, &reply) {
Some((subscription, sent)) => {
events = Some(subscription);
+21 -56
View File
@@ -97,14 +97,7 @@ pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) ->
result
}
fn remap_geo(
g: &mut crate::construct::GeoRef,
ids: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
depth: usize,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
fn remap_geo(g: &mut crate::construct::GeoRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
use crate::construct::GeoRef;
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
@@ -121,38 +114,29 @@ fn remap_geo(
*n = remap_topology(n, ids, bodies);
}
}
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1, occurrences, doc)?,
GeoRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1)?,
_ => {}
}
Ok(())
}
fn remap_plane(
p: &mut PlaneRef,
ids: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
depth: usize,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match p {
PlaneRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?,
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
PlaneRef::ThroughPoint { base, point } => {
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(point, ids, bodies, depth + 1, occurrences, doc)?;
remap_plane(base, ids, bodies, depth + 1)?;
remap_geo(point, ids, bodies, depth + 1)?;
}
PlaneRef::AtAngle { base, line, pivot, .. } => {
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
remap_plane(base, ids, bodies, depth + 1)?;
if let Some(g) = line {
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(g, ids, bodies, depth + 1)?;
}
if let Some(g) = pivot {
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(g, ids, bodies, depth + 1)?;
}
}
PlaneRef::Face { name, anchor, .. } => {
@@ -165,28 +149,13 @@ fn remap_plane(
}
_ => {
for r in p.refs_mut() {
remap_geo(r, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(r, ids, bodies, depth + 1)?;
}
}
}
Ok(())
}
/// A datum reference names an exact placement by its complete occurrence path. A copy within one
/// design rewrites the path to the copy's own walk (the copied subtree's source ancestors are not
/// in the map); an empty occurrence map (a paste from another design) or a path that leaves the
/// copied tree is refused - the reference could not follow, and a dangling one would resolve
/// against whatever then sits at those ids.
fn remap_datum_path(d: &mut crate::construct::DatumRef, occurrences: &BTreeMap<u64, u64>, doc: &crate::Document) -> crate::Result<()> {
let last = d.path.last().copied().ok_or_else(|| crate::DocError::Invalid("a copied datum names no placement".into()))?;
let new = occurrences
.get(&last)
.copied()
.ok_or_else(|| crate::DocError::Invalid("the copied reference names a placement outside the copied tree".into()))?;
d.path = doc.occurrence_path(new).ok_or_else(|| crate::DocError::Invalid("the copied placement's path is broken".into()))?;
Ok(())
}
pub(crate) fn remap_link(
source: &mut solidcraft_sketch::LinkSource,
ids: &BTreeMap<u64, u64>,
@@ -235,8 +204,6 @@ impl crate::Feature {
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
use FeatureKind as K;
match &self.kind {
@@ -299,7 +266,7 @@ impl crate::Feature {
self.kind.remap_bodies(bodies);
match &mut self.kind {
K::Sketch { plane, sketch } => {
remap_plane(plane, ids, bodies, 0, occurrences, doc)?;
remap_plane(plane, ids, bodies, 0)?;
for c in &mut sketch.constraints {
if let Some(n) = &mut c.param {
mapped_name(n, parameters);
@@ -323,33 +290,31 @@ impl crate::Feature {
}
K::FrameEndCap { ends, .. } => {
for end in ends {
remap_plane(&mut end.plane, ids, bodies, 0, occurrences, doc)?;
remap_plane(&mut end.plane, ids, bodies, 0)?;
}
}
K::ConstructionPlane { plane }
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0, occurrences, doc)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0, occurrences, doc)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
remap_geo(r, ids, bodies, 0)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
remap_geo(r, ids, bodies, 0)?;
}
}
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => {
remap_geo(geo, ids, bodies, 0, occurrences, doc)?
}
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => remap_geo(geo, ids, bodies, 0)?,
// An Align frame's joint origin finds its faces and edges by name, as the feature's
// own face names are found.
K::Align { from_frame, to_frame, .. } => {
@@ -452,7 +417,7 @@ mod clone_tests {
value["name"] = json!("Copy");
let mut feature: crate::Feature = serde_json::from_value(value).unwrap();
let before = serde_json::to_string(&feature).unwrap();
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new(), &BTreeMap::new(), &crate::Document::default()).unwrap();
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new()).unwrap();
assert_eq!(serde_json::to_string(&feature).unwrap(), before.replace("\"Old\"", "\"New\""));
}
}
-1
View File
@@ -988,7 +988,6 @@ mod tests {
sketch,
report,
profiles: Vec::new(),
profiles_3d: Vec::new(),
ends: Default::default(),
}
}
-13
View File
@@ -1704,10 +1704,6 @@ pub enum FeatureKind {
#[serde(default = "all_profiles")]
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
/// Indices into the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`): each is
/// patched in the plane it lies in (#741).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
profiles_3d: Vec<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -3399,9 +3395,6 @@ pub struct Document {
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
/// Notes: text anchored to points in model space, drawn as screen-facing callouts (#1631).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<crate::notes::Note>,
/// What was hidden when the design was saved. A session takes it from here when the design
/// opens and keeps the live state itself (`browser.visibility`: saved, and undone like any
/// other change), so a document in a session leaves this empty; the session puts it back when
@@ -3431,9 +3424,6 @@ pub struct Visibility {
/// The browser's Origin folder is hidden (`None`: the app's preference decides).
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_hidden: Option<bool>,
/// Component Origin folders explicitly shown, by complete occurrence path. Hidden by default.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub shown_component_origins: Vec<Vec<u64>>,
/// Finished sketches whose closed profiles are not shaded, by feature id.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hidden_profiles: Vec<u64>,
@@ -3461,7 +3451,6 @@ impl Visibility {
&& self.shown_sketches.is_empty()
&& self.hidden_joints.is_empty()
&& self.origin_hidden.is_none()
&& self.shown_component_origins.is_empty()
&& self.hidden_profiles.is_empty()
&& self.shown_dimensions.is_empty()
&& self.locked_bodies.is_empty()
@@ -3644,7 +3633,6 @@ impl Document {
.chain(self.timeline_groups.iter().map(|g| g.id))
.chain(self.canvases.iter().map(|c| c.id))
.chain(self.point_clouds.iter().map(|c| c.id))
.chain(self.notes.iter().map(|n| n.id))
.chain(a.joints.iter().map(|j| j.id))
.chain(a.contact_sets.iter().map(|c| c.id))
.chain(a.studies.iter().map(|s| s.id))
@@ -3700,7 +3688,6 @@ impl Document {
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
notes: Vec::new(),
visibility: Visibility::default(),
}
}
+1 -5
View File
@@ -437,8 +437,6 @@ pub struct SolvedSketch {
pub sketch: solidcraft_sketch::Sketch,
pub report: SolveReport,
pub profiles: Vec<Profile>,
/// Planar closed loops the 3D sketch curves take part in, in planes of their own (#741).
pub profiles_3d: Vec<solidcraft_sketch::Profile3D>,
/// Open and untied ends of its profile curves (where a profile can fail).
pub ends: solidcraft_sketch::EndReport,
}
@@ -2336,7 +2334,6 @@ impl Model {
name: last.name.clone(),
plane: last.plane,
profiles: find_profiles(&sketch),
profiles_3d: solidcraft_sketch::find_planar_3d_profiles(&sketch, last.plane),
ends: solidcraft_sketch::loose_ends(&sketch),
sketch,
report,
@@ -9342,10 +9339,9 @@ fn eval_sketch(
append_warning(warning, profiles_cut_short_note());
}
let profiles = search.profiles;
let profiles_3d = solidcraft_sketch::find_planar_3d_profiles(&sk, plane);
let ends = solidcraft_sketch::loose_ends(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, profiles_3d, ends }));
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
Ok(())
}
-1
View File
@@ -142,7 +142,6 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: 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.
+5 -12
View File
@@ -8,20 +8,13 @@ use solidcore_record::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, profiles_3d, body, edges, crown, continuity, points } => {
FeatureKind::Patch { sketch, profiles, body, edges, crown, continuity, points } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
// An empty index list means the caller asked only for 3D loops (#741).
if !matches!(profiles, ProfileSel::Indices { indices } if indices.is_empty()) {
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
for &i in profiles_3d {
let p = ss.profiles_3d.get(i).ok_or_else(|| DocError::Invalid(format!("sketch {sk} has no 3D profile {i}")))?;
made.push(kernel::patch_region(&p.plane, &p.region)?);
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
if !edges.is_empty() {
@@ -40,7 +33,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
made.push(kernel::patch_edges_with(&b.body, &crate::plain(edges), &spec)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region, a 3D sketch loop, or a loop of edges".into()));
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
}
for (k, b) in made.into_iter().enumerate() {
let name = new_name(st, f, k);
@@ -1,4 +0,0 @@
//! Component Origin folders explicitly shown save their complete occurrence paths
//! (`Visibility::shown_component_origins`; they are hidden by default). Older designs have none:
//! nothing new, they round-trip as they are. The last release warns about the newer format
//! number, opens what it can read and keeps the rest.
@@ -1,9 +0,0 @@
//! A joint can say which assembly constraint it was made as (`"constraint": "mate"`, `flush`,
//! `distance`, `concentric`, `insert`, `parallel`, `perpendicular`, `angle`, `tangent` or
//! `point_on_axis`), written only for a joint made with `assembly.constraint.create`. The joint
//! itself is an ordinary joint of the kind the rule needs (a mate is a planar joint), so the solver
//! never reads the field; it names the rule in the browser and the Constrain dialog, and makes the
//! joint start where its parts are when it is changed. Older designs need no transformation: their
//! joints have no constraint and are written as before. The last release opens a design with such
//! a joint with a warning and keeps the field it can't read; there the joint still solves as its
//! kind.
-4
View File
@@ -1,4 +0,0 @@
//! A design saves its notes (`notes`): text anchored to a point in model space, drawn in the
//! viewport as a small screen-facing callout. An older design has none, and opens exactly as
//! before. The last release refuses a design that has notes and asks for an update, instead of
//! opening it and losing them on the next save.
@@ -1,5 +0,0 @@
//! A sketch text's style can say where its block of lines sits up and down against its anchor
//! (`at`, or the curve it follows): `vertical_align` is `top`, `middle` or `bottom`, and is left
//! out for the first line's baseline, where every text sat before. Older designs need no rewrite:
//! without the key a text stays on its baseline. The last release opens a design that carries the
//! key with a warning, keeps what it can't read, and draws such a text on its baseline.
@@ -1,5 +0,0 @@
//! A surface patch can patch planar loops of 3D sketch curves: `profiles_3d` holds indices into
//! the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`), each patched in the plane it
//! lies in (#741). Older designs need no transformation: without the field the patch takes its
//! profiles from the sketch's 2D profile list exactly as before. The last release refuses a
//! design saved with this (the newer format number).
-86
View File
@@ -429,75 +429,6 @@ impl JointKind {
}
}
/// The assembly constraint a joint was made as (`assembly.constraint.create`): the rule in the
/// words of the Constrain dialog. The joint itself is an ordinary joint of the kind the rule needs
/// (a mate is planar, a concentric constraint cylindrical); the solver never reads this.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Constraint {
/// Two planes against each other (planar), `offset` apart.
Mate,
/// Two planes facing the same way (planar, flipped), `offset` apart.
Flush,
/// A plane `offset` from a plane (planar) or a point `offset` from a plane (point on plane).
Distance,
/// Two axes on one line (cylindrical): B slides and turns along it.
Concentric,
/// Two round edges at one centre on one axis (revolute): B only turns.
Insert,
/// Two planes or axes parallel.
Parallel,
/// Two planes or axes square to each other.
Perpendicular,
/// Two planes or axes at `angle`.
Angle,
/// A cylinder resting on a plane (line on plane, `offset` its radius).
Tangent,
/// A point on an axis (point on line).
PointOnAxis,
}
impl Constraint {
pub const ALL: [Constraint; 10] = [
Constraint::Mate,
Constraint::Flush,
Constraint::Distance,
Constraint::Concentric,
Constraint::Insert,
Constraint::Parallel,
Constraint::Perpendicular,
Constraint::Angle,
Constraint::Tangent,
Constraint::PointOnAxis,
];
/// The rule's command name (`mate`, `point_on_axis`).
pub fn name(self) -> &'static str {
match self {
Constraint::Mate => "mate",
Constraint::Flush => "flush",
Constraint::Distance => "distance",
Constraint::Concentric => "concentric",
Constraint::Insert => "insert",
Constraint::Parallel => "parallel",
Constraint::Perpendicular => "perpendicular",
Constraint::Angle => "angle",
Constraint::Tangent => "tangent",
Constraint::PointOnAxis => "point_on_axis",
}
}
/// Case, spaces, `_` and `-` are ignored; `coincident` and `axis` are also taken.
pub fn parse(s: &str) -> Option<Constraint> {
let key = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
match key.as_str() {
"coincident" => Some(Constraint::Mate),
"axis" => Some(Constraint::Concentric),
_ => Constraint::ALL.into_iter().find(|c| c.name().replace('_', "") == key),
}
}
}
/// A joint between two occurrences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Joint {
@@ -531,17 +462,6 @@ pub struct Joint {
/// 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>>,
/// The assembly constraint it was made as; `None` for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraint: Option<Constraint>,
}
impl Joint {
/// Whether its values start where its parts are when it is made or changed, so B moves no
/// further than the joint needs: a relation, or any joint made as an assembly constraint.
pub fn starts_where_it_is(&self) -> bool {
self.kind.is_relation() || self.constraint.is_some()
}
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
@@ -2607,11 +2527,6 @@ impl Document {
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
Ok(mat_mul(&t, &f))
}
/// The radius of the round face a circle origin is on (mm; an error when it is on none).
pub fn origin_radius(&self, st: &ModelState, o: &JointOrigin) -> Result<f64> {
circle_at(self, st, o).map(|c| c.radius)
}
}
#[path = "joints_cam.rs"]
@@ -2841,7 +2756,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
.collect();
let links =
-1
View File
@@ -945,7 +945,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: 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.
-1
View File
@@ -94,7 +94,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
-2
View File
@@ -49,8 +49,6 @@ mod lineage;
pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
pub mod notes;
pub use notes::Note;
mod name_lookup;
mod names;
pub mod naming;
+1 -3
View File
@@ -409,14 +409,12 @@ impl SavedModel {
let mut sketches = Vec::new();
for s in list("sketches")? {
let sketch: solidcraft_sketch::Sketch = serde_json::from_value(parse(s.get("sketch"))?).map_err(|_| bad("saved model: sketch"))?;
let plane: Plane = serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?;
sketches.push(Arc::new(SolvedSketch {
feature: s.get("feature").and_then(Json::as_u64).ok_or_else(|| bad("saved model: sketch"))?,
name: text(s.get("name"))?,
plane,
plane: serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?,
report: serde_json::from_value(parse(s.get("report"))?).map_err(|_| bad("saved model: sketch"))?,
profiles: find_profiles(&sketch),
profiles_3d: solidcraft_sketch::find_planar_3d_profiles(&sketch, plane),
ends: solidcraft_sketch::loose_ends(&sketch),
sketch,
}));
-39
View File
@@ -350,45 +350,6 @@ fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
}
/// #13464: a mesh's flat patch outline drops a corner part way along a side, even when single
/// precision has moved it off the line (as an STL's corners are), and when the loop starts there.
#[test]
fn a_mesh_outline_drops_a_rounded_corner_part_way_along_a_side() {
let single = |x: f64| f64::from(x as f32);
// a, c and d make a triangle; b lies on its side a–c up to single-precision rounding.
let a = Vec3::new(0.0, 0.0, 0.0);
let b = Vec3::new(single(0.3), single(0.33), 0.0);
let c = Vec3::new(1.0, single(1.1), 0.0);
let d = Vec3::new(0.0, 2.0, 0.0);
let off = (b - a).cross(c - a).len() / (c - a).len();
assert!(off > 0.0 && off < 1e-7, "b is off the line only by rounding: {off}");
// b first, so the outline's loop starts at it.
let mesh =
solidcraft_geom::Mesh { positions: vec![b, c, d, a], triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(0.3, 0.8, 0.0));
assert_eq!(loops.len(), 1, "{loops:?}");
let corners = loops.first().unwrap();
assert_eq!(corners.len(), 4, "three corners and the first again: {corners:?}");
assert!(!corners.iter().any(|p| p.dist(b) < 1e-12), "the corner on the side is gone: {corners:?}");
for p in [a, c, d] {
assert!(corners.iter().any(|q| q.dist(p) < 1e-12), "{p:?} is a corner: {corners:?}");
}
}
/// #13464: far from the origin, a small flat patch keeps its corners. A tolerance that grew with
/// the distance from the origin alone took every corner of a 1 × 1 square at x = 1 000 000 for one
/// on a straight side, and the face projected to nothing.
#[test]
fn a_small_mesh_patch_far_from_the_origin_keeps_its_corners() {
let x = 1_000_000.0;
let corners = [Vec3::new(x, 0.0, 0.0), Vec3::new(x + 1.0, 0.0, 0.0), Vec3::new(x + 1.0, 1.0, 0.0), Vec3::new(x, 1.0, 0.0)];
let mesh =
solidcraft_geom::Mesh { positions: corners.to_vec(), triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(x + 0.5, 0.5, 0.0));
assert_eq!(loops.len(), 1, "{loops:?}");
assert_eq!(loops.first().map(Vec::len), Some(5), "four corners and the first again: {loops:?}");
}
/// A ring turned from a rectangle on YZ about Z: each side of the rectangle makes one round face.
fn ring() -> Document {
let doc = Document {
-1
View File
@@ -1606,7 +1606,6 @@ mod tests {
sketch,
report,
profiles,
profiles_3d: Vec::new(),
ends: Default::default(),
};
let full_scan = |q: Vec2| {
-76
View File
@@ -1,76 +0,0 @@
//! Notes: text anchored to a point in model space, drawn in the viewport as a small
//! screen-facing callout (#1631). Personal design documentation — reminders, decisions,
//! warnings — saved with the design, not collaboration.
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
/// Longest text a note may hold (characters).
pub const MAX_NOTE_CHARS: usize = 4000;
/// A note: an anchor point in model space (mm, design coordinates) and the text shown at
/// its projection. The callout always faces the screen because it is drawn in screen space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: u64,
/// The point the note is anchored to (design coordinates, mm).
pub anchor: Vec3,
/// The note's text (may span lines).
pub text: String,
/// Hidden notes are kept but not drawn (`browser.visibility` shows and hides them).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
}
impl Note {
/// True when the text is usable: not empty and within [`MAX_NOTE_CHARS`].
pub fn text_is_valid(text: &str) -> bool {
!text.trim().is_empty() && text.chars().count() <= MAX_NOTE_CHARS
}
}
#[cfg(test)]
mod tests {
use super::*;
fn note(id: u64) -> Note {
Note { id, anchor: Vec3::new(1.0, 2.0, 3.0), text: format!("Check clearance here.\nLine {id}"), hidden: false }
}
#[test]
fn a_design_with_notes_survives_a_round_trip() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
doc.notes.push(Note { id: 2, anchor: Vec3::new(-4.0, 0.0, 6.5), text: "Adjust after the test print".into(), hidden: true });
let json = serde_json::to_string(&doc).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert_eq!(back, doc);
assert_eq!(back.notes.len(), 2);
assert!(back.notes[1].hidden);
}
#[test]
fn a_design_saved_before_notes_loads_with_none() {
// An older design file: no `notes` key at all.
let json = serde_json::to_string(&crate::Document::new("Old")).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert!(back.notes.is_empty());
}
#[test]
fn hidden_notes_are_not_written_when_they_are_visible() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
let json = serde_json::to_string(&doc).unwrap();
assert!(!json.contains("hidden"));
assert!(serde_json::from_str::<crate::Document>(&json).unwrap().notes[0].text.starts_with("Check"));
}
#[test]
fn note_text_must_be_non_empty_and_bounded() {
assert!(Note::text_is_valid("Extra thickness added for strength."));
assert!(!Note::text_is_valid(" "));
assert!(!Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS + 1)));
assert!(Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS)));
}
}
-1
View File
@@ -161,7 +161,6 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
named_views,
visibility,
point_clouds,
notes: _,
} = a;
(Arc::ptr_eq(render, &b.render) || *render == b.render)
&& *format == b.format
+2 -11
View File
@@ -545,16 +545,7 @@ mod tests {
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
let ss = SolvedSketch {
feature: 1,
name: "ordinary circle".into(),
plane: Plane::XY,
sketch,
report,
profiles,
profiles_3d: Vec::new(),
ends: Default::default(),
};
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() };
assert_eq!(ss.profiles.len(), 1);
let indices = vec![0; 600];
let selection = ProfileSel::Indices { indices: indices.clone() };
@@ -602,7 +593,7 @@ mod tests {
}
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, profiles_3d: Vec::new(), ends: Default::default() }
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() }
}
#[test]
+3 -10
View File
@@ -618,8 +618,7 @@ fn circular(samples: &[Vec2]) -> Option<(LinkGeom, f64)> {
sweep += d;
}
let turn = sweep.abs();
// A NaN turn is not refused here, as before.
if !turn.is_nan() && !(CIRCLE_MIN_TURN..=std::f64::consts::TAU + 1e-6).contains(&turn) {
if turn > std::f64::consts::TAU + 1e-6 || turn < CIRCLE_MIN_TURN {
return None;
}
if first.dist(last) <= tol {
@@ -1489,15 +1488,9 @@ const MESH_POLYGON_CORNERS: usize = 12;
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
/// distance from the origin, not of the circle's size; and never more loosely than 1e-4 of the
/// outline's own size, so a small outline far from the origin is not taken for a circle it misses.
/// size, not of the circle's.
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
let (lo, hi) = pts.iter().fold(
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)),
|(lo, hi), p| (lo.min(*p), hi.max(*p)),
);
let size = lo.dist(hi);
let within = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(size * 1e-4);
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
if pts.len() > MESH_POLYGON_CORNERS + 1
&& let Some(e) = tilted_circle(plane, pts, Some(within))
{
+4 -13
View File
@@ -379,13 +379,7 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
steps += 1;
}
if v == start && pts.len() >= 4 {
// A corner off the line within the single precision a mesh file keeps is on it, but
// never one farther off than 1e-4 of the loop's own size: far from the origin a small
// patch's real corners stay.
let far = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let (lo, hi) = pts.iter().fold((far, -far), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
let precision = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(lo.dist(hi) * 1e-4);
loops.extend(turning_corners(&pts, precision));
loops.extend(turning_corners(&pts, size * 1e-9));
}
}
}
@@ -394,18 +388,15 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
/// A closed loop (its first point again at its end) without the corners where it runs straight
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
/// meets it. A corner within `tol` of the line through its neighbours runs straight on. None when
/// fewer than three corners turn.
/// meets it. None when fewer than three corners turn.
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
let ring = pts.get(..pts.len().saturating_sub(1))?;
let n = ring.len();
let turns = |i: usize| {
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
// How far the corner lies off the line from the one before it to the one after.
let span = *next - *prev;
let off = (*here - *prev).cross(span).len() / span.len().max(1e-300);
span.len() <= tol || off > tol
let (a, b) = (*here - *prev, *next - *here);
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
};
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
if out.len() < 3 {
@@ -1,188 +0,0 @@
//! Assembly constraints (`assembly.constraint.create`): a geometric rule between a reference on
//! each of two parts (a mate, a flush, concentric holes…), in the words of the Constrain dialog.
//! Each rule is kept as a joint of the kind it needs, made where the parts are and tagged with the
//! rule (`Joint::constraint`), so the one joint solver places, counts and checks it with the
//! others: several constraints between one pair add up, and b keeps every motion they leave free.
use serde_json::Value;
use solidcraft_doc::joints::{Constraint, JointKind, JointOrigin, Snap};
use super::{CommandSpec, add_joint, new_joint, origin_param, value};
use crate::params::{bad, bool_, str_};
use crate::schema::joints::{CreateConstraintParams, JointAddedResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CREATE_CONTRACT: TypedContract<CreateConstraintParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
.icon("joint")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type: mate|flush|distance|concentric|insert|parallel|perpendicular|angle|tangent|point_on_axis; a, b: references, as joint.create's \
origins (a face or a datum plane for a plane, a circle or an axis for an axis, a point; b moves); offset?: the gap (mm or an expression; needed for distance); \
angle?: deg or an expression (needed for angle); flip?: turn b over; name? (default: the rule and a number, Mate1); quiet?. \
Kept as a joint of the kind the rule needs (mate and flush planar, distance planar or point on plane, concentric cylindrical, \
insert revolute, tangent line on plane, point on axis point on line), made where the parts are, so b moves no further than the \
rule needs → joint, name, dof, conflicts, fixed as joint.create",
)];
/// What a reference is, for the rules that need one kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Shape {
/// A planar face or a frame: its plane and normal.
Plane,
/// A round face's or edge's centre and axis.
Round,
/// A point (a vertex, an edge, a sketch point): its position.
Point,
/// A datum or construction axis: a line, with no rim or radius.
Axis,
}
fn shape(o: &JointOrigin) -> Shape {
match o.snap {
Snap::FaceCenter { .. } | Snap::Frame { .. } | Snap::Plane { .. } => Shape::Plane,
Snap::Axis { .. } => Shape::Axis,
Snap::CircleCenter { .. } => Shape::Round,
Snap::Point { .. } | Snap::Edge { .. } => Shape::Point,
}
}
/// How a rule is kept: the joint's kind, whether b is turned to face along a (`flip`), and whether
/// the two references were given the other way round (the plane or axis goes on side a).
struct Kept {
kind: JointKind,
/// `Some` where the rule fixes the facing; `None` keeps the parts' facing now.
flip: Option<bool>,
swap: bool,
}
fn kept(c: Constraint, a: Shape, b: Shape, cmd: &str) -> Result<Kept> {
use Shape::*;
let fixed = |kind, flip| Ok(Kept { kind, flip: Some(flip), swap: false });
let facing = |kind| Ok(Kept { kind, flip: None, swap: false });
// A line: a round face's or edge's axis, or an axis itself.
let axial = |s: Shape| matches!(s, Round | Axis);
// A rule between one plane or axis and something else: the plane or axis on side a.
let onto = |kind, want: &dyn Fn(Shape) -> bool, other_ok: &dyn Fn(Shape) -> bool| {
if want(a) && other_ok(b) {
Ok(Kept { kind, flip: Some(false), swap: false })
} else if want(b) && other_ok(a) {
Ok(Kept { kind, flip: Some(false), swap: true })
} else {
Err(bad(cmd, format!("{} needs {}", c.name(), needs(c))))
}
};
match (c, a, b) {
(Constraint::Mate, Plane, Plane) => fixed(JointKind::Planar, false),
(Constraint::Flush, Plane, Plane) => fixed(JointKind::Planar, true),
(Constraint::Distance, Plane, Plane) => facing(JointKind::Planar),
(Constraint::Distance, ..) => onto(JointKind::PointOnPlane, &|s| s == Plane, &|s| matches!(s, Round | Point)),
(Constraint::Concentric, ..) if axial(a) && axial(b) => facing(JointKind::Cylindrical),
(Constraint::Insert, Round, Round) => facing(JointKind::Revolute),
(Constraint::Parallel, ..) => facing(JointKind::Parallel),
(Constraint::Perpendicular, ..) => fixed(JointKind::Perpendicular, false),
(Constraint::Angle, ..) => fixed(JointKind::Angle, false),
(Constraint::Tangent, ..) => onto(JointKind::LineOnPlane, &|s| s == Plane, &|s| s == Round),
(Constraint::PointOnAxis, ..) => onto(JointKind::PointOnLine, &axial, &|s| s == Point),
_ => Err(bad(cmd, format!("{} needs {}", c.name(), needs(c)))),
}
}
/// The references a rule takes, for its refusal.
fn needs(c: Constraint) -> &'static str {
match c {
Constraint::Mate | Constraint::Flush => "two planar faces",
Constraint::Distance => "two planar faces, or a planar face and a point",
Constraint::Concentric => "two round faces, round edges or axes",
Constraint::Insert => "two round edges",
Constraint::Parallel | Constraint::Perpendicular | Constraint::Angle => "two faces or axes",
Constraint::Tangent => "a planar face and a round face",
Constraint::PointOnAxis => "a round face or edge (its axis) or an axis, and a point",
}
}
/// The rule's default name: `Mate1`, `Point on axis2`…, the first number no joint's name has.
fn default_name(s: &Session, c: Constraint) -> String {
let word = c.name().replace('_', " ");
let mut label = word.clone();
if let Some(first) = label.get(..1) {
label = first.to_ascii_uppercase() + label.get(1..).unwrap_or("");
}
let names: std::collections::BTreeSet<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
let n = (1..=s.doc.assembly.joints.len().saturating_add(1)).find(|n| !names.contains(format!("{label}{n}").as_str())).unwrap_or(1);
format!("{label}{n}")
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.constraint.create";
let c = str_(p, "type").and_then(Constraint::parse).ok_or_else(|| {
bad(cmd, "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis")
})?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
// The numbers are read before the references are checked, in the order the contract reads them.
for (key, angle) in [("offset", false), ("angle", true)] {
if let Some(v) = p.get(key) {
value(s, v, angle, cmd)?;
}
}
let rule = kept(c, shape(&a), shape(&b), cmd)?;
let (a, b) = if rule.swap { (b, a) } else { (a, b) };
if c == Constraint::Distance && p.get("offset").is_none() {
return Err(bad(cmd, "distance needs `offset`"));
}
if c == Constraint::Angle && p.get("angle").is_none() {
return Err(bad(cmd, "angle needs `angle`"));
}
let flip = {
let st = s.model.state();
// The way b faces a now: along (true) or against. A rule that fixes neither keeps it, so b is
// not turned over.
let along = || -> Result<bool> {
let za = s.doc.origin_world(&st, &a).map_err(|e| bad(cmd, e.to_string()))?;
let zb = s.doc.origin_world(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
Ok((0..3).map(|i| za[2][i] * zb[2][i]).sum::<f64>() > 0.0)
};
let mut flip = match rule.flip {
Some(f) => f,
None => along()?,
};
if bool_(p, "flip") == Some(true) {
flip = !flip;
}
// A cylinder resting on a plane needs its round face's radius (refused when it has none).
if c == Constraint::Tangent {
s.doc.origin_radius(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
}
flip
};
// The joint's own params: the kind and facing the rule needs, the rest as given.
let mut q = p.clone();
if let Some(m) = q.as_object_mut() {
m.remove("type");
m.remove("flip");
m.remove("values");
m.remove("value");
m.remove("limits");
m.insert("flip".into(), Value::Bool(flip));
// No name, `null` or blank text: the rule's own name (`Mate1`), not a joint's.
if !m.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty()) {
m.insert("name".into(), Value::String(default_name(s, c)));
}
}
let mut j = new_joint(s, &q, rule.kind, a, b, cmd)?;
if c == Constraint::Tangent {
// The gap is kept as the offset's expression; the radius is added to it whenever the joints
// are solved (`reexpress`, which `add_joint`'s solve runs), so the part follows its face.
j.exprs.offset = Some(j.exprs.offset.take().unwrap_or_else(|| format!("{} mm", j.offset)));
}
if j.offset != 0.0 && !j.offset.is_finite() {
return Err(bad(cmd, "`offset` must be finite"));
}
j.constraint = Some(c);
add_joint(s, j, &q)
}
+11 -71
View File
@@ -137,7 +137,7 @@ pub static COMMANDS: &[CommandSpec] = &[
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, o:4/origin, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state; component origin items use o:4/origin/XY etc. in items); \
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
),
CommandSpec::new("browser.expand_all", "Expand All", crate::checked!("browser.expand_all", expand_all))
@@ -187,10 +187,7 @@ fn keys(p: &Value, k: &str, cmd: &str) -> Result<Vec<String>> {
_ => None,
};
let v = v.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys")))?;
// Past the 256-character cap only the datum keys go through, each bounded by its own parser:
// an item's `o:<ids>/origin/<item>`, a folder's `o:<ids>/origin` - deep placements need them.
let past_cap = |x: &String| x.len() > 256 && crate::origins::item(x).is_none() && crate::origins::folder_path(x).is_none();
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || past_cap(x)) {
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || x.len() > 256) {
return Err(bad(cmd, format!("`{k}` has bad keys")));
}
Ok(v)
@@ -438,6 +435,8 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
reply(cmd, BrowserOrdered { param_warnings: Vec::new() })
}
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
/// A browser item whose visibility can change.
enum Shown {
Sketch(u64),
@@ -458,13 +457,10 @@ fn is_construction(k: &FeatureKind) -> bool {
/// The rule, the same for showing and for hiding: an exact name wins over a name that only
/// matches it by case, so the body called "Back Lid" is not the sketch called "Back lid"
/// (#6967). Among the exact matches the order is the one there always was: a feature (by id
/// or by name), then a body, then a joint. With no exact match the key may name a component
/// origin item by its key; else the name is compared without regard to case, as it always was:
/// a feature first, then an origin item.
/// or by name), then a body, then a joint. With no exact match the name is compared without
/// regard to case, as it always was: a feature first, then an origin item.
fn shown_item(s: &Session, world: &solidcraft_doc::ModelState, key: &str) -> Option<Shown> {
shown_item_exact(s, world, key)
.or_else(|| s.origin_geometry(key).map(|_| Shown::Origin(key.to_string())))
.or_else(|| shown_item_ignore_case(s, world, key))
shown_item_exact(s, world, key).or_else(|| shown_item_ignore_case(s, world, key))
}
/// The item a key names exactly: a feature, a body or a joint whose name is the key itself.
@@ -490,7 +486,7 @@ fn shown_item_ignore_case(s: &Session, world: &solidcraft_doc::ModelState, key:
if let Some(shown) = s.doc.find_feature(key).and_then(|f| shown_feature(world, f, key)) {
return Some(shown);
}
crate::origins::ITEMS.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
/// The item a feature is: a sketch, or construction geometry by name. A feature that is neither
@@ -513,11 +509,8 @@ fn shown_feature(world: &solidcraft_doc::ModelState, f: &solidcraft_doc::Feature
}
/// A browser folder or group key (`Visibility::hidden_folders`): `c0` or `o:<ids>`, then
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases`, `/origin` or `/g<group id>`.
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases` or `/g<group id>`.
pub(crate) fn tree_folder_key(k: &str) -> bool {
if crate::origins::folder_path(k).is_some() {
return true;
}
let Some((scope, last)) = k.rsplit_once('/') else { return false };
let scope_ok =
scope == "c0" || scope.strip_prefix("o:").is_some_and(|ids| ids.split('/').count() <= 64 && ids.split('/').all(|i| i.parse::<u64>().is_ok()));
@@ -634,28 +627,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
})
})
.collect::<Result<_>>()?;
// A component Origin folder's placement has to exist, and showing one is bounded.
for path in keys.iter().filter_map(|key| crate::origins::folder_path(key)) {
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
}
if visible {
let mut shown: BTreeSet<_> = s.visibility.shown_component_origins.iter().cloned().collect();
shown.extend(keys.iter().filter_map(|key| crate::origins::folder_path(key)));
if shown.len() > crate::origins::MAX_SHOWN {
return Err(bad(cmd, "at most 10000 component origins can be shown at once"));
}
}
for k in &keys {
if let Some(path) = crate::origins::folder_path(k) {
if visible {
if !s.visibility.shown_component_origins.contains(&path) {
s.visibility.shown_component_origins.push(path);
}
} else {
s.visibility.shown_component_origins.retain(|p| p != &path);
}
continue;
}
s.visibility.hidden_folders.retain(|h| h != k);
if !visible {
s.visibility.hidden_folders.push(k.clone());
@@ -761,7 +733,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
(Some("bodies"), None) => s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
(Some("origin"), None) => crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some("joints"), None) => s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)).collect(),
// Everything at once, for a single "show all" step: every body, sketch, construction
// item, origin item and joint.
@@ -769,7 +741,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
let mut all: Vec<Shown> = s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect();
all.extend(s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)));
all.extend(s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())));
all.extend(crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())));
all.extend(ORIGIN.iter().map(|o| Shown::Origin(o.to_string())));
all.extend(s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)));
all
}
@@ -934,9 +906,6 @@ fn collect_component_keys(s: &Session, prefix: &str, component: u64, root_extras
keys.closed.insert(format!("{prefix}/features"));
}
let occurrence = prefix.starts_with("o:");
if occurrence {
keys.closed.insert(format!("{prefix}/origin"));
}
for f in s.doc.features.iter().filter(|f| f.component == component) {
if let FeatureKind::Sketch { sketch, .. } = &f.kind
&& !sketch.patterns.is_empty()
@@ -2971,35 +2940,6 @@ mod tests {
assert!(plain.visibility.is_empty() && plain.visibility.origin_hidden.is_none());
}
/// A batch with a key that does not name anything (no such placement) fails whole and
/// changes nothing: the atomic contract, for the component origin keys too.
/// A visibility batch that fails validation changes nothing: every key is checked
/// before any of them is applied, and the failed command leaves the saved visibility
/// as it was (#7313).
#[test]
fn invalid_visibility_batches_are_atomic() {
let mut s = Session::default();
// The Origin folder hangs on a placement, so the key takes the placement's occurrence id
// (the create reply also carries the component id, which names no occurrence).
let created = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
let occurrence = created["occurrence"].as_u64().unwrap();
let folder = crate::origins::folder(&[occurrence]);
run(&mut s, "browser.visibility", json!({"tree_folders": [&folder], "visible": true}));
run(&mut s, "browser.visibility", json!({"items": [crate::origins::key(&[occurrence], "X")], "visible": false}));
let before = s.visibility.clone();
// The bad key fails the whole batch: the valid folder key changes nothing.
assert!(s.execute("browser.visibility", &json!({"tree_folders": ["c0/bodies", "o:999999/origin"], "visible": true})).is_err());
assert_eq!(s.visibility, before, "invalid batches must be atomic");
// The item form is atomic the same way.
assert!(s.execute("browser.visibility", &json!({"items": ["X", "o:999999/origin/X"], "visible": true})).is_err());
assert_eq!(s.visibility, before);
// And the bounds hold: no `o:` prefix, a zero or non-canonical id, the design's own scope.
for invalid in ["o:/origin", "o:0/origin", "o:01/origin", "c0/origin"] {
assert!(s.execute("browser.visibility", &json!({"tree_folders": [invalid], "visible": true})).is_err(), "{invalid}");
}
assert_eq!(s.visibility, before);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
+1 -1
View File
@@ -269,7 +269,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped, &BTreeMap::new(), &copy.doc)?;
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped)?;
if let FeatureKind::Sketch { plane, sketch } = &mut feature.kind {
// 3D wires carry their own coordinate space, independently of the sketch plane.
for wire in &mut sketch.wires {
+1 -1
View File
@@ -486,7 +486,7 @@ pub(super) fn build(
} else if f.body_names.is_empty() {
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
}
f.remap_clone(&features, &names, &bodies, &parameters, &occurrences, &scratch.doc)?;
f.remap_clone(&features, &names, &bodies, &parameters)?;
transfer_into_copy(&mut f.kind, &components)?;
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.path.is_empty()
@@ -24,7 +24,6 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
@@ -749,51 +749,3 @@ fn paste_new_of_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_ea
assert_eq!(corners(&s, part), lifted());
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
}
/// A construction axis through a nested placement's origin datum is copied with its datum path
/// rebuilt through the copy (the source's ancestors are not in the occurrence map); an axis
/// whose datum names a placement outside the copied tree refuses the copy - the reference could
/// not follow, and a dangling one would resolve against whatever then sits at those ids.
#[test]
fn cloned_datum_references_follow_the_copy() {
let mut s = Session::default();
let parent = component(&mut s, "Parent");
run(&mut s, "component.create", json!({"name": "Child", "parent": "Parent"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
let parent_occ = s.doc.occurrence_of(parent).unwrap().id;
let child_occ = s.doc.occurrences.iter().find(|o| o.parent == parent).unwrap().id;
run(&mut s, "component.activate", json!({"component": "Parent"}));
run(
&mut s,
"construct.axis.two_points",
json!({"a": format!("o:{parent_occ}/{child_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "DatumAxis"}),
);
let r = copy(&mut s, parent);
let new_comp = r["component"].as_u64().unwrap();
let new_parent_occ = r["occurrence"].as_u64().unwrap();
let new_child_occ = s.doc.occurrences.iter().find(|o| o.parent == new_comp).unwrap().id;
let axis =
s.doc.features.iter().find(|f| f.component == new_comp && matches!(f.kind, solidcraft_doc::FeatureKind::ConstructionAxis { .. })).unwrap();
let solidcraft_doc::FeatureKind::ConstructionAxis { def } = &axis.kind else { panic!("expected a construction axis: {:?}", axis.kind) };
let solidcraft_doc::construct::AxisDef::TwoPoints { a, .. } = def else { panic!("expected a two-points axis: {def:?}") };
match a {
solidcraft_doc::construct::GeoRef::Datum { datum } => {
assert_eq!(datum.path, vec![new_parent_occ, new_child_occ], "the datum names the copy's own nested placement");
}
other => panic!("expected a datum reference, got {other:?}"),
}
// An axis whose datum names a placement outside the copied tree refuses the copy. Outside is
// made at the root: `component.create` places under the active component (the paste left
// Parent active), and a datum key names its placement from the root, so a placement inside
// Parent would be unreachable to the resolver and inside the copy's map at once.
run(&mut s, "component.activate", json!({"component": 0}));
let outside = component(&mut s, "Outside");
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
let outside_occ = s.doc.occurrence_of(outside).unwrap().id;
run(&mut s, "component.activate", json!({"component": "Parent"}));
run(&mut s, "construct.axis.two_points", json!({"a": format!("o:{outside_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "OutsideAxis"}));
assert!(
s.execute("component.paste_new", &json!({"component": parent, "translate": [40, 0, 0]})).is_err(),
"a datum into another tree cannot follow the copy"
);
}
+22 -32
View File
@@ -17,7 +17,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{DesignLink, Document, FeatureKind, LinkedBodyProperties, UserMaterial};
use super::CommandSpec;
use super::design_link::{LinkState, absolute, link_presence, read_linked_file, resolve, same_file, stored};
use super::design_link::{absolute, resolve, stored};
use crate::params::{bad, str_, vec3};
use crate::schema::component_link::{
BreakLinkParams, BreakLinkResult, CopiesParams, CopiesResult, DesignInserted, ExtractParams, ExtractResult, InsertDesignParams,
@@ -112,9 +112,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(
"→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \
or names a device: it is not looked for), changed (whether the file is no longer what this design last read: Update Links \
reads it again; null when not found or the link is older than this record), then host (the network location); state (missing, \
unreadable, not_allowed: a network location not allowed or outside the folders this agent may use) and reason when the file \
can't be reached",
reads it again; null when not found or the link is older than this record), and then host (the network location) and reason",
),
];
@@ -213,12 +211,16 @@ fn remember_read(file: &Path, bytes: u64, feature: &solidcraft_io::StepFeature,
/// The bytes of the design at `file`, which must not be the design saved at `own`.
fn read_link(file: &Path, own: Option<&str>) -> Result<Vec<u8>> {
let shown = file.display().to_string();
if own.is_some_and(|o| same_file(Path::new(o), file)) {
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
}
// A network location only when its server is allowed, a device never: judged from the text
// before anything is read (on Windows, reading `\\host\share\x` connects to the host).
read_linked_file(file, own).map_err(|e| EngineError::Other(e.reason))
let design = solidcraft_io::limits::Kind::Design;
solidcraft_io::vfs::read_design_path(&shown, own, solidcraft_io::limits::max_design_bytes()).map_err(|e| {
let e = solidcraft_io::limits::explain(design, e).to_string();
EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") })
})
}
/// The solid bodies of the design at `file`, evaluated under `validation`, as an Import feature, with the digest of
@@ -555,7 +557,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
// A network or device path is judged from its text, as given, before anything reads it.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
let file = absolute(Path::new(given));
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) {
return Err(bad(cmd, "a design can't be linked into itself"));
}
if p.get("reuse").and_then(Value::as_bool).unwrap_or(false) {
@@ -797,9 +799,11 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
// A network or device path is judged from its text before the disk is asked anything.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
// The same path, or on native hosts an alias of this assembly (a symlink or a path with
// different case): the one self-link rule Insert Design and Update Links use too.
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
&& candidate == own
{
return Err(bad(cmd, "a design can't be linked into itself"));
}
if let Some(occurrence) = lone_placement(s, p, comp, cmd)? {
@@ -955,45 +959,33 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
let file = resolve(s.path.as_deref(), &l.path);
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
let path = stored(s.path.as_deref(), &file);
let shown = file.to_string_lossy().into_owned();
let file = file.to_string_lossy();
// A refused path is not looked for at all: on Windows asking whether a file on
// `\\host\share` exists already connects to the host.
match link_refusal(s.path.as_deref(), &l.path) {
None => {
let found = solidcraft_io::vfs::design_path_is_file(&shown, s.path.as_deref());
let found = solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref());
// Whether the file is no longer what this design last read (#9739): `null`
// when it isn't found, or the link keeps no digest (made before they were).
let changed = l
.file_sha256
.as_ref()
.filter(|_| found == Some(true))
.and_then(|kept| file_sha256(Path::new(&shown), s.path.as_deref()).map(|now| now != *kept));
// Why a file that can't be reached can't be: `None` when it can be read.
let (state, reason) = match link_presence(&file, s.path.as_deref()) {
Some(why) => (Some(why.state), Some(why.reason)),
None => (None, None),
};
LinkInfo::Looked { component: *c, name, path, file: shown, found, changed, state, reason }
.and_then(|kept| file_sha256(Path::new(file.as_ref()), s.path.as_deref()).map(|now| now != *kept));
LinkInfo::Looked { component: *c, name, path, file: file.into_owned(), found, changed }
}
Some(solidcraft_io::vfs::Remote::Network(host)) => LinkInfo::Network {
component: *c,
name,
path,
file: shown,
file: file.into_owned(),
found: None,
state: LinkState::NotAllowed,
reason: format!("on the network location \\\\{host}, which is not allowed"),
host,
},
Some(solidcraft_io::vfs::Remote::Device) => LinkInfo::Device {
component: *c,
name,
path,
file: shown,
found: None,
state: LinkState::Unreadable,
reason: "names a device, not a file".into(),
},
Some(solidcraft_io::vfs::Remote::Device) => {
LinkInfo::Device { component: *c, name, path, file: file.into_owned(), found: None, reason: "names a device, not a file".into() }
}
}
})
.collect();
@@ -1138,8 +1130,6 @@ mod tests {
std::fs::remove_file(&part).unwrap();
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
assert!(err.contains("plate"), "{err}");
let links = run(&mut b, "component.links", json!({}));
assert_eq!((&links["links"][0]["found"], &links["links"][0]["state"]), (&json!(false), &json!("missing")), "{links}");
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
// Break Link keeps the bodies and forgets the file.
run(&mut b, "component.break_link", json!({"component": "plate"}));
+8 -34
View File
@@ -8,7 +8,7 @@
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_doc::construct::{AxisDef, DatumItem, GeoRef, PointDef};
use solidcraft_doc::construct::{AxisDef, GeoRef, PointDef};
use solidcraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
@@ -171,7 +171,7 @@ pub(crate) fn is_model_axis(v: &Value) -> bool {
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
let Some(v) = v.filter(|v| is_model_axis(v)) else { return Ok(None) };
let g = geo(s, Some(v), cmd, "axis")?;
let (o, d) = solidcraft_doc::construct::model_axis_in(Some(&s.doc), &*s.world_state()?, &g, 0).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
let (o, d) = solidcraft_doc::construct::model_axis(&*s.world_state()?, &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
Ok(Some((g, o, d)))
}
@@ -265,47 +265,21 @@ pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Resu
}
}
Ok(GeoForm::Point(p)) => GeoRef::Point { p },
Ok(GeoForm::Plane(plane)) => {
// A bare component origin datum key (`o:2/origin/O`, `o:2/origin/X`, `o:2/origin/XY`)
// names a point or an axis as often as a plane, and only the plane items reach
// `plane_param`'s own datum branch. A feature of the same name still wins (the one
// resolution order), so the string is left to `plane_param` then; the O, X, Y and Z
// items are saved as their datum, so the reference follows the placement.
let datum = plane
.as_str()
.map(str::trim)
.filter(|k| s.doc.find_feature(k).is_none())
.and_then(crate::origins::datum)
.filter(|d| matches!(d.item, DatumItem::O | DatumItem::X | DatumItem::Y | DatumItem::Z));
match datum {
Some(datum) => GeoRef::Datum { datum },
None => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
}
}
Ok(GeoForm::Plane(plane)) => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
Ok(GeoForm::Axis(a)) => {
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
GeoRef::World { axis: a.to_ascii_uppercase() }
} else {
match s.doc.find_feature(a).map(|f| &f.kind) {
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
// A component origin axis: saved as its datum, so it follows the placement.
_ => match crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
Some(datum) => GeoRef::Datum { datum },
None => return Err(bad(cmd, format!("no axis `{a}`"))),
},
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
}
}
}
Ok(GeoForm::ConstructPoint(n)) => {
match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
// A component origin's O point: its datum, so it follows the placement.
_ => match crate::origins::datum(n).filter(|d| d.item == DatumItem::O) {
Some(datum) => GeoRef::Datum { datum },
None => return Err(bad(cmd, format!("no construction point `{n}`"))),
},
}
}
Ok(GeoForm::ConstructPoint(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
},
Err(GeoFormError::Saved(e)) => return Err(bad(cmd, format!("`{what}`: {e}"))),
Err(GeoFormError::Unknown) => return Err(bad(cmd, format!("`{what}` must be {REF}"))),
};
-207
View File
@@ -1,207 +0,0 @@
//! SOLID ▸ CONSTRUCT: Plane, Axis and Point. They have no methods of their own. Given
//! references, they try their family's CONSTRUCT commands (`construct.plane.*`,
//! `construct.axis.*`, `construct.point.*`, in the order the commands are listed) with the
//! references in each command's reference parameters, and build with the first that takes them.
//! A method added to a family is tried as well, with nothing to change here.
use serde_json::{Map, Value, json};
use super::CommandSpec;
use crate::params::bad;
use crate::schema::construct::{ConstructAutoParams, ConstructAutoResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CONTRACT: TypedContract<ConstructAutoParams> = TypedContract::new();
const PARAMS: &str = "refs: 1-3 refs in any order; with?: {the method's other parameters: offset, angle, t, name…}. Builds with the first of the family's CONSTRUCT commands that takes the refs in its reference parameters (and every key of `with`). Returns method: the command used";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.plane", "Plane", crate::checked!("construct.plane", plane))
.at("SOLID", "CONSTRUCT")
.icon("plane_auto")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.axis", "Axis", crate::checked!("construct.axis", axis))
.at("SOLID", "CONSTRUCT")
.icon("axis")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.point", "Point", crate::checked!("construct.point", point))
.at("SOLID", "CONSTRUCT")
.icon("point")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
];
/// Whether `id` is one of the tools that choose their method (`construct.plane`…).
pub fn is_construct_tool(id: &str) -> bool {
COMMANDS.iter().any(|c| c.id == id)
}
/// The methods of `tool` (`construct.plane` → `construct.plane.offset`, `construct.plane.midplane`
/// …), in the order the commands are listed: the order they are tried in. Planned commands are
/// left out.
pub fn construct_methods(tool: &str) -> Vec<&'static CommandSpec> {
let prefix = format!("{tool}.");
super::command_specs().into_iter().filter(|c| c.id.starts_with(&prefix) && c.planned.is_none() && c.contract.is_some()).collect()
}
/// How a reference parameter reads a reference (its schema type).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Form {
/// Any `GeoRef` form, as given.
Geo,
/// `PlaneOrFace`: a plane as such (`{plane: p}` given as `p`) or `{face}`.
Plane,
/// `AxisLine`: an axis as such (`{axis: a}` given as `a`), `{edge}` or `{face}`.
Axis,
}
/// A parameter of a method that takes a reference: its key, how it reads one, and whether the
/// method needs it.
struct Slot {
key: String,
form: Form,
required: bool,
}
/// The schema type a property refers to, directly or as one of its `anyOf`/`oneOf` (an
/// optional parameter's).
fn form_of(property: &Value) -> Option<Form> {
let named = |v: &Value| match v.get("$ref").and_then(Value::as_str).and_then(|r| r.rsplit('/').next()) {
Some("GeoRef") => Some(Form::Geo),
Some("PlaneOrFace") => Some(Form::Plane),
Some("AxisLine") => Some(Form::Axis),
_ => None,
};
named(property).or_else(|| ["anyOf", "oneOf"].iter().filter_map(|k| property.get(*k).and_then(Value::as_array)).flatten().find_map(named))
}
/// The reference parameters of `spec`, read from its input schema.
fn slots(spec: &CommandSpec) -> Vec<Slot> {
let Some(contract) = spec.contract else { return Vec::new() };
let schema = contract.input_schema();
let root = schema.as_value();
let required: Vec<&str> =
root.get("required").and_then(Value::as_array).map(|r| r.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let Some(properties) = root.get("properties").and_then(Value::as_object) else { return Vec::new() };
properties
.iter()
.filter_map(|(key, p)| form_of(p).map(|form| Slot { key: key.clone(), form, required: required.contains(&key.as_str()) }))
.collect()
}
/// `r` as a parameter of `form` reads it.
fn as_form(r: &Value, form: Form) -> Value {
let key = match form {
Form::Geo => return r.clone(),
Form::Plane => "plane",
Form::Axis => "axis",
};
match r.as_object() {
Some(o) if o.len() == 1 => o.get(key).cloned().unwrap_or_else(|| r.clone()),
_ => r.clone(),
}
}
/// Every way to put each of `refs` in its own slot of `slots`, with every required slot filled:
/// the slot index of each reference. The first ways keep the references in the slots' order.
fn placements(refs: usize, slots: &[Slot]) -> Vec<Vec<usize>> {
/// Extend the placement in `at` by one reference, in every free slot, and keep each complete
/// one that fills every required slot.
fn go(refs: usize, slots: &[Slot], at: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
if at.len() == refs {
if slots.iter().enumerate().all(|(i, s)| !s.required || at.contains(&i)) {
out.push(at.clone());
}
return;
}
for i in 0..slots.len() {
if !at.contains(&i) {
at.push(i);
go(refs, slots, at, out);
at.pop();
}
}
}
let mut out = Vec::new();
if refs <= slots.len() {
go(refs, slots, &mut Vec::with_capacity(refs), &mut out);
}
out
}
/// The parameters of `spec` with `refs` placed as `at` says, and `with`.
fn params(refs: &[Value], slots: &[Slot], at: &[usize], with: &Map<String, Value>) -> Value {
let mut p = with.clone();
for (r, i) in refs.iter().zip(at) {
if let Some(slot) = slots.get(*i) {
p.insert(slot.key.clone(), as_form(r, slot.form));
}
}
Value::Object(p)
}
/// The first method of `tool` that builds from `refs` with `with`, tried on a scratch copy of
/// `s`: its command and parameters. The error, when none does, is the first a method gave whose
/// parameters fitted, else a list of what can be built from.
pub fn construct_method(s: &Session, tool: &str, refs: &[Value], with: &Map<String, Value>) -> Result<(&'static str, Value)> {
let mut first_error = None;
for spec in construct_methods(tool) {
let Some(contract) = spec.contract else { continue };
let slots = slots(spec);
for at in placements(refs.len(), &slots) {
let p = params(refs, &slots, &at, with);
if !contract.check(&p).is_empty() {
continue;
}
let mut trial = s.scratch();
match spec.run_nested(&mut trial, &p) {
Ok(_) => return Ok((spec.id, p)),
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
}
Err(first_error.unwrap_or_else(|| {
let names: Vec<&str> = construct_methods(tool).iter().map(|c| c.label).collect();
bad(tool, format!("no method takes these references; the methods are: {}", names.join(", ")))
}))
}
/// Run `tool` (`construct.plane`…): build with its first method that takes `refs` and `with`, as
/// one undo step, and name the method in the reply.
fn build(s: &mut Session, tool: &str, p: &Value) -> Result<Value> {
// The contract has checked `refs` and `with`; this reads them as given.
let refs = p.get("refs").and_then(Value::as_array).cloned().unwrap_or_default();
let with = p.get("with").and_then(Value::as_object).cloned().unwrap_or_default();
let (method, params) = construct_method(s, tool, &refs, &with)?;
let spec = super::find_command(method).ok_or_else(|| bad(tool, format!("no command `{method}`")))?;
let mut reply = spec.run_nested(s, &params)?;
if let Value::Object(m) = &mut reply {
m.insert("method".into(), json!(method));
}
Ok(reply)
}
/// `construct.plane`.
fn plane(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.plane", p)
}
/// `construct.axis`.
fn axis(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.axis", p)
}
/// `construct.point`.
fn point(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.point", p)
}
+1 -210
View File
@@ -2,15 +2,9 @@
//! link to come): where a link keeps its file and how it finds it again. A link keeps the file
//! relative to the linking design's folder once that design is saved, so a folder of designs can
//! move together; before the design has a file the path is kept whole.
//!
//! Reading a linked design ([`read_linked_file`]) gives its bytes or its [`LinkState`] with the
//! reason, in one set of state words every kind of link reports.
use std::io::ErrorKind;
use std::path::{Component as PathPart, Path, PathBuf};
use schemars::JsonSchema;
use serde::Serialize;
use solidcraft_doc::{DesignLink, Document};
/// The folder relative link paths are kept against: the design's own, once it has a file.
@@ -74,120 +68,7 @@ pub(super) fn resolve(design: Option<&str>, link: &str) -> PathBuf {
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: rebase(&l.path, from, to), ..l.clone() })).collect()
}
/// One link's path, kept for a design saved at `from`, as a design saved at `to` keeps it: the
/// one Save As rule every kind of link follows.
pub(super) fn rebase(link: &str, from: Option<&str>, to: &str) -> String {
stored(Some(to), &resolve(from, link))
}
/// Whether two paths name one file: the same path, or (on native hosts) the same file through a
/// symlink or a path with different case. A design can't link itself under either.
pub(super) fn same_file(a: &Path, b: &Path) -> bool {
absolute(a) == absolute(b) || matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y)
}
/// Where a link stands against its file: the one set of state words every kind of link reports.
/// Reading tells [`LinkState::Missing`], [`LinkState::Unreadable`] and [`LinkState::NotAllowed`]
/// apart; whether a design that was read is [`LinkState::UpToDate`] or [`LinkState::OutOfDate`]
/// is the link's own comparison. A design a newer SolidCraft saved is not a state of its own: it
/// opens, and its `newer_file` says what this build couldn't read (#12166).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum LinkState {
/// The file holds what the link last took from it.
UpToDate,
/// The file changed since the link last took from it.
OutOfDate,
/// There is no file at the link's path.
Missing,
/// The file is there but isn't a design this build can read, or the path names a device.
Unreadable,
/// The file is on a network location that is not allowed, or outside the folders this agent
/// may use.
NotAllowed,
}
impl LinkState {
/// The state word (`up_to_date`, `missing`…) that link listings report.
pub fn word(self) -> &'static str {
match self {
LinkState::UpToDate => "up_to_date",
LinkState::OutOfDate => "out_of_date",
LinkState::Missing => "missing",
LinkState::Unreadable => "unreadable",
LinkState::NotAllowed => "not_allowed",
}
}
}
/// Why a linked design can't be read: its state, and the reason to show.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkProblem {
pub state: LinkState,
pub reason: String,
}
impl std::fmt::Display for LinkProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.reason)
}
}
fn problem(state: LinkState, reason: String) -> LinkProblem {
LinkProblem { state, reason }
}
/// The state of an I/O error reading `shown`, with the reason naming the file once.
fn io_problem(shown: &str, e: &std::io::Error) -> LinkProblem {
let named = |e: String| if e.starts_with(shown) { e } else { format!("{shown}: {e}") };
match e.kind() {
// The folder scope's refusal says which folders the agent may use.
ErrorKind::PermissionDenied => problem(LinkState::NotAllowed, e.to_string()),
ErrorKind::NotFound => problem(LinkState::Missing, format!("{shown}: not found")),
_ => problem(LinkState::Unreadable, named(e.to_string())),
}
}
/// Why the path a design names may not be reached at all, judged from its text before anything
/// asks the disk or the network (#7372): a network location whose server isn't allowed
/// ([`LinkState::NotAllowed`]), or a device ([`LinkState::Unreadable`]). `design` is the linking
/// design's file, whose own server is allowed.
fn refusal(shown: &str, design: Option<&str>) -> Option<LinkProblem> {
let remote = solidcraft_io::vfs::design_path_refusal(shown, design)?;
let reason = solidcraft_io::vfs::check_design_path(shown, design).err().map(|e| e.to_string()).unwrap_or_else(|| shown.to_string());
Some(match remote {
solidcraft_io::vfs::Remote::Network(_) => problem(LinkState::NotAllowed, reason),
solidcraft_io::vfs::Remote::Device => problem(LinkState::Unreadable, reason),
})
}
/// Where the linked design at `file` stands without reading it: `None` when it can be read.
/// Cheap, for a design being opened, which must not read every file it links. A refused network
/// or device path is judged from its text alone, with nothing asked of the disk or the network.
pub(super) fn link_presence(file: &Path, design: Option<&str>) -> Option<LinkProblem> {
let shown = file.display().to_string();
refusal(&shown, design).or_else(|| solidcraft_io::vfs::len(&shown).err().map(|e| io_problem(&shown, &e)))
}
/// The bytes of the linked design at `file`, linked from the design saved at `design`, read
/// through the folders the caller may use, or why they can't be: a network location not allowed
/// or a device (judged from the path's text before anything is read), not a design file name,
/// missing, larger than the design size limit, or outside an agent's folders. Each with the
/// reason to show. Every kind of link reads its file here.
pub(super) fn read_linked_file(file: &Path, design: Option<&str>) -> Result<Vec<u8>, LinkProblem> {
let shown = file.display().to_string();
if let Some(why) = refusal(&shown, design) {
return Err(why);
}
if !solidcraft_io::is_design_path(&shown) {
return Err(problem(LinkState::Unreadable, format!("{shown}: not a .{} design", solidcraft_io::DESIGN_EXT)));
}
let limit = solidcraft_io::limits::max_design_bytes();
solidcraft_io::vfs::read_design_path(&shown, design, limit)
.map_err(|e| io_problem(&shown, &solidcraft_io::limits::explain(solidcraft_io::limits::Kind::Design, e)))
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), ..l.clone() })).collect()
}
#[cfg(test)]
@@ -218,94 +99,4 @@ mod tests {
Some("plate.solidcraft")
);
}
#[test]
fn one_rule_rebases_every_link_for_save_as() {
let root = absolute(Path::new("projects"));
let from = root.join("asm").join("case.solidcraft").to_string_lossy().into_owned();
let to = root.join("asm").join("v2").join("case.solidcraft").to_string_lossy().into_owned();
assert_eq!(rebase("../parts/plate.solidcraft", Some(&from), &to), "../../parts/plate.solidcraft");
// A link kept whole (the design had no file yet) becomes relative too.
let whole = root.join("parts").join("plate.solidcraft").to_string_lossy().into_owned();
assert_eq!(rebase(&whole, None, &to), "../../parts/plate.solidcraft");
}
/// One folder per test, with the files it needs.
fn folder(test: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-design-link-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reading_a_linked_design_reports_its_state_and_reason() {
let dir = folder("read");
let good = dir.join("plate.sc");
std::fs::write(&good, solidcraft_io::write_design(&Document::new("plate"))).unwrap();
let read = read_linked_file(&good, None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.name).ok());
assert_eq!(read, Ok(Some("plate".to_string())));
assert_eq!(link_presence(&good, None), None);
let state = |file: &Path| read_linked_file(file, None).err().map(|e| (e.state, e.reason));
// Missing: no file there (and the same without reading it).
let missing = dir.join("gone.sc");
assert!(matches!(state(&missing), Some((LinkState::Missing, r)) if r.contains("gone.sc")));
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::Missing));
// Unreadable: not a design file name, or a folder.
std::fs::write(dir.join("part.step"), b"ISO-10303-21;").unwrap();
assert!(matches!(state(&dir.join("part.step")), Some((LinkState::Unreadable, r)) if r.contains("not a .sc design")));
std::fs::create_dir_all(dir.join("folder.sc")).unwrap();
assert!(matches!(state(&dir.join("folder.sc")), Some((LinkState::Unreadable, _))));
// A design a newer SolidCraft saved is read like any other, and opens saying so (#12166).
let mut newer: serde_json::Value = serde_json::from_slice(&solidcraft_io::write_design(&Document::new("next"))).unwrap();
newer["format"] = serde_json::json!(solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1));
std::fs::write(dir.join("next.sc"), newer.to_string()).unwrap();
let opened =
read_linked_file(&dir.join("next.sc"), None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.newer_file.is_some()).ok());
assert_eq!(opened, Ok(Some(true)));
// Not allowed: outside the folders an agent may use, without saying whether it exists.
let inside = dir.join("inside");
std::fs::create_dir_all(&inside).unwrap();
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([inside])), || {
assert_eq!(state(&good).map(|s| s.0), Some(LinkState::NotAllowed));
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::NotAllowed));
});
let words: Vec<&str> = [LinkState::UpToDate, LinkState::OutOfDate, LinkState::Missing, LinkState::Unreadable, LinkState::NotAllowed]
.iter()
.map(|s| s.word())
.collect();
assert_eq!(words, ["up_to_date", "out_of_date", "missing", "unreadable", "not_allowed"]);
assert_eq!(serde_json::json!(LinkState::NotAllowed), serde_json::json!("not_allowed"));
let _ = std::fs::remove_dir_all(&dir);
}
/// A network location not allowed, or a device, is refused from the path's text alone:
/// reading it asks nothing of the disk or the network (#7372).
#[test]
fn network_and_device_paths_are_refused_before_anything_is_read() {
let calls = solidcraft_io::vfs::file_calls();
let network = Path::new(r"\\fileserver\share\standard.sc");
let refused = read_linked_file(network, None).err();
assert!(matches!(&refused, Some(p) if p.state == LinkState::NotAllowed && p.reason.contains("fileserver")), "{refused:?}");
assert_eq!(link_presence(network, None).map(|p| p.state), Some(LinkState::NotAllowed));
let device = Path::new(r"\\.\pipe\standard.sc");
assert_eq!(read_linked_file(device, None).err().map(|p| p.state), Some(LinkState::Unreadable));
assert_eq!(solidcraft_io::vfs::file_calls(), calls, "a refused path asked the file system");
}
#[test]
fn a_path_and_its_alias_are_the_same_file() {
let dir = folder("same");
let file = dir.join("a.sc");
std::fs::write(&file, b"{}").unwrap();
assert!(same_file(&file, &dir.join(".").join("a.sc")));
assert!(!same_file(&file, &dir.join("b.sc")));
#[cfg(unix)]
{
let alias = dir.join("alias.sc");
std::os::unix::fs::symlink(&file, &alias).unwrap();
assert!(same_file(&file, &alias));
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+26 -57
View File
@@ -2,7 +2,6 @@
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::construct::DatumItem;
use solidcraft_doc::{
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall,
Touching, expr::Kind, point_expr,
@@ -429,11 +428,6 @@ pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind, exact: bool)
let st = s.model.state();
for (sketch, sel) in kind.profile_sels_mut() {
let Some(ss) = st.sketch(sketch) else { continue };
// An empty selection (a patch asked for only the sketch's 3D loops, #741) has nothing to
// capture: it stays empty.
if matches!(sel, ProfileSel::Indices { indices } if indices.is_empty()) {
continue;
}
let indices = match sel {
ProfileSel::Indices { indices } => {
let have = ss.profiles.len();
@@ -1398,8 +1392,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
/// world axis (also as saved: {type: world, axis: X|Y|Z}), a construction axis by name
/// (followed when it is edited), a component origin axis (saved as its datum, so it follows the
/// placement), {origin, dir} (each origin coordinate a number or an expression over
/// (followed when it is edited), {origin, dir} (each origin coordinate a number or an expression over
/// the parameters, which the axis follows), {sketch, curve} (a line of another sketch, followed in
/// that sketch, #6548; also as saved: {type: sketch_line, …}), or an edge
/// or round face of the model ({edge|face: [x,y,z]}), which the revolve follows as the model
@@ -1430,13 +1423,6 @@ pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Resu
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
Some(name) if !is_sketch_line(s, sketch, name) && is_construction_axis(s, name) => Ok(AxisRef::Construction { name: name.into() }),
// A component origin axis: saved as its datum, so the revolve follows the placement.
Some(name) if !is_sketch_line(s, sketch, name) => {
match crate::origins::datum(name).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
Some(datum) => Ok(AxisRef::Model { geo: solidcraft_doc::construct::GeoRef::Datum { datum } }),
None => Ok(AxisRef::SketchLine { curve: name.into(), sketch: None }),
}
}
Some(id) => Ok(AxisRef::SketchLine { curve: id.into(), sketch: None }),
None => {
Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
@@ -2543,39 +2529,33 @@ fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<so
if let Some((geo, o, d)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
return Ok((o, d, Some(geo), None));
}
named_axis_line(s, p, cmd)
let (o, d, name) = named_axis_line(s, p, cmd)?;
Ok((o, d, None, name))
}
/// An axis line from `axis`: X, Y, Z, a construction axis by name, a component origin axis (its
/// datum, followed) or {origin, dir}. A construction axis comes back where it is now, with its
/// name; a component origin axis with the datum that finds it again on each evaluation.
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<solidcraft_doc::construct::GeoRef>, Option<FeatureName>)> {
if let Some(o @ Value::Object(_)) = p.get("axis") {
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
return Ok((origin, dir, None, None));
/// An axis line from `axis`: X, Y, Z, a construction axis by name or {origin, dir}. A construction
/// axis comes back where it is now, with its name.
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
match p.get("axis") {
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Ok((Vec3::ZERO, Vec3::X, None)),
"Y" => Ok((Vec3::ZERO, Vec3::Y, None)),
"Z" => Ok((Vec3::ZERO, Vec3::Z, None)),
_ if is_construction_axis(s, a) => {
let (_, _, origin, dir) = crate::view::construction_axes(s)
.into_iter()
.find(|(_, name, _, _)| name == a)
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
Ok((origin, dir, Some(a.as_str().into())))
}
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")),
},
Some(o @ Value::Object(_)) => {
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
Ok((origin, dir, None))
}
_ => Err(bad(cmd, "`axis` is required")),
}
let Some(a) = str_(p, "axis") else { return Err(bad(cmd, "`axis` is required")) };
// The world axes keep the precedence they always had: X, Y and Z name the design's axes
// before a construction axis answers to the same letters (an older design can hold one
// named `x`, and a case-only match must not take them) (#7313 review).
match a.to_ascii_uppercase().as_str() {
"X" => return Ok((Vec3::ZERO, Vec3::X, None, None)),
"Y" => return Ok((Vec3::ZERO, Vec3::Y, None, None)),
"Z" => return Ok((Vec3::ZERO, Vec3::Z, None, None)),
_ => {}
}
if is_construction_axis(s, a) {
let (_, _, origin, dir) = crate::view::construction_axes(s)
.into_iter()
.find(|(_, name, _, _)| name == a)
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
return Ok((origin, dir, None, Some(a.into())));
}
if let Some(datum) = crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
let (origin, dir) = s.origin_axis(a).ok_or_else(|| bad(cmd, format!("no axis `{a}`")))?;
return Ok((origin, dir, Some(solidcraft_doc::construct::GeoRef::Datum { datum }), None));
}
Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
}
/// An axis line `{origin?, dir}` as [`named_axis_line`] reads it: the origin (default: the
@@ -2845,18 +2825,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
}
Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() })
}
Some(Value::String(n)) => {
// A construction plane feature by name first, so a name that looks like a datum key
// still reaches it; a component origin plane is saved as its datum, so it follows
// the placement instead of staying where it was picked.
if s.doc.find_feature(n.trim()).is_some() {
return construction(n.trim());
}
match crate::origins::datum(n).filter(|d| matches!(d.item, DatumItem::XY | DatumItem::XZ | DatumItem::YZ)) {
Some(datum) => Ok(PlaneRef::Datum { datum }),
None => construction(n.trim()),
}
}
Some(Value::String(n)) => construction(n.trim()),
Some(Value::Number(id)) => match id.as_u64() {
Some(id) => construction(&id.to_string()),
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
-1
View File
@@ -425,7 +425,6 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
})
.collect::<Result<_>>()?;
-18
View File
@@ -428,23 +428,6 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
})
.collect();
// Planar loops the 3D sketch curves close in planes of their own (#741): the plane is given
// as an origin and a unit normal, and the centroid is in the same frame as the sketch plane.
let profiles_3d: Vec<Value> = ss
.profiles_3d
.iter()
.enumerate()
.map(|(i, p)| {
json!({
"index": i,
"area_mm2": p.area,
"plane": { "origin": p.plane.origin, "normal": p.plane.normal() },
"centroid": p.centroid,
"curves": p.outer_curves,
"holes": p.hole_curves,
})
})
.collect();
let wires = sk
.wires
.iter()
@@ -475,7 +458,6 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
points,
constraints,
profiles,
profiles_3d,
links: serde_json::to_value(&sk.links).unwrap_or_default(),
wires,
visible: crate::view::sketch_shown(s, id),
+8 -39
View File
@@ -47,9 +47,6 @@ const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
#[path = "joints_inspect.rs"]
mod inspect;
#[path = "assembly_constraint.rs"]
pub(crate) mod constraint;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
.at("SOLID", "CREATE")
@@ -239,12 +236,9 @@ pub fn place(s: &mut Session) -> joints::Solution {
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
/// solve leaves that joint out and says why. A tangent constraint's offset is its gap plus the
/// radius of its round face as the face is now, so the part keeps resting on the plane when the
/// radius changes; a face that can't be found keeps the numbers it has.
/// solve leaves that joint out and says why.
fn reexpress(s: &mut Session) {
let params = joints::Params::new(&s.doc);
let st = s.model.state();
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
.doc
.assembly
@@ -253,10 +247,7 @@ fn reexpress(s: &mut Session) {
.enumerate()
.filter(|(_, j)| !j.exprs.is_empty())
.filter_map(|(i, j)| {
let (mut o, a, mut q) = joints::expressed(&params, j).ok()?;
if j.constraint == Some(joints::Constraint::Tangent) {
o += s.doc.origin_radius(&st, &j.b).ok()?;
}
let (o, a, mut q) = joints::expressed(&params, j).ok()?;
joints::clamp(j, &mut q);
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
})
@@ -1161,14 +1152,14 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
check_sides(&j.a, &j.b, "joint.create")?;
// Relations, constraints and loop-closing typed joints start where their parts are. A typed joint in
// Relations and loop-closing typed joints start where their parts are. A typed joint in
// an open chain keeps its usual initial values; explicitly supplied values also keep theirs.
// A joint on a plane or axis side anchors its part to that geometry: it starts where its
// parts are too, so the part keeps its place on the plane (or along the axis) instead of
// jumping to the geometry's origin.
if p.get("values").is_none()
&& p.get("value").is_none()
&& (j.starts_where_it_is() || closes_loop(s, &j) || datum_sided(&j))
&& (j.kind.is_relation() || closes_loop(s, &j) || datum_sided(&j))
&& let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j)
{
j.values = q;
@@ -1354,13 +1345,6 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
let j = new_joint(s, p, kind, a, b, cmd)?;
add_joint(s, j, p)
}
/// A joint of `kind` between origins `a` and `b`, with the values, limits, flip, offset, angle and
/// name `p` gives (as joint.create reads them), not yet added.
fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: JointOrigin, cmd: &str) -> Result<Joint> {
let id = s.doc_mut().allocate_id()?;
let name = new_joint_name(s, p, kind);
let offset = match p.get("offset") {
@@ -1385,14 +1369,13 @@ fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: Joi
suppressed: false,
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
captured: None,
constraint: 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.)
if angle.is_none() && !j.kind.is_relation() {
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
}
Ok(j)
add_joint(s, j, p)
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1432,7 +1415,6 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
add_joint(s, j, p)
}
@@ -2087,14 +2069,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(k) = kind {
let was = j.kind;
j.kind = k;
// A constraint changed to another kind of joint is a plain joint now: its tag would keep
// restarting it, and a tangent's gap expression would leave the radius out of its offset.
if k != was {
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset = None;
}
j.constraint = None;
}
j.values.resize(k.dofs().len(), 0.0);
j.limits.resize(k.dofs().len(), None);
j.exprs.values.truncate(k.dofs().len());
@@ -2109,10 +2083,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = offset {
j.offset = o;
j.exprs.offset = typed(p.get("offset"));
// A tangent constraint's offset is its gap: the radius is added when it is solved.
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset.get_or_insert_with(|| format!("{o} mm"));
}
}
if let Some(a) = angle {
j.angle = a;
@@ -2150,11 +2120,11 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
&& kind.is_none_or(|k| k == was_kind)
&& offset.is_none_or(|o| (o - was_offset).abs() <= 1e-9)
&& bool_(p, "flip").is_none_or(|f| f == was_flip);
// Otherwise a relation or a constraint whose type, offset, tilt or flip changes, or whose
// sides move, starts again from where its parts are.
// Otherwise a relation whose type, offset, tilt or flip changes, or whose sides move, starts
// again from where its parts are.
let reseat = !tilt_only && (kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides);
if reseat {
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.starts_where_it_is()).cloned();
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).cloned();
if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j))
&& let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id)
{
@@ -2248,7 +2218,6 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
frame_a: world(&j.a),
frame_b: world(&j.b),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
})
.collect();
-1
View File
@@ -64,7 +64,6 @@ fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q
errors: why(&sol.errors),
conflicts: why(&sol.conflicts),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
}
@@ -257,7 +257,6 @@ fn a_panel_full_of_parts_placed_by_relations_stays_solved() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
if let Some(q) = solidcraft_doc::joints::relation_values(&s.doc, &s.model.state(), &j) {
j.values = q;
@@ -374,273 +373,3 @@ fn an_angle_edit_while_the_joint_is_suppressed_starts_from_where_the_part_is() {
assert!(near(side(&s), moved, 1e-6), "together {together}: {:?}, moved to {moved:?}", side(&s));
}
}
// Assembly constraints (`assembly.constraint.create`): the rules in the Constrain dialog's words,
// kept as joints of the kinds they need and tagged with the rule.
fn constrain(s: &mut Session, kind: &str, a: Value, b: Value, extra: Value) -> Value {
let mut p = json!({"type": kind, "a": a, "b": b});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
run(s, "assembly.constraint.create", p)
}
fn dof_of(r: &Value, s: &Session, component: &str) -> Option<u64> {
let o = occ(s, component);
r["dof"].as_array()?.iter().find(|d| d["occurrence"] == json!(o))?["dof"].as_u64()
}
/// Mate, flush and a distance place the bracket fully, in the panel's words, and it follows the
/// panel as it changes size: its bottom on the top, its front flush with the panel's front, its
/// right end 40 mm in from the panel's.
#[test]
fn mate_flush_and_distance_place_a_part_and_follow_the_panel() {
let mut s = panel();
let r = constrain(&mut s, "mate", json!("Top"), json!("Bottom"), json!({}));
assert_eq!(dof(&r, &s), Some(3), "{r}");
assert_eq!(r["name"], "Mate1");
let r = constrain(&mut s, "flush", json!("Front"), json!("Face"), json!({}));
assert_eq!(dof(&r, &s), Some(1), "{r}");
let r = constrain(&mut s, "distance", json!("Right"), json!("Corner"), json!({"offset": -40}));
assert!(clean(&r), "{r}");
assert_eq!(dof(&r, &s), Some(0), "{r}");
for w in [400.0, 650.0, 250.0] {
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "W {w}: {r}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 500.0, 0.0)), Vec3::new(w - 40.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(0.0, 500.0, 0.0)), Vec3::new(w - 60.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 510.0, 6.0)), Vec3::new(w - 40.0, 10.0, 8.0), 1e-6), "W {w}");
}
// Each is a joint of the kind its rule needs, tagged with the rule.
let list = run(&mut s, "joint.list", json!({}));
let kinds: Vec<(String, String)> = list["joints"]
.as_array()
.unwrap()
.iter()
.map(|j| (j["type"].as_str().unwrap().to_string(), j["constraint"].as_str().unwrap_or("").to_string()))
.collect();
assert_eq!(kinds, [("planar".into(), "mate".into()), ("planar".into(), "flush".into()), ("point_on_plane".into(), "distance".into())]);
let inspect = run(&mut s, "joint.inspect", json!({"joint": "Flush1"}));
assert_eq!(inspect["joints"][0]["constraint"], "flush", "{inspect}");
// Saved and reopened, the rules stay; a joint made as a joint has none.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let rules: Vec<_> = back.assembly.joints.iter().map(|j| j.constraint).collect();
use solidcraft_doc::joints::Constraint;
assert_eq!(rules, [Some(Constraint::Mate), Some(Constraint::Flush), Some(Constraint::Distance)]);
relation(&mut s, "parallel", "Top", "Bottom", json!({}));
assert!(!s.doc.to_json().contains("\"constraint\":null"));
assert_eq!(s.doc.assembly.joints.last().unwrap().constraint, None);
}
/// A mate moves the part only across the gap: a bracket flat above the panel drops straight onto
/// it, not turned and not slid.
#[test]
fn a_mate_moves_the_part_only_across_the_gap() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let corners = [Vec3::new(30.0, 40.0, 50.0), Vec3::new(50.0, 40.0, 50.0), Vec3::new(30.0, 60.0, 70.0)];
let r = constrain(
&mut s,
"mate",
json!({"occurrence": "Plate", "face": [5, 5, 10]}),
json!({"occurrence": "Block", "face": [31, 41, 50]}),
json!({}),
);
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
for c in corners {
let got = place(&s, "Block", c);
assert!(near(got, c - Vec3::new(0.0, 0.0, 40.0), 1e-6), "{c:?}: {got:?}");
}
// A gap: 5 mm above the plate.
let r = run(&mut s, "joint.edit", json!({"joint": "Mate1", "offset": 5}));
assert!(clean(&r), "{r}");
assert!(near(place(&s, "Block", corners[0]), Vec3::new(30.0, 40.0, 15.0), 1e-6));
}
/// A part's own datum plane and axis are references too: a block mated onto the plate's XY plane
/// drops onto it, and a point on the plate's Z axis brings its corner onto that axis. Insert and
/// tangent need a rim or a radius, which an axis has not.
#[test]
fn datum_planes_and_axes_take_constraints() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let xy = json!({"occurrence": "Plate", "plane": "XY"});
let z = json!({"occurrence": "Plate", "axis": "Z"});
let bottom = json!({"occurrence": "Block", "face": [31, 41, 50]});
let corner = json!({"occurrence": "Block", "point": [30, 40, 50]});
let r = constrain(&mut s, "mate", xy.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::new(30.0, 40.0, 0.0), 1e-6));
assert!(near(place(&s, "Block", Vec3::new(50.0, 60.0, 70.0)), Vec3::new(50.0, 60.0, 20.0), 1e-6));
let r = constrain(&mut s, "point_on_axis", z.clone(), corner.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(1), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::ZERO, 1e-6));
for (kind, a, b, says) in [
("insert", z.clone(), z.clone(), "two round edges"),
("tangent", xy.clone(), z.clone(), "a planar face and a round face"),
("distance", xy.clone(), z.clone(), "a planar face and a point"),
] {
let p = json!({"type": kind, "a": a, "b": b, "offset": 1});
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert_eq!(s.doc.assembly.joints.len(), 2);
}
/// A shaft and a collar: two round faces. Concentric leaves a slide and a turn; Insert only the
/// turn, with the collar's end on the shaft's.
#[test]
fn concentric_and_insert_leave_a_slide_and_a_turn_or_a_turn() {
let shaft = || {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Shaft"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 0, 1], "radius": 5, "height": 20, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Collar"}));
run(&mut s, "solid.cylinder", json!({"base": [50, 10, 30], "axis": [0, 0, 1], "radius": 5, "height": 10, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let o = occ(&s, "Shaft");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
s
};
let top = json!({"occurrence": "Shaft", "circle": [5, 0, 20]});
let bottom = json!({"occurrence": "Collar", "circle": [55, 10, 30]});
let mut s = shaft();
let r = constrain(&mut s, "concentric", top.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(2), "{r}");
let c = place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0));
assert!(c.x.abs() < 1e-6 && c.y.abs() < 1e-6, "on the shaft's axis: {c:?}");
let mut s = shaft();
let r = constrain(&mut s, "insert", top, bottom, json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(1), "{r}");
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0)), Vec3::new(0.0, 0.0, 20.0), 1e-6));
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 40.0)), Vec3::new(0.0, 0.0, 30.0), 1e-6), "standing on the shaft, not through it");
}
/// The rules' remaining freedoms: parallel 4, perpendicular and angle 5, point on axis 4, tangent
/// 4 (a roller on the panel: it rolls, slides and turns), each made where the parts are.
#[test]
fn each_rule_leaves_the_freedom_it_should() {
let roller = |s: &mut Session| {
run(s, "component.create", json!({"name": "Roller"}));
run(s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": 4, "height": 30, "operation": "new"}));
run(s, "component.activate", json!({"component": "root"}));
};
for (kind, a, b, extra, part, want) in [
("parallel", json!("Top"), json!("Bottom"), json!({}), "Bracket", 4),
("perpendicular", json!("Top"), json!("Face"), json!({}), "Bracket", 5),
("angle", json!("Top"), json!("Bottom"), json!({"angle": 30}), "Bracket", 5),
("distance", json!("Top"), json!("Bottom"), json!({"offset": 12}), "Bracket", 3),
("tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}), "Roller", 4),
("point_on_axis", json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!("Corner"), json!({}), "Bracket", 4),
] {
let mut s = panel();
roller(&mut s);
if kind == "point_on_axis" {
let o = occ(&s, "Roller");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
}
let r = constrain(&mut s, kind, a, b, extra);
assert!(clean(&r), "{kind}: {r}");
assert_eq!(dof_of(&r, &s, part), Some(want), "{kind}: {r}");
if kind == "tangent" {
// Its axis a radius above the panel's top.
let p = place(&s, "Roller", Vec3::new(0.0, 600.0, 50.0));
assert!((p.z - 6.0).abs() < 1e-6, "tangent: {p:?}");
}
}
}
/// A rule given the wrong kind of reference, or without the number it needs, is refused with what
/// it needs, and nothing is added.
#[test]
fn a_rule_with_the_wrong_references_is_refused() {
let mut s = panel();
let cases = [
(json!({"type": "mate", "a": "Top", "b": "Corner"}), "two planar faces"),
(json!({"type": "insert", "a": "Top", "b": "Bottom"}), "two round edges"),
(json!({"type": "distance", "a": "Top", "b": "Bottom"}), "offset"),
(json!({"type": "angle", "a": "Top", "b": "Bottom"}), "angle"),
(json!({"type": "weld", "a": "Top", "b": "Bottom"}), "`type` must be"),
(json!({"type": "mate", "a": "Top", "b": "Bottom", "offset": "1e999"}), ""),
];
for (p, says) in cases {
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert!(s.doc.assembly.joints.is_empty());
}
/// A roller tangent on the panel keeps resting on it as its radius changes, and a gap edited in as
/// a number stays a gap above the face, not the axis's height.
/// A constraint given a blank or `null` name is named after its rule, as one given no name is. A
/// joint.edit to another kind of joint makes it a plain joint: the tag goes, and a former tangent's
/// offset no longer follows its face's radius.
#[test]
fn a_constraint_edited_to_another_joint_type_is_a_plain_joint() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({"name": null}));
assert!(clean(&r), "{r}");
let r = constrain(&mut s, "parallel", json!("Top"), json!("Bottom"), json!({"name": " "}));
assert!(clean(&r), "{r}");
let names: Vec<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["Tangent1", "Parallel1"]);
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "type": "revolute"}));
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").cloned().unwrap();
assert_eq!((j.kind, j.constraint, j.exprs.offset.clone()), (solidcraft_doc::joints::JointKind::Revolute, None, None));
let list = run(&mut s, "joint.list", json!({}));
let listed = list["joints"].as_array().and_then(|js| js.iter().find(|x| x["name"] == "Tangent1")).cloned().unwrap_or_default();
assert!(listed.get("constraint").is_none(), "{listed}");
// The radius no longer moves it.
run(&mut s, "parameters.change", json!({"name": "R", "expression": "7 mm"}));
run(&mut s, "joint.solve", json!({}));
let after = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").map(|j| j.offset);
assert_eq!(after, Some(j.offset));
}
#[test]
fn a_tangent_constraint_follows_its_faces_radius() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}));
assert!(clean(&r), "{r}");
let axis = |s: &Session| place(s, "Roller", Vec3::new(0.0, 600.0, 50.0)).z;
assert!((axis(&s) - 6.0).abs() < 1e-6, "{}", axis(&s));
for (radius, gap, want) in [(7.0, None, 9.0), (2.5, None, 4.5), (2.5, Some(1.0), 5.5), (6.0, None, 9.0)] {
run(&mut s, "parameters.change", json!({"name": "R", "expression": format!("{radius} mm")}));
if let Some(g) = gap {
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "offset": g}));
}
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "R {radius}: {r}");
assert!((axis(&s) - want).abs() < 1e-6, "R {radius}, gap {gap:?}: axis at {}, expected {want}", axis(&s));
}
}
+2 -6
View File
@@ -42,12 +42,8 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result<String> {
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "mesh.convert";
let state = s.model.state();
let given = target(s, p, cmd)?;
// The selection and the Browser name a body of a further placement as the world shows it
// (`Mesh (Scan:2)`): that name means the body it shows, which the conversion edits in its
// component, as `appearance.assign` reads it (#9740).
let body = s.own_body_name(&given).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
let body = target(s, p, cmd)?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
if !source.body.is_mesh() {
return Err(bad(cmd, "select a mesh body"));
}
-7
View File
@@ -33,7 +33,6 @@ mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod construct_auto;
mod delete;
pub(crate) mod diagnostics;
mod edit;
@@ -42,8 +41,6 @@ mod features;
mod features_more;
mod join_pieces;
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, plane_axis_point_kind};
pub use construct_auto::{construct_method, construct_methods, is_construct_tool};
pub use design_link::{LinkProblem, LinkState};
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
@@ -88,7 +85,6 @@ mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod notes;
mod parts;
mod point_cloud;
mod script;
@@ -351,7 +347,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct_auto::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
@@ -360,7 +355,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(joints::constraint::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(panels::COMMANDS.iter());
@@ -369,7 +363,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(bom::COMMANDS.iter());
v.extend(mass_properties::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(notes::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(selection::COMMANDS.iter());
-124
View File
@@ -1,124 +0,0 @@
//! Note commands (#1631): personal notes anchored to points in model space, saved with the
//! design and drawn in the viewport as screen-facing callouts. `note.add` anchors a note,
//! `note.update` edits its text, anchor, or hidden flag, `note.delete` removes it, and
//! `note.list` reads them back. Everything lives in the document, so notes are saved and
//! undone like any other design change.
use serde_json::{Value, json};
use solidcraft_doc::Note;
use super::CommandSpec;
use crate::params::{bad, bool_, req_vec3, str_};
use crate::schema::notes::{NoteAddParams, NoteDeleteParams, NoteDeleted, NoteId, NoteItem, NoteListParams, NoteUpdateParams, NotesList};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const ADD: TypedContract<NoteAddParams> = TypedContract::new();
const UPDATE: TypedContract<NoteUpdateParams> = TypedContract::new();
const DELETE: TypedContract<NoteDeleteParams> = TypedContract::new();
const LIST: TypedContract<NoteListParams> = TypedContract::new();
/// Notes a design may hold.
const MAX_NOTES: usize = 1000;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("note.add", "Add Note", crate::checked!("note.add", note_add))
.icon("text")
.contract(&ADD)
.output(output_schema_for::<NoteId>)
.params("anchor: [x,y,z] (the point in model space, mm); text: the note (may span lines); hidden?: bool (saved but not drawn) → note: id"),
CommandSpec::new("note.update", "Edit Note", crate::checked!("note.update", note_update))
.icon("edit")
.contract(&UPDATE)
.output(output_schema_for::<NoteId>)
.params("note: id; text?: the new text; anchor?: [x,y,z] (move the anchor); hidden?: bool (show or hide) → note: id"),
CommandSpec::new("note.delete", "Delete Note", crate::checked!("note.delete", note_delete))
.icon("delete")
.contract(&DELETE)
.output(output_schema_for::<NoteDeleted>)
.params("note: id → deleted: id"),
CommandSpec::new("note.list", "List Notes", crate::checked!("note.list", note_list))
.icon("list")
.contract(&LIST)
.output(output_schema_for::<NotesList>)
.noundo()
.doc_only()
.params("→ notes: [{id, anchor: [x,y,z], text, hidden}] in creation order"),
];
fn note_add(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.add";
let anchor = req_vec3(CMD, p, "anchor")?;
let text = str_(p, "text").ok_or_else(|| bad(CMD, "`text` is required"))?;
if !Note::text_is_valid(text) {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
if s.doc.notes.len() >= MAX_NOTES {
return Err(bad(CMD, "too many notes"));
}
let id = s.doc_mut().allocate_id()?;
let hidden = bool_(p, "hidden").unwrap_or(false);
s.doc_mut().notes.push(Note { id, anchor, text: text.to_string(), hidden });
Ok(json!({ "note": id }))
}
/// The index of a note by id.
fn note_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let id = p.get("note").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`note` must be a note id"))?;
s.doc.notes.iter().position(|n| n.id == id).ok_or_else(|| bad(cmd, format!("no note {id}")))
}
fn note_update(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.update";
let i = note_index(s, p, CMD)?;
let text = match str_(p, "text") {
Some(t) if Note::text_is_valid(t) => Some(t.to_string()),
Some(_) => {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
None => None,
};
let anchor = match p.get("anchor") {
Some(_) => Some(req_vec3(CMD, p, "anchor")?),
None => None,
};
let hidden = bool_(p, "hidden");
if text.is_none() && anchor.is_none() && hidden.is_none() {
return Err(bad(CMD, "give at least one of `text`, `anchor`, `hidden`"));
}
let id = {
let n = &mut s.doc_mut().notes[i];
if let Some(t) = text {
n.text = t;
}
if let Some(a) = anchor {
n.anchor = a;
}
if let Some(h) = hidden {
n.hidden = h;
}
n.id
};
Ok(json!({ "note": id }))
}
fn note_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = note_index(s, p, "note.delete")?;
let n = s.doc_mut().notes.remove(i);
Ok(json!({ "deleted": n.id }))
}
fn note_list(s: &mut Session, _p: &Value) -> Result<Value> {
let notes: Vec<NoteItem> = s
.doc
.notes
.iter()
.map(|n| NoteItem { id: n.id, anchor: [n.anchor.x, n.anchor.y, n.anchor.z], text: n.text.clone(), hidden: n.hidden })
.collect();
Ok(json!({ "notes": notes }))
}
#[cfg(test)]
#[path = "notes_tests.rs"]
mod tests;
-87
View File
@@ -1,87 +0,0 @@
//! Note commands (#1631): add, edit, delete, list, undo, and persistence.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
}
#[test]
fn add_update_delete_and_list_round_trip() {
let mut s = Session::default();
let added = run(&mut s, "note.add", json!({"anchor": [10.0, 20.0, 30.0], "text": "Check clearance here."}));
let id = added["note"].as_u64().unwrap();
assert!(id > 0);
let listed = run(&mut s, "note.list", json!({}));
let notes = listed["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0]["id"], json!(id));
assert_eq!(notes[0]["text"], "Check clearance here.");
assert_eq!(notes[0]["anchor"], json!([10.0, 20.0, 30.0]));
assert_eq!(notes[0]["hidden"], json!(false));
run(&mut s, "note.update", json!({"note": id, "text": "Adjust this diameter after the test print.", "hidden": true}));
let listed = run(&mut s, "note.list", json!({}));
assert_eq!(listed["notes"][0]["text"], "Adjust this diameter after the test print.");
assert_eq!(listed["notes"][0]["hidden"], json!(true));
run(&mut s, "note.update", json!({"note": id, "anchor": [1.0, 2.0, 3.0]}));
assert_eq!(run(&mut s, "note.list", json!({}))["notes"][0]["anchor"], json!([1.0, 2.0, 3.0]));
assert_eq!(run(&mut s, "note.delete", json!({"note": id})), json!({"deleted": id}));
assert!(run(&mut s, "note.list", json!({}))["notes"].as_array().unwrap().is_empty());
}
#[test]
fn notes_survive_a_save_and_reopen() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 5.0], "text": "Extra thickness added for strength."}));
let saved = serde_json::to_string(&*s.doc).unwrap();
let reopened = Session::new(serde_json::from_str(&saved).unwrap());
assert_eq!(reopened.doc.notes.len(), 1);
assert_eq!(reopened.doc.notes[0].text, "Extra thickness added for strength.");
}
#[test]
fn undo_undoes_note_changes() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "first"}));
run(&mut s, "note.add", json!({"anchor": [1.0, 0.0, 0.0], "text": "second"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes.len(), 1);
assert_eq!(s.doc.notes[0].text, "first");
let id = s.doc.notes[0].id;
run(&mut s, "note.update", json!({"note": id, "text": "renamed"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes[0].text, "first");
}
#[test]
fn ids_are_never_reused() {
let mut s = Session::default();
let a = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "a"}))["note"].as_u64().unwrap();
run(&mut s, "note.delete", json!({"note": a}));
let b = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "b"}))["note"].as_u64().unwrap();
assert_ne!(a, b);
}
#[test]
fn bad_calls_are_refused() {
let mut s = Session::default();
// Empty or missing text.
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0], "text": " "})).is_err());
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0]})).is_err());
// A non-finite anchor.
assert!(s.execute("note.add", &json!({"anchor": [f64::NAN, 0.0, 0.0], "text": "x"})).is_err());
// Unknown note id.
assert!(s.execute("note.update", &json!({"note": 999, "text": "x"})).is_err());
assert!(s.execute("note.delete", &json!({"note": 999})).is_err());
// An update that changes nothing.
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "x"}));
let id = s.doc.notes[0].id;
assert!(s.execute("note.update", &json!({"note": id})).is_err());
assert_eq!(s.doc.notes[0].text, "x");
}
-301
View File
@@ -14,203 +14,6 @@ fn near(value: &Value, expected: f64) {
assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}");
}
/// Nested and repeated placements each keep their own Origin: the same item under different
/// paths resolves at its own placement, folders and items follow the browser's eyes, and the
/// saved state round-trips, undoes and prunes with the placements (#7313).
#[test]
fn component_origins_keep_nested_copies_and_visibility_separate() {
use crate::origins;
use solidcore_record::Vec3;
let mut s = Session::default();
let (parent, first) = created(&mut s, "Assembly");
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[100,0,0]}));
let (_, child) = created(&mut s, "Child");
run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[20,0,0]}));
run(&mut s, "component.activate", json!({"component":0}));
let second = run(&mut s, "occurrence.copy", json!({"component":parent,"translate":[50,0,0]}))["occurrence"].as_u64().unwrap();
let (one, two) = (vec![first, child], vec![second, child]);
assert!(s.origin_plane(&origins::key(&one, "XY")).unwrap().origin.dist(Vec3::new(120., 0., 0.)) < 1e-8);
assert!(s.origin_plane(&origins::key(&two, "XY")).unwrap().origin.dist(Vec3::new(170., 0., 0.)) < 1e-8);
assert!(s.shown_component_origins().is_empty());
run(&mut s, "browser.visibility", json!({"tree_folders":[origins::folder(&two)],"visible":true}));
assert!(s.folder_hidden(&origins::folder(&one)));
assert!(!s.folder_hidden(&origins::folder(&two)));
assert_eq!(s.visibility.origin_hidden, None);
assert_eq!(s.shown_component_origins().len(), 7);
run(&mut s, "browser.visibility", json!({"items":[origins::key(&two,"X")],"visible":false}));
assert_eq!(s.shown_component_origins().len(), 6);
assert!(s.construction_visible("X"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.shown_component_origins().len(), 7);
run(&mut s, "edit.redo", json!({}));
let mut saved = (*s.doc).clone();
saved.visibility = s.visibility.clone();
let reopened = Session::new(serde_json::from_str(&serde_json::to_string(&saved).unwrap()).unwrap());
assert_eq!(reopened.visibility, s.visibility);
assert_eq!(reopened.shown_component_origins().len(), 6);
run(&mut s, "browser.visibility", json!({"paths":[[second]],"visible":false}));
assert!(s.shown_component_origins().is_empty(), "a hidden parent hides its origin too");
let before = s.visibility.clone();
assert!(s.execute("browser.visibility", &json!({"tree_folders":[origins::folder(&one),"o:999999/origin"],"visible":true})).is_err());
assert_eq!(s.visibility, before, "invalid batches must be atomic");
for invalid in ["o:/origin", "o:0/origin", "o:01/origin", "c0/origin"] {
assert!(s.execute("browser.visibility", &json!({"tree_folders":[invalid],"visible":true})).is_err(), "{invalid}");
}
let folded = run(&mut s, "browser.expand_all", json!({"path":one}));
assert!(folded["closed_by_default"].as_array().unwrap().contains(&json!(origins::folder(&one))));
run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[10,0,0],"capture":false}));
assert!(s.origin_plane(&origins::key(&one, "XY")).unwrap().origin.dist(Vec3::new(130., 0., 0.)) < 1e-8);
}
/// A component origin pick lands on the geometry as it is placed: a sketch on the datum
/// follows the component, and axis and plane references resolve at the placement in the
/// reading component's frame, whoever authors them (#7313).
#[test]
fn component_origin_picks_use_world_geometry_and_follow_the_placement() {
use crate::origins;
use solidcore_record::Vec3;
let mut s = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
let (component, occurrence) = created(&mut s, "Part");
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,30,40],"axis":[1,0,0],"angle":"90 deg"}));
let plane = origins::key(&[occurrence], "XY");
let expected = Vec3::new(20., 30., 40.);
assert!(s.origin_plane(&plane).unwrap().origin.dist(expected) < 1e-8);
assert!(s.origin_plane(&plane).unwrap().normal().dist(-Vec3::Y) < 1e-8);
s.selection = vec![crate::Sel::Plane { name: plane.clone() }];
run(&mut s, "view.normal_to", json!({}));
let camera = s.camera.unwrap();
assert!(camera.target.dist(expected) < 1e-8);
assert!(camera.back().dot(Vec3::Y).abs() > 1. - 1e-8);
for author in [0, component] {
run(&mut s, "component.activate", json!({"component":author}));
let sketch = run(&mut s, "sketch.create", json!({"plane":plane}))["sketch"].as_u64().unwrap();
let state = s.world_state().unwrap();
let actual = state.sketch(sketch).unwrap().plane;
assert!(actual.origin.dist(expected) < 1e-8, "author {author}: {actual:?}");
assert!(actual.normal().dist(-Vec3::Y) < 1e-8);
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "construct.point.vertex", json!({"point":{"construct_point":origins::key(&[occurrence],"O")},"name":"Origin copy"}));
let point = crate::view::construction_points(&s).into_iter().find(|(_, n, _)| n == "Origin copy").unwrap().2;
assert!(point.dist(expected) < 1e-8);
run(&mut s, "construct.plane.at_angle", json!({"base":plane,"axis":origins::key(&[occurrence],"X"),"angle":"90 deg","name":"Turned"}));
let turned = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Turned").unwrap().2;
assert!(turned.origin.dist(expected) < 1e-8);
assert!(turned.normal().dot(Vec3::Z).abs() > 1. - 1e-8);
run(&mut s, "component.activate", json!({"component":component}));
run(&mut s, "construct.plane.perpendicular", json!({"plane":plane,"line":{"axis":origins::key(&[occurrence],"X")},"name":"Perpendicular"}));
let perpendicular = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n.ends_with("Perpendicular")).unwrap().2;
assert!(perpendicular.origin.dist(expected) < 1e-8);
assert!(perpendicular.normal().dot(Vec3::Z).abs() > 1. - 1e-8);
}
/// The cascade rule (the #9791 rule for construction geometry, applied to datums): a hidden
/// placement hides its Origin folder and its items regardless of their own eyes; showing it
/// again restores the saved per-item state.
#[test]
fn hidden_placement_hides_its_origin_and_restores_it() {
use crate::origins;
let mut s = Session::default();
let (_, occurrence) = created(&mut s, "Part");
// The origin shown, one item hidden by itself: the saved per-item state to keep.
run(&mut s, "browser.visibility", json!({"tree_folders":[origins::folder(&[occurrence])],"visible":true}));
run(&mut s, "browser.visibility", json!({"items":[origins::key(&[occurrence],"X")],"visible":false}));
assert_eq!(s.shown_component_origins().len(), 6);
assert!(s.construction_visible(&origins::key(&[occurrence], "Y")));
assert!(!s.construction_visible(&origins::key(&[occurrence], "X")));
// Hiding the placement hides its origin too, whatever the items' own eyes say.
run(&mut s, "browser.visibility", json!({"paths":[[occurrence]],"visible":false}));
assert!(s.shown_component_origins().is_empty(), "a hidden placement hides its origin");
assert!(!s.construction_visible(&origins::key(&[occurrence], "Y")));
assert!(!s.construction_visible(&origins::key(&[occurrence], "X")));
// Showing it again restores the saved eyes: X stays hidden, the other six are back.
run(&mut s, "browser.visibility", json!({"paths":[[occurrence]],"visible":true}));
assert_eq!(s.shown_component_origins().len(), 6);
assert!(s.construction_visible(&origins::key(&[occurrence], "Y")));
assert!(!s.construction_visible(&origins::key(&[occurrence], "X")));
assert_eq!(s.visibility.shown_component_origins, vec![vec![occurrence]], "the placement stays shown by name");
}
/// A placement that is gone cannot be cleared by hand (browser.visibility refuses a path that
/// no longer resolves), so its shown Origin prunes like hidden_occurrences, and undo brings
/// the placement and its shown folder back (#7313).
#[test]
fn deleting_a_placement_prunes_its_shown_origin_and_undo_restores_it() {
use crate::origins;
let mut s = Session::default();
let (component, occurrence) = created(&mut s, "Part");
run(&mut s, "browser.visibility", json!({"tree_folders":[origins::folder(&[occurrence])],"visible":true}));
assert_eq!(s.shown_component_origins().len(), 7);
// A deleted placement's folder cannot be cleared by hand any more: browser.visibility refuses
// a path that no longer resolves, whichever eye it sets. The stale folder prunes the way
// hidden_occurrences does.
run(&mut s, "component.delete", json!({"component": component}));
assert!(s.shown_component_origins().is_empty(), "the deleted placement's shown origin is pruned");
// Undo brings the placement back with its saved eyes.
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.shown_component_origins().len(), 7, "undo restores the shown origin with the placement");
}
/// A bare component origin datum key in a construct reference names its item: O the point, X an
/// axis, XY a plane (through the plane resolver). Before, only the `{axis}`/`{construct_point}`
/// forms read the datums, and the bare shorthand refused `o:2/4/origin/O` as "no plane" (#7313).
#[test]
fn bare_datum_keys_name_their_item_in_construct_refs() {
use crate::origins;
use solidcraft_doc::construct::{AxisDef, DatumItem, GeoRef};
use solidcraft_doc::{FeatureKind, PlaneRef};
let mut s = Session::default();
let (parent, first) = created(&mut s, "Parent");
run(&mut s, "component.create", json!({"name": "Child", "parent": "Parent"}));
let child = s.doc.occurrences.iter().find(|o| o.parent == parent).unwrap().id;
let path = vec![first, child];
run(&mut s, "component.activate", json!({"component": parent}));
run(&mut s, "construct.axis.two_points", json!({"a": origins::key(&path, "O"), "b": {"point": [12, 0, 0]}, "name": "From O"}));
run(&mut s, "construct.axis.edge", json!({"edge": origins::key(&path, "X"), "name": "On X"}));
run(&mut s, "sketch.create", json!({"plane": origins::key(&path, "XY"), "name": "On the datum"}));
// A closure cannot name the output lifetime here (two reference params), so a nested fn
// ties the returned kind to the session it is read from.
fn saved<'a>(s: &'a Session, name: &str) -> &'a FeatureKind {
s.doc.features.iter().find(|f| f.name == name).map(|f| &f.kind).unwrap()
}
match saved(&s, "From O") {
FeatureKind::ConstructionAxis { def } => match def {
AxisDef::TwoPoints { a, .. } => match a {
GeoRef::Datum { datum } => {
assert_eq!(datum.path, path, "the datum names the nested placement");
assert_eq!(datum.item, DatumItem::O);
}
other => panic!("expected the O datum, got {other:?}"),
},
other => panic!("expected two points, got {other:?}"),
},
other => panic!("expected a construction axis, got {other:?}"),
}
match saved(&s, "On X") {
FeatureKind::ConstructionAxis { def } => match def {
AxisDef::Edge { edge } => match edge {
GeoRef::Datum { datum } => assert_eq!(datum.item, DatumItem::X),
other => panic!("expected the X datum, got {other:?}"),
},
other => panic!("expected an edge line, got {other:?}"),
},
other => panic!("expected a construction axis, got {other:?}"),
}
match saved(&s, "On the datum") {
FeatureKind::Sketch { plane, .. } => match plane {
PlaneRef::Datum { datum } => {
assert_eq!(datum.path, path);
assert_eq!(datum.item, DatumItem::XY);
}
other => panic!("expected the XY datum, got {other:?}"),
},
other => panic!("expected a sketch, got {other:?}"),
}
}
/// A new component holds no bodies of its own: creating one changes nothing in the design and
/// rejects body-taking parameters, so its Origin starts empty like the design's own (#7313).
#[test]
fn ambiguous_component_names_are_refused_without_changing_the_session() {
let mut s = Session::default();
@@ -2223,107 +2026,3 @@ fn absolute_move_refuses_relative_keys_and_keeps_capture_and_grounding() {
let r = run(&mut s, "occurrence.move", json!({"occurrence": occ, "to": {"position": [9,0,0]}, "move_grounded": true}));
near(&r["transform"][3][0], 9.0);
}
/// The one resolution order, pinned at the engine's resolver sites: a feature (exact name)
/// first, then a datum key, then the origin aliases. A construction feature named like a datum
/// key resolves as the feature at every site (its saved reference names the feature, or its
/// geometry is where the feature is now); without the feature, the same key is the datum. The
/// dialog sites (`plane_by_name`, `axis_of`, `named_plane`) pin the same order in the ui tests.
#[test]
fn resolver_order_is_feature_first_at_every_site() {
use crate::origins;
use solidcore_record::Vec3;
use solidcraft_doc::{FeatureKind, PlaneRef};
let mut s = Session::default();
let (_, child) = created(&mut s, "Child");
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [100, 0, 0]}));
// The features are created in the active component, and `component.create` made Child active:
// back to the root, so "where the feature is now" is the root origin, not the child placement.
run(&mut s, "component.activate", json!({"component": 0}));
let axis_key = origins::key(&[child], "X");
let plane_key = origins::key(&[child], "XY");
// The features the datum-looking names resolve to at every site below.
run(&mut s, "construct.axis.two_planes", json!({"a": "XY", "b": "XZ", "name": &axis_key}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "2 mm", "name": &plane_key}));
// construct.geo: a saved reference keeps the feature's name.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XY", "line": {"axis": &axis_key}, "name": "Geo axis"}));
let f = s.doc.features.iter().find(|f| f.name == "Geo axis").unwrap();
let FeatureKind::ConstructionPlane { plane: PlaneRef::Perpendicular { line, .. } } = &f.kind else { panic!("{:?}", f.kind) };
assert!(matches!(line.as_ref(), solidcraft_doc::construct::GeoRef::Axis { name } if *name == axis_key), "{line:?}");
// named_axis_line: the axis is where the feature is now (the root origin), not the datum's.
run(&mut s, "construct.plane.at_angle", json!({"base": "XY", "axis": &axis_key, "angle": "30 deg", "name": "Axis line"}));
let f = s.doc.features.iter().find(|f| f.name == "Axis line").unwrap();
let FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { axis_origin, .. } } = &f.kind else { panic!("{:?}", f.kind) };
assert!(axis_origin.dist(Vec3::ZERO) < 1e-9, "{axis_origin:?}");
// axis_param: a revolve saves the construction axis by name, not the datum.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Profile"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": &axis_key, "angle": "180 deg", "name": "Revolved"}));
let f = s.doc.features.iter().find(|f| f.name == "Revolved").unwrap();
let FeatureKind::Revolve { axis: solidcraft_doc::AxisRef::Construction { name }, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(name.to_string(), axis_key);
// plane_param: a sketch on the named plane is attached to the feature, not the datum.
let sid = run(&mut s, "sketch.create", json!({"plane": &plane_key, "name": "On the feature"}))["sketch"].as_u64().unwrap();
let FeatureKind::Sketch { plane: PlaneRef::Construction { feature }, .. } = &s.doc.feature(sid).unwrap().kind else {
panic!("{:?}", s.doc.feature(sid).unwrap().kind)
};
let named = s.doc.features.iter().find(|f| f.name == plane_key).unwrap().id;
assert_eq!(feature.0, named);
// shown_item: the exact name is the feature, and a datum key with no feature is the datum.
run(&mut s, "browser.visibility", json!({"items": [&axis_key], "visible": false}));
assert!(s.visibility.hidden_origin.iter().any(|h| h == &axis_key), "the feature hides by its exact name");
run(&mut s, "browser.visibility", json!({"items": [origins::key(&[child], "Y")], "visible": false}));
assert!(s.visibility.hidden_origin.iter().any(|h| h == &origins::key(&[child], "Y")), "the datum item hides by its key");
}
/// The world axes keep the precedence they always had at the axis resolvers: X, Y and Z name
/// the design's axes before a construction axis answers to the same letters, however it is
/// cased (#7313 review). An angled plane about `x` stands on the world axis, as it did before
/// component origins, not on a construction axis named `x`.
#[test]
fn world_axis_names_keep_their_precedence_at_the_axis_resolvers() {
use solidcore_record::Vec3;
use solidcraft_doc::{FeatureKind, PlaneRef};
let mut s = Session::default();
// An older design can hold a construction axis named like a world axis (case included):
// away from the origin, so the geometry tells which one the command took.
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[7,0,0]},"b":{"point":[12,0,0]},"name":"x"}));
for name in ["x", "X"] {
run(&mut s, "construct.plane.at_angle", json!({"base":"XY","axis":name,"angle":"30 deg","name":format!("Angled {name}")}));
let f = s.doc.features.iter().find(|f| f.name == format!("Angled {name}")).unwrap();
let FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { axis_origin, .. } } = &f.kind else { panic!("{:?}", f.kind) };
assert!(axis_origin.dist(Vec3::ZERO) < 1e-9, "{name}: the world axis, not the construction axis at {axis_origin:?}");
}
}
/// Look At keeps the world planes for their names: a picked `XY` looks at the design's XY
/// plane, not at a construction plane of the same name (an older design can hold one), as it
/// did before component origins (#7313 review). A datum key without a feature of its name
/// still follows its placement.
#[test]
fn look_at_keeps_the_world_planes_for_their_names() {
use crate::origins;
use solidcore_record::Vec3;
let mut s = Session::default();
let (_, occurrence) = created(&mut s, "Part");
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[0,0,5]}));
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":"3 mm","name":"Old plane"}));
// The commands refuse a plane named like a world plane today; an older design predates
// that check, so the fixture renames it by hand.
s.doc_mut().features.iter_mut().find(|f| f.name == "Old plane").unwrap().name = "XY".into();
s.selection = vec![crate::Sel::Plane { name: "XY".into() }];
run(&mut s, "view.normal_to", json!({}));
let camera = s.camera.unwrap();
assert!(camera.target.dist(Vec3::ZERO) < 1e-9, "the design's XY plane, not the construction plane of the same name at {:?}", camera.target);
let key = origins::key(&[occurrence], "XY");
s.selection = vec![crate::Sel::Plane { name: key }];
run(&mut s, "view.normal_to", json!({}));
let camera = s.camera.unwrap();
assert!(camera.target.dist(Vec3::new(0., 0., 5.)) < 1e-9, "the datum follows its placement: {:?}", camera.target);
}
+2 -16
View File
@@ -4,12 +4,12 @@
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle, TextVerticalAlign};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
() => {
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), vertical_align?: baseline|top|middle|bottom (the block of lines against `at` or the path: the first line's baseline (default), its capitals' tops, halfway between those and the last line's baseline, or the bottom of the last line's descenders), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
};
}
@@ -246,20 +246,6 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
Some(_) => return Err(bad(cmd, "`align` must be left, center or right")),
}
const VERTICAL_MUST: &str = "`vertical_align` must be baseline, top, middle or bottom";
match p.get("vertical_align") {
None | Some(Value::Null) => {}
Some(Value::String(a)) => {
style.vertical_align = match a.trim().to_ascii_lowercase().as_str() {
"baseline" => TextVerticalAlign::Baseline,
"top" => TextVerticalAlign::Top,
"middle" | "center" | "centre" => TextVerticalAlign::Middle,
"bottom" => TextVerticalAlign::Bottom,
_ => return Err(bad(cmd, VERTICAL_MUST)),
}
}
Some(_) => return Err(bad(cmd, VERTICAL_MUST)),
}
if p.get("letter_spacing").is_some_and(|v| !v.is_null()) {
style.letter_spacing =
num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?;
@@ -226,8 +226,6 @@ fn text_style_parameters_are_checked() {
(json!({"link":link,"bold":true}), "no bold face"),
(json!({"link":link,"bold":"yes"}), "`bold` must be true or false"),
(json!({"link":link,"align":"justify"}), "`align` must be left, center or right"),
(json!({"link":link,"vertical_align":"above"}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"vertical_align":1}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"line_spacing":0}), "`line_spacing` must be a positive factor"),
(json!({"link":link,"letter_spacing":"wide"}), "`letter_spacing` must be a length"),
(json!({"link":link,"path":"no-such-curve"}), "not a curve of this sketch"),
@@ -237,19 +235,14 @@ fn text_style_parameters_are_checked() {
assert!(e.contains(error), "{params}: {e}");
assert_eq!(s.doc.to_json(), before);
}
run(
&mut s,
"sketch.edit_text",
json!({"link":link,"italic":true,"align":"right","vertical_align":"middle","letter_spacing":1.5,"line_spacing":1.2}),
);
run(&mut s, "sketch.edit_text", json!({"link":link,"italic":true,"align":"right","letter_spacing":1.5,"line_spacing":1.2}));
let LinkSource::Text { style, text, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert_eq!(text, "II");
assert!(style.italic && style.align == solidcraft_sketch::TextAlign::Right && style.letter_spacing == 1.5 && style.line_spacing == 1.2);
assert_eq!(style.vertical_align, solidcraft_sketch::TextVerticalAlign::Middle);
// Left out, a style stays.
run(&mut s, "sketch.edit_text", json!({"link":link,"text":"I"}));
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert!(style.italic && style.letter_spacing == 1.5 && style.vertical_align == solidcraft_sketch::TextVerticalAlign::Middle);
assert!(style.italic && style.letter_spacing == 1.5);
}
/// `base` with the keys of `extra` set (a null `extra` value is sent as null).
+5 -32
View File
@@ -16,15 +16,14 @@ use super::features::{add_feature, axis_param, check_expr, extent_param, feature
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
use crate::schema::features::{FeatureResult, SurfaceSweepParams};
use crate::schema::surface::{
FairParams, OffsetParams, PatchParams, PatchResult, ReverseNormalParams, StitchParams, SurfaceExtendParams, SurfaceLoftParams, SurfaceTrimParams,
ThickenParams, UnstitchParams, UntrimParams,
FairParams, OffsetParams, ReverseNormalParams, StitchParams, SurfaceExtendParams, SurfaceLoftParams, SurfaceTrimParams, ThickenParams,
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};
const PATCH_CONTRACT: TypedContract<PatchParams> = TypedContract::new();
const STITCH_CONTRACT: TypedContract<StitchParams> = TypedContract::new();
const UNSTITCH_CONTRACT: TypedContract<UnstitchParams> = TypedContract::new();
const REVERSE_NORMAL_CONTRACT: TypedContract<ReverseNormalParams> = TypedContract::new();
@@ -40,12 +39,10 @@ const BLEND_CONTRACT: TypedContract<BlendParams> = TypedContract::new();
const SURFACE_RULED_CONTRACT: TypedContract<SurfaceRuledParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", crate::checked!("surface.patch", patch))
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("surface_patch")
.contract(&PATCH_CONTRACT)
.output(output_schema_for::<PatchResult>)
.params("sketch?: id|name and profiles? (planar patches of sketch regions) or profiles_3d: [indices] (the sketch's planar 3D-sketch loops, each patched in its own plane, as listed by sketch.inspect) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2|g3|g4 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges, g3 also its rate of change, g4 the change of that rate) or [{edge: [x,y,z], g: g0|g1|g2|g3|g4}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or higher edge); name?, body_name?"),
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2|g3|g4 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges, g3 also its rate of change, g4 the change of that rate) or [{edge: [x,y,z], g: g0|g1|g2|g3|g4}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or higher edge); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", crate::checked!("surface.stitch", stitch))
.at("SURFACE", "MODIFY")
.icon("surface_stitch")
@@ -143,13 +140,9 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let crown = crown_param(p, cmd)?;
let continuity = patch_continuity(p, cmd)?;
let points: Vec<Position> = patch_points(p, cmd)?.into_iter().map(Position).collect();
let profiles_3d = profiles_3d_param(p, cmd)?;
let edges: Vec<solidcore_record::Vec3> =
p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
let mut v = if !edges.is_empty() {
if !profiles_3d.is_empty() {
return Err(bad(cmd, "`profiles_3d` is for a patch on a sketch"));
}
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
@@ -160,7 +153,6 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
FeatureKind::Patch {
sketch: None,
profiles: ProfileSel::All,
profiles_3d: Vec::new(),
body: body.map(BodyName),
edges: edges.into_iter().map(Position).collect(),
crown,
@@ -173,13 +165,10 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`continuity` and `points` are for a patch on a loop of edges"));
}
let sketch = feature_sketch(s, p, cmd)?;
// With only 3D loops asked for, the sketch's own-plane profiles stay untouched.
let profiles =
if !profiles_3d.is_empty() && p.get("profiles").is_none() { ProfileSel::Indices { indices: Vec::new() } } else { profiles(p, cmd)? };
let profiles = profiles(p, cmd)?;
let kind = FeatureKind::Patch {
sketch: Some(SketchId(sketch)),
profiles,
profiles_3d,
body: None,
edges: Vec::new(),
crown,
@@ -195,22 +184,6 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(v)
}
/// Indices of the sketch's planar 3D loops (#741): `profiles_3d: [0, 2]`, as listed by
/// `sketch.inspect`.
fn profiles_3d_param(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let Some(v) = p.get("profiles_3d") else { return Ok(Vec::new()) };
let a = v.as_array().ok_or_else(|| bad(cmd, "`profiles_3d` must be a list of indices"))?;
if a.is_empty() || a.len() > 128 {
return Err(bad(cmd, "`profiles_3d` needs 1 to 128 indices"));
}
a.iter()
.map(|i| {
let n = i.as_u64().ok_or_else(|| bad(cmd, "`profiles_3d` indices must be whole numbers"))?;
usize::try_from(n).map_err(|_| bad(cmd, format!("there is no profile {n}")))
})
.collect()
}
/// A continuity word for a patch, within what a patch builds ([`Construction::Patch`]).
fn patch_order(v: Option<&Value>, cmd: &str) -> Result<Continuity> {
crate::params::continuity(cmd, v, "a patch's continuity", Construction::Patch)
+2 -22
View File
@@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current Vi
.noundo()
.contract(&CONTRACT)
.output(output_schema_for::<ViewDxfResult>)
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); zero?: target|origin (where the drawing's 0,0 is: the camera target, default, or the model origin as seen from the camera); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cam: Camera =
@@ -48,12 +48,6 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !sketch::exportable(s) {
return Err(bad(CMD, "no visible bodies or sketches to export"));
}
let at_origin = match p.get("zero") {
None => false,
Some(Value::String(v)) if v == "target" => false,
Some(Value::String(v)) if v == "origin" => true,
_ => return Err(bad(CMD, "zero must be target or origin")),
};
let tolerance = match p.get("tolerance") {
None => 0.01,
Some(v) => v.as_f64().filter(|t| (0.0001..=1.0).contains(t)).ok_or_else(|| bad(CMD, "tolerance must be 0.0001..1 mm"))?,
@@ -70,21 +64,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let meshes = mesh_lines::prepare(s, CMD, tolerance, |b| s.body_visible(&b.name))?;
let prepare_ms = started.elapsed().as_secs_f64() * 1000.;
let sketch = sketch::parts(s, tolerance).map_err(|e| bad(CMD, e))?;
let view = View::from_camera(&cam);
let mut drawing = mesh_lines::drawing(&meshes, &sketch, &view).map_err(|e| bad(CMD, e))?;
if at_origin {
// The drawing is centred on the camera target; move it so the model origin is 0,0.
let zero = view.world_origin().map_err(|e| bad(CMD, e))?;
for l in &mut drawing.lines {
l.a -= zero;
l.b -= zero;
}
for path in &mut drawing.sketch {
for pt in &mut path.points {
*pt -= zero;
}
}
}
let drawing = mesh_lines::drawing(&meshes, &sketch, &View::from_camera(&cam)).map_err(|e| bad(CMD, e))?;
let writing = Stage::new("dxf_write", 1);
writing.check().map_err(|e| bad(CMD, e))?;
let mut visible = 0usize;
-33
View File
@@ -252,36 +252,3 @@ fn view_dxf_hides_finished_sketches_behind_bodies_but_draws_the_active_sketch_on
assert_eq!(dashed["sketch"], 2);
assert!((length(&dashed) - 80.).abs() < 1e-3, "{}", length(&dashed));
}
#[test]
fn view_dxf_zero_origin_puts_the_model_origin_at_the_drawing_zero() {
let mut s = sample();
let bounds = |v: &Value| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for g in solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap() {
let solidcraft_io::Geom2::Line(a, b) = g else { panic!("{g:?}") };
for p in [a, b] {
(lo, hi) = (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)));
}
}
(lo, hi)
};
let near = |a: Vec2, x: f64, y: f64| (a.x - x).abs() < 1e-3 && (a.y - y).abs() < 1e-3;
// By default the drawing is centred on the camera target (20, 15), whatever the model origin.
let (lo, hi) = bounds(&export(&mut s, camera(StandardView::Top), "omit"));
assert!(near(lo, -20., -15.) && near(hi, 20., 15.), "{lo:?} {hi:?}");
// `zero: origin` is the same drawing with the model origin, the plate's corner, at 0,0.
for zero in [None, Some("target")] {
let mut p = json!({"camera": camera(StandardView::Top)});
if let Some(z) = zero {
p["zero"] = json!(z);
}
let (lo, _) = bounds(&run(&mut s, CMD, p));
assert!(near(lo, -20., -15.), "{zero:?}");
}
let v = run(&mut s, CMD, json!({"camera": camera(StandardView::Top), "zero": "origin"}));
let (lo, hi) = bounds(&v);
assert!(near(lo, 0., 0.) && near(hi, 40., 30.), "{lo:?} {hi:?}");
let err = s.execute(CMD, &json!({"camera": camera(StandardView::Top), "zero": "corner"})).unwrap_err().to_string();
assert!(err.contains("zero must be target or origin"), "{err}");
}
+1 -13
View File
@@ -243,19 +243,7 @@ fn normal_target(s: &Session) -> Option<(solidcore_record::Vec3, solidcore_recor
let plane = state.body(body)?.body.planar_face(*index)?;
Some((*point, plane.normal()))
}
Sel::Plane { name } => {
// The design's world planes keep the precedence they always had: `XY` looks at the
// design's XY plane before a construction plane of the same name answers (an older
// design can hold one named `XY`) (#7313 review). A component origin key follows
// its placement, with a construction plane of the same name still winning over it.
let feature = || crate::view::construction_planes(s).into_iter().find(|(_, n, _)| n == name).map(|(_, _, plane)| plane);
let plane = if crate::origins::datum(name).is_some() {
feature().or_else(|| s.origin_plane(name))
} else {
solidcore_record::Plane::named(name).or_else(feature)
};
plane.map(|plane| (plane.origin, plane.normal()))
}
Sel::Plane { name } => solidcore_record::Plane::named(name).map(|plane| (plane.origin, plane.normal())),
_ => None,
})
}
@@ -318,35 +318,3 @@ fn an_editor_accepts_the_placements_rolling_the_marker_solves() {
assert_eq!(session.doc.marker, None);
assert_eq!(occurrence(&session, "B").transform, moved);
}
/// Cancel on a computing OK (solidcore#3247): a dialog's computation of a long Combine Cut (a
/// coil cut from a cylinder, as in #3962) stops within 500 ms of Cancel and says it was
/// cancelled. Nothing it computed is kept in the evaluation cache the session shares: OK again
/// computes the cut anew, and finishes.
#[test]
fn a_cancelled_computation_stops_promptly_and_is_never_cached() {
use solidcraft_doc::progress::{Progress, watch};
use std::time::{Duration, Instant};
let mut session = Session::default();
session.execute("solid.cylinder", &json!({"radius": 20, "height": 120, "body_name": "Stock"})).unwrap();
let coil = json!({"diameter": 51, "pitch": 15.24, "turns": 6, "section": "square", "section_size": 13, "body_name": "Tool", "operation": "new", "base": [0, 0, 12]});
session.execute("solid.coil", &coil).unwrap();
let cut = vec![("solid.combine".to_string(), json!({"operation": "cut", "target": "Stock", "tools": ["Tool"]}))];
let progress = Arc::new(Progress::default());
let (watching, scratch, commands) = (progress.clone(), session.scratch(), cut.clone());
let worker = std::thread::spawn(move || {
let r = scratch.stop_token().within(|| watch(watching, || scratch.compute_commands(&commands, None)));
(r.map(|_| ()).map_err(|e| e.to_string()), Instant::now())
});
std::thread::sleep(Duration::from_millis(300));
let cancelled = Instant::now();
progress.cancel();
let (r, returned) = worker.join().unwrap();
let took = returned.saturating_duration_since(cancelled);
assert!(r.as_ref().is_err_and(|e| e.contains("cancelled")), "{r:?}");
assert!(took < Duration::from_millis(500), "returned {took:?} after Cancel");
let again = session.scratch().compute_commands(&cut, None).unwrap();
// The cut is the one feature computed anew (the session's counter carries over to its copies).
assert_eq!(again.computed.model.recomputed, session.model.recomputed + 1, "nothing of the cancelled run was cached");
assert!(again.computed.model.results.iter().all(|r| r.error.is_none()), "OK again finishes the cut");
}
-110
View File
@@ -967,113 +967,3 @@ fn construction_on_a_moved_body_moves_with_it() {
let (o, d) = axis(&s, "Tether");
assert!(on_line(o, d, Vec3::new(0.0, 0.0, 20.0)) && on_line(o, d, Vec3::new(0.0, 50.0, 20.0)), "{o:?} {d:?}");
}
/// A feature named exactly like a component origin item stays reachable: at every resolver that
/// takes a name the feature wins over the datum key (the #9128 mistake the plan calls out).
/// Without the feature the same pick is the component origin's, saved as its datum.
#[test]
fn feature_named_like_a_datum_key_stays_reachable() {
let mut s = Session::default();
let made = run(&mut s, "component.create", json!({"name": "Child", "grounded": false}));
let child = made["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [100, 0, 0]}));
let key = crate::origins::key(&[child], "X");
run(&mut s, "construct.axis.two_planes", json!({"a": "XY", "b": "XZ", "name": key}));
// The name resolves the feature, not a frozen line of the origin axis it is spelled like.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XY", "line": {"axis": key}, "name": "P"}));
let f = s.doc.features.iter().find(|f| f.name == "P").unwrap();
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::Perpendicular { line, .. } } = &f.kind else {
panic!("{:?}", f.kind)
};
let solidcraft_doc::construct::GeoRef::Axis { name } = line.as_ref() else { panic!("{:?}", line) };
assert_eq!(name.to_string(), key);
// Without the feature the same pick is the component origin axis, saved as its datum: it
// follows the placement instead of staying where it was picked.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XY", "line": {"axis": crate::origins::key(&[child], "Y")}, "name": "Q"}));
let f = s.doc.features.iter().find(|f| f.name == "Q").unwrap();
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::Perpendicular { line, .. } } = &f.kind else {
panic!("{:?}", f.kind)
};
let solidcraft_doc::construct::GeoRef::Datum { datum } = line.as_ref() else { panic!("{:?}", line) };
assert_eq!(datum.item, solidcraft_doc::construct::DatumItem::Y);
assert_eq!(datum.path, vec![child]);
}
/// Plane, Axis and Point build with the first method of their family that takes the references:
/// the feature is that method's own, and the reply names it.
#[test]
fn plane_axis_and_point_build_with_the_first_method_that_takes_the_references() {
let mut s = part();
let top = json!({"face": [20, 15, 20]});
let x_edge = json!({"edge": [20, 0, 0]});
let corner = json!({"vertex": [40, 30, 20]});
let cases = [
("construct.plane", json!({"refs": [top], "with": {"offset": 5, "name": "Lid"}}), "construct.plane.offset"),
("construct.plane", json!({"refs": [corner, {"plane": "XY"}], "with": {"name": "Corner"}}), "construct.plane.offset"),
("construct.plane", json!({"refs": [{"plane": "XY"}, top], "with": {"name": "Mid"}}), "construct.plane.midplane"),
(
"construct.plane",
json!({"refs": [{"plane": "XY"}, {"axis": "X"}], "with": {"angle": "90 deg", "name": "Turned"}}),
"construct.plane.at_angle",
),
(
"construct.plane",
json!({"refs": [{"point": [0, 0, 7]}, {"point": [1, 0, 7]}, {"point": [0, 1, 7]}], "with": {"name": "Three"}}),
"construct.plane.three_points",
),
("construct.axis", json!({"refs": [{"face": [65, 0, 5]}], "with": {"name": "Bore"}}), "construct.axis.through_cylinder"),
("construct.axis", json!({"refs": [{"plane": "XZ"}, {"plane": "YZ"}], "with": {"name": "Meet"}}), "construct.axis.two_planes"),
("construct.point", json!({"refs": [corner], "with": {"name": "Tip"}}), "construct.point.vertex"),
(
"construct.point",
json!({"refs": [{"plane": "XY"}, {"plane": "XZ"}, {"plane": "YZ"}], "with": {"name": "Zero"}}),
"construct.point.three_planes",
),
("construct.point", json!({"refs": [x_edge], "with": {"t": 0.5, "name": "Half"}}), "construct.point.along_path"),
];
for (id, p, method) in cases {
let features = s.doc.features.len();
assert_eq!(run(&mut s, id, p.clone())["method"], method, "{id} {p}");
assert_eq!(s.doc.features.len(), features + 1, "{id} {p}: one feature, the method's own");
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
let lid = plane(&s, "Lid");
assert!(parallel(lid.normal(), Vec3::Z) && on_plane(&lid, Vec3::new(1.0, 1.0, 25.0)), "{lid:?}");
let corner_plane = plane(&s, "Corner");
assert!(on_plane(&corner_plane, Vec3::new(1.0, 1.0, 20.0)), "{corner_plane:?}");
let mid = plane(&s, "Mid");
assert!(on_plane(&mid, Vec3::new(1.0, 1.0, 10.0)), "{mid:?}");
let turned = plane(&s, "Turned");
assert!(parallel(turned.normal(), Vec3::Y), "{turned:?}");
let three = plane(&s, "Three");
assert!(on_plane(&three, Vec3::new(5.0, 5.0, 7.0)), "{three:?}");
let (o, d) = axis(&s, "Bore");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 3.0)), "{o:?} {d:?}");
let (o, d) = axis(&s, "Meet");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}");
assert!(near(point(&s, "Tip"), Vec3::new(40.0, 30.0, 20.0)));
assert!(near(point(&s, "Zero"), Vec3::ZERO));
assert!(near(point(&s, "Half"), Vec3::new(20.0, 0.0, 0.0)), "{:?}", point(&s, "Half"));
// One undo step takes the feature away.
let features = s.doc.features.len();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.features.len(), features - 1);
}
/// References no method builds from, and a `with` key no method takes, are refused and leave the
/// design as it was.
#[test]
fn plane_axis_and_point_refuse_references_no_method_takes() {
let mut s = part();
let before = (*s.doc).clone();
for (id, p) in [
("construct.plane", json!({"refs": [{"point": [0, 0, 0]}, {"point": [1, 0, 0]}]})),
("construct.plane", json!({"refs": [{"plane": "XY"}, {"plane": "XZ"}], "with": {"bogus": 1}})),
("construct.axis", json!({"refs": [{"vertex": [40, 30, 20]}]})),
("construct.point", json!({"refs": [{"plane": "XY"}]})),
("construct.plane", json!({"refs": []})),
] {
assert!(s.execute(id, &p).is_err(), "{id} {p}");
assert_eq!(*s.doc, before, "{id} {p}: a refusal changes nothing");
}
}
-9
View File
@@ -324,10 +324,6 @@ impl Mapper<'_> {
if v.is_array() {
return self.pick(v, None);
}
// A name, and among them a component origin key (`o:…/origin/XY`) in particular, passes
// through untouched: the command's resolver applies the one order - a feature of the same
// name first, then the datum - and saves the reference that follows what it names (#7313),
// instead of the plane being frozen where it was picked.
let o = v.as_object()?;
let mut m = o.clone();
if let Some(x) = o.get("face").and_then(|x| self.pick(x, None)) {
@@ -725,11 +721,6 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
vec3(v).and_then(|p| m.local_point(&frame, p))
}
Axis => {
// A name, and among them a component origin key (`o:…/origin/X`), passes
// through untouched: the command's resolver applies the one order - a feature
// of the same name first, then the datum - and saves the reference that
// follows what it names (#7313), instead of the axis being frozen where it
// was picked.
if let Some(axis) = v.as_object() {
let mut axis = axis.clone();
// An `{origin, dir}` axis without its origin passes through the world's,
-32
View File
@@ -1118,35 +1118,3 @@ fn edge_picks_with_no_placement_to_look_through_are_checked() {
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!((body_volume(&s, "Box") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{}", body_volume(&s, "Box"));
}
/// A sketch (and a construction plane) picked on another component's origin follows that
/// component: the reference is saved as the placement's datum, not as a world plane frozen
/// where the component was when it was picked (#7313).
#[test]
fn cross_component_sketch_refs_follow_the_component() {
use solidcraft_doc::construct::DatumItem;
let mut s = Session::default();
let made = run(&mut s, "component.create", json!({"name": "Assembly", "grounded": false}));
let (parent, first) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap());
run(&mut s, "occurrence.move", json!({"occurrence": first, "translate": [100, 0, 0]}));
let child = run(&mut s, "component.create", json!({"name": "Child", "grounded": false}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [20, 0, 0]}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "occurrence.copy", json!({"component": parent, "translate": [50, 0, 0]}));
// A sketch on the child's XY in its first placement, and a plane offset from the same plane.
let plane = crate::origins::key(&[first, child], "XY");
let sid = run(&mut s, "sketch.create", json!({"plane": plane, "name": "On child"}))["sketch"].as_u64().unwrap();
let f = s.doc.feature(sid).unwrap();
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Datum { datum }, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(datum.item, DatumItem::XY);
assert_eq!(datum.path, vec![first, child]);
run(&mut s, "construct.plane.offset", json!({"base": plane, "offset": "5 mm", "name": "Off child"}));
// The sketch is where the child's XY is, and it follows the child when it moves.
let sketch_plane = |s: &Session| s.world_state().unwrap().sketch(sid).unwrap().plane;
assert!(near(sketch_plane(&s).origin, Vec3::new(120.0, 0.0, 0.0)), "{:?}", sketch_plane(&s));
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [10, 0, 0]}));
assert!(near(sketch_plane(&s).origin, Vec3::new(130.0, 0.0, 0.0)), "{:?}", sketch_plane(&s));
// The offset plane, built on the datum, follows the move too - offset and all.
let (_, _, off) = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Off child").unwrap();
assert!(near(off.origin, Vec3::new(130.0, 0.0, 5.0)), "{off:?}");
}
-1
View File
@@ -86,7 +86,6 @@ fn classify(e: &EngineError) -> Outcome {
| EngineError::BadParams { .. }
| EngineError::Disabled(..)
| EngineError::Refused { .. }
| EngineError::Cancelled(_)
| EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
// A failed boolean is a failure even when it says "not supported": those are the cases
// to fix. Features the kernel doesn't do yet are limitations.
+2 -18
View File
@@ -22,7 +22,6 @@ pub mod fuzz;
pub mod licences;
mod mesh_lines;
pub mod occurrences;
pub mod origins;
pub mod panels;
pub mod params;
pub mod product_scene;
@@ -52,8 +51,8 @@ use solidcraft_doc::{Document, Model, ModelState};
pub use cmd::settings::{MAX_PROFILE_NAME, profile_name};
pub use cmd::{
Addition, CommandInfo, CommandSpec, LinkProblem, LinkState, OpenSteps, addition, auto_operation, command_specs, dimension_roles, extrude_reach,
find_command, resolve_design_links, shown_scope, standard_view,
Addition, CommandInfo, CommandSpec, OpenSteps, addition, auto_operation, command_specs, dimension_roles, extrude_reach, find_command,
resolve_design_links, shown_scope, standard_view,
};
pub use solidcraft_doc as doc;
pub use solidcraft_doc::Visibility;
@@ -88,10 +87,6 @@ pub enum EngineError {
/// A command panicked; the document was kept as it was (a bug: please report it).
#[error("internal error in `{0}` (the design was kept as it was): {1}")]
Internal(String, String),
/// The work running the command was cancelled ([`solidcraft_doc::progress::Progress::cancel`])
/// while it ran: nothing was applied, whatever was computed before the cancel came in.
#[error("`{0}` was cancelled; nothing was applied")]
Cancelled(String),
}
impl From<solidcraft_doc::DocError> for EngineError {
@@ -1734,15 +1729,6 @@ impl Session {
Err(EngineError::Internal(spec.id.to_string(), msg))
}
};
// Cancelled while it ran: no result, even one finished before the cancel came in, and
// the design goes back as it was (solidcore#3247). Opening, starting or recovering a
// design (which replaces the session) keeps its own Cancel: the features not yet built
// are skipped and what is built so far is shown (#10577).
let r = if solidcraft_doc::progress::cancelled() && self.replacements == replacements {
Err(EngineError::Cancelled(spec.id.to_string()))
} else {
r
};
// The kernel's internal errors come up without the command's id.
let r = r.map_err(|e| match e {
EngineError::Internal(id, msg) if id.is_empty() => {
@@ -2542,7 +2528,6 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
timeline_groups,
browser_order,
named_views,
notes,
visibility,
next_component_color_index,
} = a;
@@ -2579,7 +2564,6 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
&& *timeline_groups == b.timeline_groups
&& *browser_order == b.browser_order
&& *named_views == b.named_views
&& *notes == b.notes
&& *visibility == b.visibility
&& *next_component_color_index == b.next_component_color_index
}
@@ -73,13 +73,6 @@ impl View {
Ok(projected)
}
/// Where the world origin lands in the view's right and up, so a drawing can be shifted to
/// put its zero there.
pub(crate) fn world_origin(&self) -> Result<Vec2, &'static str> {
let p = self.project(Vec3::new(0., 0., 0.))?;
Ok(Vec2::new(p.x, p.y))
}
/// How far behind a surface a line must be to count as covered, in the depth `project` gives.
pub(super) fn depth_eps(&self, eps: f64) -> f64 {
self.perspective.map_or(eps, |distance| 1e-8 / distance)
+3 -20
View File
@@ -801,10 +801,6 @@ impl Session {
/// Is a browser folder or group hidden itself (by tree key, see `Visibility::hidden_folders`)?
pub fn folder_hidden(&self, key: &str) -> bool {
// A component Origin folder: hidden by default, shown only by name (`shown_component_origins`).
if let Some(path) = crate::origins::folder_path(key) {
return !self.visibility.shown_component_origins.contains(&path);
}
if let Some(cache) = self.visibility_index() {
return cache.as_ref().is_none_or(|c| c.folder_keys.contains(key));
}
@@ -866,20 +862,12 @@ impl Session {
if self.visibility.hidden_origin.iter().any(|h| h == name) {
return false;
}
// A feature first, so one named like a datum key still reaches it; then a component
// origin item, gated by its placement (a hidden placement hides its origin too) and its
// folder's own eye.
if let Some(f) = self.doc.features.iter().find(|f| f.name == name) {
return self.feature_placement_visible(f.id) && !self.feature_folder_hidden(f.id, "construction");
match self.doc.features.iter().find(|f| f.name == name) {
Some(f) => self.feature_placement_visible(f.id) && !self.feature_folder_hidden(f.id, "construction"),
None => true,
}
if let Some((path, _)) = crate::origins::item(name) {
return self.visibility.shown_component_origins.contains(&path) && self.occurrence_visible(&path);
}
true
}
/// Whether an occurrence shows in the world: its own eye and every ancestor placement's
/// eye along its path. A hidden placement hides its Origin folder and items too (#7313).
pub fn body_visible(&self, name: &str) -> bool {
if self.body_hidden_itself(name) || self.body_removed_later(name) {
return false;
@@ -1017,10 +1005,5 @@ impl Session {
}
let hidden = std::mem::take(&mut self.visibility.hidden_occurrences);
self.visibility.hidden_occurrences = hidden.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect();
// A shown Origin folder whose placement is gone cannot be cleared by hand - browser.visibility
// refuses a path that no longer resolves, whichever eye it sets - so it prunes like
// hidden_occurrences does; undo restores the snapshot with the placement (#7313).
let shown = std::mem::take(&mut self.visibility.shown_component_origins);
self.visibility.shown_component_origins = shown.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect();
}
}
-97
View File
@@ -1,97 +0,0 @@
//! The reference geometry of an exact assembly placement, without timeline features.
use solidcore_record::{Plane, Vec3};
use solidcraft_doc::construct::{ConstructGeom, DatumItem, DatumRef};
use crate::Session;
pub const ITEMS: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
pub const MAX_SHOWN: usize = 10_000;
/// A component Origin folder uses the same scope as its Bodies and Sketches folders.
pub fn folder(path: &[u64]) -> String {
format!("{}/origin", Session::folder_scope(path))
}
/// Parse a bounded, canonical placement folder key. The design's origin has its own controls.
pub fn folder_path(key: &str) -> Option<Vec<u64>> {
if key.len() > 2048 {
return None;
}
let ids = key.strip_prefix("o:")?.strip_suffix("/origin")?;
if ids.is_empty() || ids.split('/').count() > 64 {
return None;
}
ids.split('/').map(|id| id.parse::<u64>().ok().filter(|n| *n != 0 && n.to_string() == id)).collect()
}
/// An item key carries the complete path: a shared child in two parents has two origins. The
/// item reads the way the document's datum grammar parses it — case-insensitively, answered in
/// the canonical spelling (`yz` answers `YZ`) — so a resolver that takes a key in any spelling
/// and one that matches the stored spelling agree about what a key names, and a key no datum
/// grammar accepts is no datum anywhere (#7313).
pub fn item(name: &str) -> Option<(Vec<u64>, &'static str)> {
let (key, item) = name.rsplit_once('/')?;
let item = ITEMS.iter().find(|known| known.eq_ignore_ascii_case(item))?;
Some((folder_path(key)?, *item))
}
/// The Browser key of a placement's origin item: the folder path, then the item
/// (`o:2/origin/XY`). Resolvers and the saved visibility speak these keys.
pub fn key(path: &[u64], item: &str) -> String {
format!("{}/{item}", folder(path))
}
/// The datum an item key names, by the document's shared datum grammar: `o:<ids>/origin/<item>`
/// with its strict bounds. Resolvers save it, so the reference follows the placement.
pub fn datum(name: &str) -> Option<DatumRef> {
DatumRef::parse_key(name)
}
impl Session {
/// Resolve a Browser pick even when its origin is hidden. Includes pending placement moves.
pub fn origin_geometry(&self, name: &str) -> Option<ConstructGeom> {
let d = datum(name)?;
let placement = self.occurrence_placement(&d.path).ok()?;
Some(d.item.geom().placed(&placement.world))
}
/// The plane a component origin key names, placed where its component now stands:
/// `None` for the axes and the point, and for a key whose placement is gone.
pub fn origin_plane(&self, name: &str) -> Option<Plane> {
match self.origin_geometry(name)? {
ConstructGeom::Plane(p) => Some(p),
_ => None,
}
}
/// The axis a component origin key names, placed where its component now stands:
/// `None` for the planes and the point, and for a key whose placement is gone.
pub fn origin_axis(&self, name: &str) -> Option<(Vec3, Vec3)> {
match self.origin_geometry(name)? {
ConstructGeom::Axis { origin, dir } => Some((origin, dir)),
_ => None,
}
}
/// Reference geometry explicitly shown by the user. No assembly expansion on every frame.
pub fn shown_component_origins(&self) -> Vec<(String, ConstructGeom)> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for path in self.visibility.shown_component_origins.iter().take(MAX_SHOWN) {
if path.is_empty() || path.len() > 64 || !seen.insert(path) || !self.occurrence_visible(path) {
continue;
}
let Ok(placement) = self.occurrence_placement(path) else { continue };
for item in ITEMS {
let name = key(path, item);
if !self.visibility.hidden_origin.contains(&name)
&& let Some(geom) = DatumItem::parse(item).map(|d| d.geom())
{
out.push((name, geom.placed(&placement.world)));
}
}
}
out
}
}
+1 -1
View File
@@ -437,7 +437,7 @@ pub fn default_weight() -> f32 {
}
pub fn validate_weight(weight: f32) -> PanelResult<()> {
if !weight.is_finite() || !(0.0..=MAX_LOGICAL).contains(&weight) {
if !weight.is_finite() || weight < 0.0 || weight > MAX_LOGICAL {
return Err(error(Rule::Bounds, "weight must be finite and in [0, 32768]"));
}
Ok(())
-4
View File
@@ -971,9 +971,6 @@ pub struct VisibilityState {
/// Whether the Origin folder is hidden. Present when set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_hidden: Option<bool>,
/// Component Origin folders explicitly shown, by occurrence path. Present when there are any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shown_component_origins: Vec<Vec<u64>>,
/// Sketches whose closed profiles are not shaded, by feature id. Present when there are any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_profiles: Vec<u64>,
@@ -998,7 +995,6 @@ impl From<&solidcraft_doc::Visibility> for VisibilityState {
shown_sketches: v.shown_sketches.clone(),
hidden_joints: v.hidden_joints.clone(),
origin_hidden: v.origin_hidden,
shown_component_origins: v.shown_component_origins.clone(),
hidden_profiles: v.hidden_profiles.clone(),
shown_dimensions: v.shown_dimensions.clone(),
locked_bodies: v.locked_bodies.clone(),
@@ -240,7 +240,6 @@ fn the_visibility_reply_type_matches_the_design_s() {
shown_sketches: vec![5],
hidden_joints: vec![6],
origin_hidden: Some(true),
shown_component_origins: vec![vec![3]],
hidden_profiles: vec![7],
shown_dimensions: vec![8],
locked_bodies: vec!["B".into()],
+2 -15
View File
@@ -20,7 +20,6 @@ use solidcraft_geom::Vec3Geom as _;
use super::check::{located, whole};
use super::types::{not_blank_pattern, present};
use crate::cmd::LinkState;
use crate::params;
/// The most components `component.update_links` and `component.replace_with_link` read from a
@@ -433,9 +432,8 @@ pub struct LinksResult {
pub param_warnings: Vec<String>,
}
/// One linked component. A file that is looked for has `found` and `changed`, and a `state` and
/// a `reason` when it can't be reached; a refused one is not looked for and has `found` null, a
/// `state` and a `reason`.
/// One linked component. A file that is looked for has `found` and `changed`; a refused one is
/// not looked for and has `found` null and a `reason`.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum LinkInfo {
@@ -453,13 +451,6 @@ pub enum LinkInfo {
/// Whether the file is no longer what this design last read (`component.update_links`
/// reads it again); null when it is not found or the link is older than this record.
changed: Option<bool>,
/// Why the file can't be reached: `missing`, `unreadable` or `not_allowed`. Left out
/// when it can be read.
#[serde(default, skip_serializing_if = "Option::is_none")]
state: Option<LinkState>,
/// What to show about it. Left out with `state`.
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
/// The file is on a network location that is not allowed.
Network {
@@ -471,8 +462,6 @@ pub enum LinkInfo {
found: Option<bool>,
/// The network location.
host: String,
/// Always `not_allowed`.
state: LinkState,
reason: String,
},
/// The path names a device.
@@ -483,8 +472,6 @@ pub enum LinkInfo {
file: String,
/// Always null: it is not looked for.
found: Option<bool>,
/// Always `unreadable`.
state: LinkState,
reason: String,
},
}
@@ -200,11 +200,6 @@ fn link_replies_match_the_output_schema() {
// The design B links changed, and C's file is gone: B updates, C is named in `failed`.
save_box(&b, 25);
std::fs::remove_file(&c).unwrap_or_else(|e| panic!("{e}"));
// A file that is gone says why: its state and reason, in a reply that matches.
let gone = run("component.links", json!({}));
let missing = gone["links"].as_array().and_then(|l| l.iter().find(|e| e["component"] == third["component"])).cloned().unwrap_or_default();
assert!(missing["found"] == false && missing["state"] == "missing" && missing["reason"].is_string(), "{gone}");
matches(output_schema_for::<LinksResult>(), "component.links", &gone);
let update = run("component.update_links", json!({"components": ["B", "C", "A"]}));
assert!(update["updated"] == json!(["B"]) && update["unchanged"] == json!(["A"]), "{update}");
assert!(update["failed"][0]["name"] == "C" && update["failed"][0]["component"] == third["component"], "{update}");
+1 -26
View File
@@ -6,7 +6,7 @@
//! is refused when the feature is evaluated, which fails the command.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use super::types::{AngleExpr, ConstructionCommon, GeoRefParam, NumberExpr, PlaneCommon, Point3, feature_common, present};
@@ -254,31 +254,6 @@ pub struct PlanePerpendicularParams {
pub common: PlaneCommon,
}
/// Add a construction plane, axis or point (`construct.plane`, `construct.axis`,
/// `construct.point`) with the first of the family's methods (`construct.plane.*`…, in the order
/// the commands are listed) that takes the references in its reference parameters, with `with`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConstructAutoParams {
/// What to build it from, one to three references in any order.
#[schemars(length(min = 1, max = 3))]
pub refs: Vec<GeoRefParam>,
/// The method's other parameters (`offset`, `angle`, `t`, `name`…): only a method that takes
/// them all is used.
#[serde(default)]
pub with: Option<serde_json::Map<String, serde_json::Value>>,
}
/// What `construct.plane`, `construct.axis` and `construct.point` return: the feature, and the
/// method they chose.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ConstructAutoResult {
/// The id of the CONSTRUCT command whose method was used (`construct.plane.midplane`…).
pub method: String,
#[serde(flatten)]
pub feature: super::features::FeatureResult,
}
#[cfg(test)]
#[path = "construct_tests.rs"]
mod tests;
-4
View File
@@ -962,10 +962,6 @@ pub struct SketchInspected {
pub points: Vec<SketchPoint>,
pub constraints: Vec<SketchConstraint>,
pub profiles: Vec<SketchProfile>,
/// Planar closed loops the 3D sketch curves take part in, in planes of their own (#741):
/// each has `index`, `area_mm2`, `plane` (origin and unit normal), `centroid`, `curves`, `holes`.
#[schemars(with = "Vec<Map<String, Value>>")]
pub profiles_3d: Vec<Value>,
/// Links to geometry outside the sketch (projections, intersections, includes), as saved.
#[schemars(with = "Vec<Map<String, Value>>")]
pub links: Value,
+1 -92
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::joints::cam::MAX_PROFILE_EDGES;
use solidcraft_doc::joints::{Constraint, Coupling, JointExprs, JointKind, Snap};
use solidcraft_doc::joints::{Coupling, JointExprs, JointKind, Snap};
use solidcraft_geom::Vec3Geom;
use super::check::whole;
@@ -559,88 +559,6 @@ impl<'de> Deserialize<'de> for CreateJointParams {
}
}
/// An assembly constraint's type, as `assembly.constraint.create` takes it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConstraintTypeParam(pub Constraint);
const CONSTRAINT_MESSAGE: &str = "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis";
impl ConstraintTypeParam {
fn read(v: &Value) -> Result<Self, String> {
v.as_str().and_then(Constraint::parse).map(ConstraintTypeParam).ok_or_else(|| CONSTRAINT_MESSAGE.to_string())
}
}
impl JsonSchema for ConstraintTypeParam {
fn schema_name() -> Cow<'static, str> {
"ConstraintType".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The rule: mate (two planes against each other), flush (two planes facing the same way), distance (a plane or a point `offset` from a plane), concentric (two round faces on one axis), insert (two round edges at one centre: only a turn is left), parallel, perpendicular, angle (two planes or axes), tangent (a cylinder resting on a plane) or point_on_axis. Case, spaces, `_` and `-` are ignored; coincident and axis are also taken.",
"type": "string",
"minLength": 1
})
}
}
/// Constrain two parts by a geometric rule between a reference on each: the rule is kept as a
/// joint of the kind it needs (a mate is planar), made where the parts are, so b moves no further
/// than the rule needs and keeps every motion it leaves free. Several constraints between one pair
/// add up. One undo step.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = create_required)]
pub struct CreateConstraintParams {
/// The rule.
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ConstraintTypeParam")]
pub kind: Option<ConstraintTypeParam>,
/// Side a's reference: a joint origin, as joint.create takes it (a face for a plane, a circle
/// for an axis, a point).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointOriginArg")]
pub a: Option<JointOriginArg>,
/// Side b's reference, as `a`; b is the part that moves.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointOriginArg")]
pub b: Option<JointOriginArg>,
/// The gap from a's reference to b's, in mm or an expression (a mate's or flush's gap, a
/// distance; needed for distance). Default: 0.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointValue")]
pub offset: Option<JointValue>,
/// An angle constraint's angle, in degrees or an expression (needed for angle).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointValue")]
pub angle: Option<JointValue>,
/// Turn b over: its reference along a's instead of the way the rule puts it. Default: a mate
/// is against, a flush along, the others as the parts face now.
#[serde(default)]
pub flip: Option<bool>,
/// The constraint's name. Default: the rule and the first free number (Mate1).
#[serde(default)]
pub name: Option<String>,
/// Leave out `dof` and `fixed` in the reply. Default: false.
#[serde(default)]
pub quiet: Option<bool>,
}
impl<'de> Deserialize<'de> for CreateConstraintParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = keys(d)?;
Ok(CreateConstraintParams {
kind: given(&m, "type", ConstraintTypeParam::read)?,
a: given(&m, "a", JointOriginArg::read)?,
b: given(&m, "b", JointOriginArg::read)?,
offset: given(&m, "offset", JointValue::read)?,
angle: given(&m, "angle", JointValue::read)?,
flip: flag(&m, "flip")?,
name: text(&m, "name")?,
quiet: flag(&m, "quiet")?,
})
}
}
/// Add a joint between two occurrences that stay where they are: its frame is a joint origin
/// (`at`, default b's origin), and the joint starts with the origins coinciding as they are. An
/// origin on a or b is kept on its face or hole there, so the joint follows it as the design
@@ -1116,11 +1034,6 @@ pub struct ListedJoint {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointExpressions")]
pub expressions: Option<JointExprs>,
/// The assembly constraint the joint was made as (mate, flush…). Left out for a joint made
/// as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub constraint: Option<Constraint>,
}
/// A named joint origin.
@@ -1405,10 +1318,6 @@ pub struct InspectedJoint {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointExpressions")]
pub expressions: Option<JointExprs>,
/// The assembly constraint it was made as (mate, flush…). Left out for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub constraint: Option<Constraint>,
}
/// What `joint.inspect` returns.
+4 -39
View File
@@ -3,10 +3,10 @@ use solidcore_record::Vec3;
use solidcraft_doc::joints::JointKind;
use super::{
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateConstraintParams, CreateJointParams, DeleteJointParams, DeleteJointResult,
DriveJointParams, DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName,
JointLimitsParams, ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams,
ReachParams, ReachResult, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams,
DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName, JointLimitsParams,
ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams, ReachParams, ReachResult,
RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
@@ -19,7 +19,6 @@ fn schemas_and_the_key_check_agree() {
input_schema_for::<CreateJointParams>(),
&["a", "angle", "b", "flip", "limits", "name", "offset", "quiet", "type", "value", "values"],
);
assert_keys(input_schema_for::<CreateConstraintParams>(), &["a", "angle", "b", "flip", "name", "offset", "quiet", "type"]);
assert_keys(input_schema_for::<AsBuiltJointParams>(), &["a", "at", "b", "limits", "name", "quiet", "type", "value", "values"]);
assert_keys(input_schema_for::<DriveJointParams>(), &["continuous", "index", "joint", "quiet", "stop_at_budget", "value", "values"]);
assert_keys(input_schema_for::<JointLimitsParams>(), &["index", "joint", "max", "min", "quiet"]);
@@ -43,7 +42,6 @@ fn schemas_and_the_key_check_agree() {
#[test]
fn the_commands_publish_their_schemas() {
for id in [
"assembly.constraint.create",
"joint.create",
"joint.as_built",
"joint.drive",
@@ -206,13 +204,6 @@ fn strict_keeps_todays_errors() {
("joint.screw", json!({"a": true, "b": 1, "pitch": 1})),
("joint.cam", json!({})),
("joint.cam", json!({"a": false, "b": 1})),
("assembly.constraint.create", json!({})),
("assembly.constraint.create", json!({"type": "weld"})),
("assembly.constraint.create", json!({"type": 5})),
("assembly.constraint.create", json!({"type": "mate"})),
("assembly.constraint.create", json!({"type": "mate", "a": 5, "b": at(0, 1.0)})),
("assembly.constraint.create", json!({"type": "mate", "a": {}, "b": at(0, 1.0)})),
("assembly.constraint.create", json!({"type": "mate", "a": at(0, 0.0), "b": at(0, 1.0), "offset": true})),
]);
}
@@ -519,18 +510,6 @@ fn the_schemas_take_what_the_handlers_read() {
input_schema_for::<InspectJointParams>(),
&[(json!({}), true), (json!({"joint": "J"}), true), (json!({"joint": 4}), true), (json!({"joint": true}), false)],
);
takes(
input_schema_for::<CreateConstraintParams>(),
&[
(json!({"type": "mate", "a": {"face": [0, 0, 0]}, "b": "Origin"}), true),
(json!({"type": "distance", "a": {"face": [0, 0, 0]}, "b": {"point": [1, 2, 3]}, "offset": "gap"}), true),
(json!({"type": "angle", "a": "A", "b": "B", "angle": 30}), true),
(json!({"a": "A", "b": "B"}), false),
(json!({"type": 5, "a": "A", "b": "B"}), false),
(json!({"type": "mate", "a": "A", "b": "B", "flip": 1}), false),
(json!({"type": "mate", "a": "A", "b": "B", "values": [1]}), false),
],
);
takes(input_schema_for::<ListJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
takes(input_schema_for::<SolveJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
}
@@ -576,20 +555,6 @@ fn replies_match_the_output_schema() {
json!({"type": "rigid", "a": "Top", "b": {"occurrence": "LeafC", "face": [20, 15, 0]}, "name": "Stack", "offset": "0 mm", "angle": 0}),
);
matches(output_schema_for::<JointAddedResult>(), "joint.create", &by_origin);
// assembly.constraint.create answers as joint.create does.
let rule = run(
"assembly.constraint.create",
json!({"type": "parallel", "a": {"occurrence": "LeafA", "face": [20, 15, 3]}, "b": {"occurrence": "LeafE", "face": [20, 15, 3]}, "name": "Rule"}),
);
matches(output_schema_for::<JointAddedResult>(), "assembly.constraint.create", &rule);
// Listed and inspected with the rule it was made as.
let list = run("joint.list", json!({}));
matches(output_schema_for::<ListJointsResult>(), "joint.list", &list);
assert!(list["joints"].as_array().is_some_and(|j| j.iter().any(|j| j["constraint"] == "parallel")), "{list}");
let inspected = run("joint.inspect", json!({"joint": "Rule"}));
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &inspected);
assert_eq!(inspected["joints"][0]["constraint"], "parallel", "{inspected}");
run("joint.delete", json!({"joint": "Rule"}));
let built = run("joint.as_built", json!({"type": "slider", "a": "LeafB", "b": "LeafC", "name": "Slide", "limits": [[0, 10]], "quiet": false}));
matches(output_schema_for::<JointAddedResult>(), "joint.as_built", &built);
let at = run("joint.as_built", json!({"type": "rigid", "a": "LeafA", "b": "LeafD", "at": {"point": [0, 0, 0]}, "values": [1]}));
-1
View File
@@ -89,7 +89,6 @@ pub mod motion_analysis;
pub mod motion_play;
pub mod motion_trace;
pub mod new_design;
pub mod notes;
pub mod panels;
pub mod parameter_sweep;
pub mod parameters;
-77
View File
@@ -1,77 +0,0 @@
//! Typed contracts for the note commands (#1631).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::types::Point3;
/// Anchor a new note to a point in model space.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteAddParams {
/// The point the note is anchored to: `[x, y, z]` (mm).
pub anchor: Point3,
/// The note's text (may span lines).
pub text: String,
/// Save the note hidden: kept with the design but not drawn.
#[serde(default)]
pub hidden: bool,
}
/// Change a note's text, anchor, or hidden flag.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteUpdateParams {
/// The note's id.
pub note: u64,
/// The new text (may span lines).
#[serde(default)]
pub text: Option<String>,
/// The new anchor point: `[x, y, z]` (mm).
#[serde(default)]
pub anchor: Option<Point3>,
/// Show or hide the note.
#[serde(default)]
pub hidden: Option<bool>,
}
/// Delete a note.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteDeleteParams {
/// The note's id.
pub note: u64,
}
/// List the design's notes (takes nothing).
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteListParams {}
/// The result of `note.add` and `note.update`: the note's id.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteId {
pub note: u64,
}
/// The result of `note.delete`: the id that was removed.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteDeleted {
pub deleted: u64,
}
/// One note as `note.list` returns it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteItem {
pub id: u64,
/// The anchor point `[x, y, z]` (mm).
pub anchor: [f64; 3],
pub text: String,
pub hidden: bool,
}
/// The result of `note.list`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NotesList {
pub notes: Vec<NoteItem>,
}
-26
View File
@@ -277,28 +277,6 @@ fn hidden<'de, D: Deserializer<'de>>(d: D) -> Result<Option<HiddenLines>, D::Err
read_as(d, "hidden must be omit or dashed")
}
/// Where the drawing's zero point is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DxfZero {
/// The point the camera looks at.
Target,
/// The model's origin, seen from the camera.
Origin,
}
fn zero_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Where the drawing's zero point is: `target` is the point the camera looks at, `origin` is the model's origin as seen from the camera (for machining from a part corner). Default: target.",
"enum": ["target", "origin"],
"default": "target"
})
}
fn zero<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DxfZero>, D::Error> {
read_as(d, "zero must be target or origin")
}
fn tolerance<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
read_with(d, "tolerance must be 0.0001..1 mm", |v| v.as_f64().filter(|t| (0.0001..=1.0).contains(t)))
}
@@ -351,10 +329,6 @@ pub struct ExportViewDxfParams {
#[serde(default, deserialize_with = "tolerance", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "tolerance_schema")]
pub tolerance: Option<f64>,
/// Where the drawing's zero point is: the camera target or the model's origin. Default: target.
#[serde(default, deserialize_with = "zero", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "zero_schema")]
pub zero: Option<DxfZero>,
/// The `.dxf` file to write. Default: the reply carries the DXF text instead.
#[serde(default, deserialize_with = "export_path", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "path_schema")]
+1 -4
View File
@@ -29,7 +29,7 @@ fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SilhouetteParams>(), &["bodies", "camera", "rows", "view"]);
assert_keys(input_schema_for::<ExportViewDxfParams>(), &["camera", "hidden", "path", "tolerance", "zero"]);
assert_keys(input_schema_for::<ExportViewDxfParams>(), &["camera", "hidden", "path", "tolerance"]);
}
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `camera`
@@ -209,8 +209,6 @@ fn the_schemas_take_what_the_handlers_read() {
(json!({"camera": camera(), "tolerance": 2}), false),
(json!({"camera": camera(), "tolerance": 0}), false),
(json!({"camera": camera(), "pathh": "a.dxf"}), false),
(json!({"camera": camera(), "zero": "origin"}), true),
(json!({"camera": camera(), "zero": "corner"}), false),
],
);
}
@@ -225,7 +223,6 @@ fn the_check_names_what_is_wrong_with_a_key() {
assert_eq!(issues(SIL, json!({"bodies": [1]})), ["`bodies` must contain names"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "hidden": 1})), ["hidden must be omit or dashed"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "tolerance": 3})), ["tolerance must be 0.0001..1 mm"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "zero": "corner"})), ["zero must be target or origin"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "path": " "})), ["invalid path"]);
assert_eq!(issues(DXF, json!({"camera": 1})), ["invalid camera"]);
// Left out, the handler's own refusal stands in for "is required".

Some files were not shown because too many files have changed in this diff Show More