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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:58:18 +00:00
1 parent eb22a149b1
commit 9301bc4210
17 files changed
+1842 -30

No files matched your search

+278
View File
@@ -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;