Sketch projection: Project (edges, face loops, body silhouettes, vertices, other sketches' curves and points, origin, axes, planes), Intersect (body/face/edge section with the sketch plane) and Include 3D Geometry as linked reference geometry: fixed, purple, re-resolved on every recompute (follows upstream edits, keeps ids when the shape is unchanged, flagged when the reference is lost), usable in profiles, constraints and dimensions; Break Link; sketching on a face auto-projects its edges (sketch.auto_project); drawing on a model vertex or edge projects it
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
eb22a149b1
commit
9301bc4210
17 files changed
+1842
-30
No files matched your search
@@ -65,18 +65,22 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
.map(|(i, c)| {
|
||||
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
|
||||
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
|
||||
match c.kind {
|
||||
let mut v = match c.kind {
|
||||
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fully_constrained": det}),
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
o.insert("link".into(), json!(l));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let points: Vec<Value> = sk
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": p.fixed, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": p.fixed, "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
|
||||
.collect();
|
||||
let constraints: Vec<Value> = sk
|
||||
.constraints
|
||||
@@ -112,6 +116,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
"points": points,
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod features;
|
||||
mod file;
|
||||
mod inspect;
|
||||
mod sketch;
|
||||
mod sketch_project;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
@@ -104,6 +105,7 @@ pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
v.extend(sketch::COMMANDS.iter());
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
|
||||
@@ -192,6 +192,7 @@ fn edit<T>(
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
super::sketch_project::refresh(s, &doc, id, &mut sk);
|
||||
let out = f(&mut sk, &mut doc)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, &mut sk)?;
|
||||
@@ -356,7 +357,15 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = s.doc_mut().add_feature(FeatureKind::Sketch { plane, sketch: Sketch::new() }, name)?;
|
||||
s.active_sketch = Some(id);
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"sketch": id, "name": name}))
|
||||
// Sketching on a face projects the face's edges (unless turned off).
|
||||
let face = p.get("plane").and_then(|v| v.get("face")).and_then(vec3);
|
||||
let mut link = None;
|
||||
if let Some(at) = face
|
||||
&& bool_(p, "project_edges").unwrap_or(s.auto_project)
|
||||
{
|
||||
link = super::sketch_project::auto_project_face(s, id, at)?;
|
||||
}
|
||||
Ok(json!({"sketch": id, "name": name, "projected": link}))
|
||||
}
|
||||
|
||||
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -387,6 +396,7 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
|
||||
fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "DrawPolyline";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?;
|
||||
if list.len() < 2 || list.len() > 10_000 {
|
||||
return Err(bad(cmd, "a line needs 2 or more points"));
|
||||
@@ -530,6 +540,7 @@ fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
|
||||
|
||||
fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "CircleCenterRadius";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let r = radius_arg(p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
@@ -582,6 +593,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ArcThreePoint";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (a, b) = (req_parg(sk, p, "start", cmd)?, req_parg(sk, p, "end", cmd)?);
|
||||
@@ -603,6 +615,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ArcCenterTwoPoint";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let sweep = num(p, "sweep");
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
@@ -1126,6 +1139,9 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
points.push(q);
|
||||
} else if sk.constraints.iter().any(|c| &c.id == id) {
|
||||
cons.push(id.clone());
|
||||
} else if sk.link(id).is_some() {
|
||||
curves.extend(sk.link_curves(id));
|
||||
points.extend(sk.link_points(id));
|
||||
} else {
|
||||
return Err(bad(cmd, format!("unknown sketch entity `{id}`")));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Sketch projection: Project, Intersect and Include 3D Geometry bring model edges, faces,
|
||||
//! bodies, other sketches and the origin into the active sketch as linked reference geometry
|
||||
//! (fixed, drawn purple, updated when the upstream geometry changes). Break Link turns them into
|
||||
//! normal geometry.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::project::resolve;
|
||||
use solvecraft_doc::{Document, FeatureKind};
|
||||
use solvecraft_geom::{Plane, Vec3};
|
||||
use solvecraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("ProjectNewCmd", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
|
||||
CommandSpec::new("IntersectCmd", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
|
||||
"refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked",
|
||||
),
|
||||
CommandSpec::new("Include3DGeometry", "Include 3D Geometry", include_cmd)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("include")
|
||||
.enabled(in_sketch)
|
||||
.params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", break_link)
|
||||
.enabled(in_sketch)
|
||||
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project)
|
||||
.noundo()
|
||||
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
];
|
||||
|
||||
const REFS_DOC: &str = "refs: [{edge: [x,y,z], body?} | {face: [x,y,z], body?} (its edge loops) | {body: name} (silhouette) | {vertex: [x,y,z], body?} | {sketch, curve} | {sketch, point} | \"origin\" | {axis: X|Y|Z} | {plane: XY|XZ|YZ|name}]";
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
if let Some(name) = v.as_str() {
|
||||
return match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
};
|
||||
}
|
||||
let body = || str_(v, "body").unwrap_or("").to_string();
|
||||
if let Some(at) = v.get("edge").and_then(vec3) {
|
||||
return Ok(LinkSource::Edge { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("face").and_then(vec3) {
|
||||
return Ok(LinkSource::Face { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("vertex").and_then(vec3) {
|
||||
return Ok(LinkSource::Vertex { body: body(), at });
|
||||
}
|
||||
if let Some(sk) = v.get("sketch") {
|
||||
let key = match sk {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
|
||||
};
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = str_(v, "curve") {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = str_(v, "point") {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
return Err(bad(cmd, "a sketch reference needs `curve` or `point`"));
|
||||
}
|
||||
if let Some(b) = str_(v, "body") {
|
||||
return Ok(LinkSource::Body { body: b.to_string() });
|
||||
}
|
||||
if let Some(a) = str_(v, "axis") {
|
||||
return Ok(LinkSource::Axis { name: a.to_ascii_uppercase() });
|
||||
}
|
||||
if let Some(p) = str_(v, "plane") {
|
||||
return Ok(LinkSource::Plane { name: p.to_string() });
|
||||
}
|
||||
if v.get("origin").is_some() {
|
||||
return Ok(LinkSource::Origin);
|
||||
}
|
||||
Err(bad(cmd, format!("cannot project {v}")))
|
||||
}
|
||||
|
||||
/// The plane of a sketch feature.
|
||||
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
|
||||
let (vals, _) = doc.param_values();
|
||||
match doc.feature(id).map(|f| &f.kind) {
|
||||
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane(&vals, plane, 0)?),
|
||||
_ => Err(EngineError::Other(format!("feature {id} is not a sketch"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
|
||||
/// current reference geometry. Returns warnings.
|
||||
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
|
||||
if sk.links.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(plane) = sketch_plane(doc, id) else { return Vec::new() };
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
solvecraft_doc::project::refresh_links(doc, &vals, &st, &plane, sk)
|
||||
}
|
||||
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
|
||||
let plane = sketch_plane(doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
||||
Ok(sk.add_link(kind, r.source, &r.geom)?)
|
||||
}
|
||||
|
||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||
let curves: Vec<String> = sk.link_curves(link).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
|
||||
let points: Vec<String> = sk.link_points(link).iter().filter_map(|p| sk.points.get(*p).map(|p| p.id.clone())).collect();
|
||||
json!({"link": link, "curves": curves, "points": points})
|
||||
}
|
||||
|
||||
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Value> {
|
||||
let refs: Vec<Value> = match p.get("refs").or_else(|| p.get("ref")) {
|
||||
Some(Value::Array(a)) => a.iter().take(1000).cloned().collect(),
|
||||
Some(v) => vec![v.clone()],
|
||||
None => return Err(bad(cmd, "`refs` must list what to project")),
|
||||
};
|
||||
if refs.is_empty() {
|
||||
return Err(bad(cmd, "`refs` is empty"));
|
||||
}
|
||||
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
let sources: Vec<LinkSource> = refs.iter().map(|v| source(s, v, cmd)).collect::<Result<_>>()?;
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
refresh(s, &doc, id, &mut sk);
|
||||
let mut out = Vec::new();
|
||||
for src in sources {
|
||||
let what = src.describe();
|
||||
let l = add_link(s, &doc, id, &mut sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
|
||||
out.push(link_json(&sk, &l));
|
||||
}
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(json!({"links": out}))
|
||||
}
|
||||
|
||||
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_links(s, p, "ProjectNewCmd", LinkKind::Project)
|
||||
}
|
||||
|
||||
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_links(s, p, "IntersectCmd", LinkKind::Intersect)
|
||||
}
|
||||
|
||||
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
run_links(s, p, "Include3DGeometry", LinkKind::Include)
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.break_link";
|
||||
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
let mut doc = (*s.doc).clone();
|
||||
let sk = doc.sketch_mut(id)?;
|
||||
let mut links: Vec<String> = Vec::new();
|
||||
if let Some(l) = str_(p, "link") {
|
||||
if sk.link(l).is_none() {
|
||||
return Err(bad(cmd, format!("no link `{l}`")));
|
||||
}
|
||||
links.push(l.to_string());
|
||||
}
|
||||
for e in crate::params::string_list(p, "entities") {
|
||||
let l = sk
|
||||
.curves
|
||||
.iter()
|
||||
.find(|c| c.id == e)
|
||||
.and_then(|c| c.link.clone())
|
||||
.or_else(|| sk.points.iter().find(|q| q.id == e).and_then(|q| q.link.clone()))
|
||||
.ok_or_else(|| bad(cmd, format!("`{e}` is not linked geometry")))?;
|
||||
if !links.contains(&l) {
|
||||
links.push(l);
|
||||
}
|
||||
}
|
||||
if links.is_empty() {
|
||||
return Err(bad(cmd, "give `link` or `entities`"));
|
||||
}
|
||||
for l in &links {
|
||||
sk.break_link(l)?;
|
||||
}
|
||||
*s.doc_mut() = doc;
|
||||
Ok(json!({"broken": links}))
|
||||
}
|
||||
|
||||
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
|
||||
Ok(json!({"auto_project": s.auto_project}))
|
||||
}
|
||||
|
||||
/// Point arguments that snap to model geometry while drawing, rewritten to sketch point ids:
|
||||
/// `{"vertex": [x,y,z]}` projects the vertex (a linked point); `{"on_edge": [x,y,z]}` projects
|
||||
/// the edge and makes a free point on it (coincident with the projection). Everything else is
|
||||
/// left as it is. The links are added to the active sketch in the session's document.
|
||||
pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let Some(id) = s.active_sketch else { return Ok(p.clone()) };
|
||||
let mut out = p.clone();
|
||||
let mut doc: Option<(Document, Sketch)> = None;
|
||||
let fix = |v: &mut Value, s: &Session, doc: &mut Option<(Document, Sketch)>| -> Result<()> {
|
||||
let (vertex, edge) = (v.get("vertex").and_then(vec3), v.get("on_edge").and_then(vec3));
|
||||
if vertex.is_none() && edge.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if doc.is_none() {
|
||||
let d = (*s.doc).clone();
|
||||
let mut sk = d.sketch(id)?.clone();
|
||||
refresh(s, &d, id, &mut sk);
|
||||
*doc = Some((d, sk));
|
||||
}
|
||||
let Some((d, sk)) = doc.as_mut() else { return Ok(()) };
|
||||
let body = str_(v, "body").unwrap_or("").to_string();
|
||||
let pid = if let Some(at) = vertex {
|
||||
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Vertex { body, at }).map_err(|e| bad(cmd, format!("vertex: {e}")))?;
|
||||
let pi = sk.link_points(&l).first().copied().ok_or_else(|| bad(cmd, "vertex projects to nothing"))?;
|
||||
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
|
||||
} else if let Some(at) = edge {
|
||||
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
|
||||
let plane = sketch_plane(d, id)?;
|
||||
let q = plane.to_local(at);
|
||||
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
|
||||
let pi = sk.add_point(q, None)?;
|
||||
sk.add_constraint(ConstraintKind::PointOnCurve { p: pi, c: ci }, None)?;
|
||||
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
*v = Value::String(pid);
|
||||
Ok(())
|
||||
};
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
for (_, v) in o.iter_mut() {
|
||||
match v {
|
||||
Value::Array(a) if a.len() <= 10_000 => {
|
||||
for x in a.iter_mut() {
|
||||
fix(x, s, &mut doc)?;
|
||||
}
|
||||
}
|
||||
Value::Object(_) => fix(v, s, &mut doc)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((mut d, sk)) = doc {
|
||||
*d.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = d;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Project the edges of the face a new sketch was created on (Fusion's default).
|
||||
pub(super) fn auto_project_face(s: &mut Session, id: u64, at: Vec3) -> Result<Option<String>> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
let l = match add_link(s, &doc, id, &mut sk, LinkKind::Project, LinkSource::Face { body: String::new(), at }) {
|
||||
Ok(l) => l,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok(Some(l))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,197 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
|
||||
fn box_part(s: &mut Session) {
|
||||
run(s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"}));
|
||||
run(s, "SketchCreate", json!({"plane": "XY"}));
|
||||
let r = run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
let l = ids(&r["curves"]);
|
||||
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
let start = match sk.curves[0].kind {
|
||||
solvecraft_sketch::CurveKind::Line { a, .. } => sk.points[a].id.clone(),
|
||||
_ => panic!(),
|
||||
};
|
||||
run(s, "ConstraintCoincident", json!({"a": start, "b": "origin"}));
|
||||
run(s, "SketchDimension", json!({"entities": [l[0]], "value": "width"}));
|
||||
run(s, "SketchDimension", json!({"entities": [l[1]], "value": 30}));
|
||||
run(s, "SketchStop", json!({}));
|
||||
run(s, "Extrude", json!({"distance": 20}));
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session) -> Value {
|
||||
run(s, "sketch.inspect", json!({}))
|
||||
}
|
||||
|
||||
fn linked_curves(si: &Value) -> Vec<Value> {
|
||||
si["curves"].as_array().unwrap().iter().filter(|c| c.get("link").is_some()).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_on_face_projects_its_edges_and_follows_edits() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
assert!(r["projected"].is_string(), "{r}");
|
||||
let si = inspect(&mut s);
|
||||
let lc = linked_curves(&si);
|
||||
assert_eq!(lc.len(), 4, "{si}");
|
||||
assert!(lc.iter().all(|c| c["type"] == "line"));
|
||||
// The face loop is one closed profile of projected lines.
|
||||
assert_eq!(si["profiles"].as_array().unwrap().len(), 1, "{si}");
|
||||
assert!((si["profiles"][0]["area_mm2"].as_f64().unwrap() - 1200.0).abs() < 1e-6);
|
||||
// Projected geometry is fixed: the sketch has no degrees of freedom.
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
// A circle on the face, dimensioned to a projected edge.
|
||||
let c = run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 3}));
|
||||
let cid = ids(&c["curves"])[0].clone();
|
||||
let e = lc.iter().find(|c| c["start_at"][1].as_f64().unwrap().abs() < 1e-9 && c["end_at"][1].as_f64().unwrap().abs() < 1e-9).unwrap();
|
||||
run(&mut s, "SketchDimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
|
||||
let si = inspect(&mut s);
|
||||
let center = si["points"].as_array().unwrap().iter().find(|p| p["id"] == format!("{cid}.center")).unwrap();
|
||||
assert!((center["at"][1].as_f64().unwrap() - 8.0).abs() < 1e-7, "{center}");
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
// Widen the box: the projected loop follows.
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50 mm"}));
|
||||
let st = s.model.state();
|
||||
let ss = st.sketches.last().unwrap();
|
||||
assert_eq!(ss.profiles.len(), 2);
|
||||
let outer = ss.profiles.iter().map(|p| p.area).fold(0.0, f64::max);
|
||||
assert!((outer - (1500.0 - std::f64::consts::PI * 9.0)).abs() < 1e-6, "{outer}");
|
||||
let w = s.model.results.last().unwrap().warning.clone();
|
||||
assert!(w.is_none(), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_project_can_be_turned_off() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
|
||||
assert!(r["projected"].is_null());
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "sketch.auto_project", json!({"value": false}));
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
assert!(r["projected"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_edges_bodies_sketches_and_origin() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
run(&mut s, "PrimitiveCylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
// An edge on the top face projects to a line on XY.
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [20, 30, 20]}]}));
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
|
||||
assert_eq!(c["type"], "line");
|
||||
let ys: Vec<f64> = [c["start_at"][1].as_f64().unwrap(), c["end_at"][1].as_f64().unwrap()].to_vec();
|
||||
assert!(ys.iter().all(|y| (y - 30.0).abs() < 1e-9), "{c}");
|
||||
// The cylinder's silhouette seen from above is its circle, exactly.
|
||||
let name = s.model.state().bodies[1].name.clone();
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": name}]}));
|
||||
let cid = r["links"][0]["curves"][0].clone();
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == cid).unwrap().clone();
|
||||
assert_eq!(c["type"], "circle", "{c}");
|
||||
assert!((c["radius"].as_f64().unwrap() - 6.0).abs() < 1e-6, "{c}");
|
||||
assert!((c["center_at"][0].as_f64().unwrap() - 80.0).abs() < 1e-6, "{c}");
|
||||
// Origin, the Y axis and the YZ plane.
|
||||
run(&mut s, "ProjectNewCmd", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
|
||||
// The box silhouette from above is its 40 x 30 outline.
|
||||
let b0 = s.model.state().bodies[0].name.clone();
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": b0}]}));
|
||||
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
let first = s.active_sketch;
|
||||
assert!(first.is_none());
|
||||
let sk1 = s.doc.features.last().unwrap().id;
|
||||
// Another sketch projects a curve of the first one.
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
|
||||
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
|
||||
let c2 = r["links"][0]["curves"][0].clone();
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == c2).unwrap().clone();
|
||||
assert_eq!(c["type"], "circle");
|
||||
// Bad references fail cleanly.
|
||||
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
|
||||
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_cuts_a_body_with_the_sketch_plane() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 12}));
|
||||
let name = s.model.state().bodies[0].name.clone();
|
||||
let r = run(&mut s, "IntersectCmd", json!({"refs": [{"body": name}]}));
|
||||
let si = inspect(&mut s);
|
||||
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
|
||||
assert_eq!(c["type"], "circle", "{c}");
|
||||
assert!((c["radius"].as_f64().unwrap() - 7.0).abs() < 1e-6, "{c}");
|
||||
assert_eq!(si["profiles"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_link_makes_normal_geometry() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
|
||||
let link = r["projected"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.break_link", json!({"link": link}));
|
||||
let si = inspect(&mut s);
|
||||
assert!(linked_curves(&si).is_empty());
|
||||
assert!(si["links"].as_array().unwrap().is_empty());
|
||||
// Now free: 4 lines with 4 shared corners, 8 degrees of freedom.
|
||||
assert_eq!(si["dof"], 8, "{si}");
|
||||
assert!(s.execute("sketch.break_link", &json!({"link": "j99"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_reference_keeps_geometry_with_a_warning() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let boxf = s.doc.features.last().unwrap().id;
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [5, 0, 10]}]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": boxf, "suppressed": true}));
|
||||
let st = s.model.state();
|
||||
let ss = st.sketches.last().unwrap();
|
||||
assert_eq!(ss.sketch.curves.len(), 1, "geometry kept");
|
||||
assert!(ss.sketch.links[0].lost);
|
||||
let w = s.model.results.last().unwrap().warning.clone().unwrap_or_default();
|
||||
assert!(w.contains("lost"), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_on_a_model_vertex_projects_it() {
|
||||
let mut s = Session::default();
|
||||
box_part(&mut s);
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "DrawPolyline", json!({"points": [{"vertex": [40, 30, 20]}, [60, 45]]}));
|
||||
let l = ids(&r["curves"])[0].clone();
|
||||
let si = inspect(&mut s);
|
||||
let start = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == l).unwrap()["start"].clone();
|
||||
let p = si["points"].as_array().unwrap().iter().find(|p| p["id"] == start).unwrap().clone();
|
||||
assert!(p["link"].is_string(), "{p}");
|
||||
assert_eq!(p["fixed"], true);
|
||||
assert!((p["at"][0].as_f64().unwrap() - 40.0).abs() < 1e-9);
|
||||
// Snapping onto an edge projects it and keeps the point on it.
|
||||
let r = run(&mut s, "DrawPolyline", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
|
||||
assert_eq!(ids(&r["curves"]).len(), 1);
|
||||
let si = inspect(&mut s);
|
||||
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}");
|
||||
}
|
||||
Reference in new issue
Block a user