Holes at sketch points (one feature, many holes, following the points) and threaded holes; cosmetic threads (FusionThreadCommand, ISO metric, model.threads); components groundwork: the document is a component tree (New Component, Create Components from Bodies, active component, component.list)

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 23:00:57 +00:00
1 parent 61ed21847a
commit b3ca8911eb
13 files changed
+641 -88

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
//! Components: the design as a tree. New sketches and features go into the active component;
//! bodies belong to their feature's component unless moved. (Groundwork for assemblies:
//! occurrences, joints and per-component physical properties build on this.)
use serde_json::{Value, json};
use super::CommandSpec;
use crate::params::{bad, str_, string_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("FusionCreateNewComponentCommand", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
.icon("component")
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
CommandSpec::new("FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies", from_bodies)
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
CommandSpec::new("component.list", "List Components", list).noundo(),
];
fn component_key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
Some(Value::String(s)) => Some(s.clone()),
_ => None,
}
}
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCreateNewComponentCommand";
let parent = match component_key(p.get("parent")) {
Some(k) => s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?,
None => s.active_component,
};
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
s.active_component = id;
}
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
Ok(json!({"component": id, "name": name, "parent": parent}))
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionCreateComponentsFromBodiesCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 1000 {
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
}
let st = s.model.state();
for b in &bodies {
if st.body(b).is_none() {
return Err(bad(cmd, format!("no body `{b}`")));
}
}
let parent = s.active_component;
let mut made = Vec::new();
for b in bodies {
let id = s.doc_mut().add_component(Some(&b), parent)?;
s.doc_mut().body_components.insert(b.clone(), id);
made.push(json!({"component": id, "body": b}));
}
Ok(json!({ "components": made }))
}
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.activate";
let k = component_key(p.get("component")).ok_or_else(|| bad(cmd, "`component` must be an id or name"))?;
let id = s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?;
s.active_component = id;
s.revision += 1;
Ok(json!({ "active": id }))
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.model.state();
let doc = &s.doc;
let node = |id: u64, name: &str, parent: Option<u64>| {
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
let features: Vec<&str> = doc.features.iter().filter(|f| f.component == id).map(|f| f.name.as_str()).collect();
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features})
};
let mut out = vec![node(0, &doc.name, None)];
for c in &doc.components {
out.push(node(c.id, &c.name, Some(c.parent)));
}
Ok(json!({"active": s.active_component, "components": out}))
}
+84 -17
View File
@@ -47,7 +47,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face; direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?"),
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("FusionThreadCommand", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
CommandSpec::new("FusionShellBodyCommand", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
@@ -217,6 +221,12 @@ fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result
fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
let name = str_(p, "name");
let id = s.doc_mut().add_feature(kind, name)?;
let comp = s.active_component;
if comp != 0
&& let Some(f) = s.doc_mut().feature_mut(id)
{
f.component = comp;
}
let mut names = string_list(p, "body_names");
if let Some(b) = str_(p, "body_name") {
names.insert(0, b.to_string());
@@ -595,25 +605,60 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
fn hole(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionHoleCommand";
let position = p.get("position").and_then(vec3).ok_or_else(|| bad(cmd, "`position` must be [x, y, z]"))?;
let direction = match p.get("direction").and_then(vec3) {
Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?,
None => {
// Into the planar face the position lies on.
// At sketch points: the holes go perpendicular to the sketch and follow its points.
let points = match (p.get("sketch"), p.get("points")) {
(Some(_), Some(_)) => {
let sketch = feature_sketch(s, p, cmd)?;
let ids = string_list(p, "points");
if ids.is_empty() || ids.len() > 10_000 {
return Err(bad(cmd, "`points` must list 1…10000 sketch point ids"));
}
let st = s.model.state();
let mut found = None;
for b in &st.bodies {
let tol = (b.body.size() * 1e-3).max(1e-3);
for f in b.body.faces(tol).unwrap_or_default() {
if let Some(n) = f.plane_normal
&& (position - f.centroid).dot(n).abs() < tol * 10.0
{
found = Some(-n);
}
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
for id in &ids {
if !ss.sketch.points.iter().any(|q| &q.id == id) {
return Err(bad(cmd, format!("no point `{id}` in the sketch")));
}
}
found.ok_or_else(|| bad(cmd, "no planar face at `position`; give `direction`"))?
let n = ss.plane.normal();
let first =
ids.first().and_then(|id| ss.sketch.points.iter().find(|q| &q.id == id)).map(|q| ss.plane.to_world(q.pos)).unwrap_or_default();
Some((solvecraft_doc::SketchPoints { sketch, ids }, first, -n))
}
_ => None,
};
let thread = str_(p, "thread").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
if let Some(t) = &thread
&& solvecraft_doc::parse_metric_thread(t).is_none()
{
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M6 or M6x0.75)")));
}
let position = match (&points, p.get("position").and_then(vec3)) {
(Some((_, first, _)), None) => *first,
(_, Some(x)) => x,
(None, None) => return Err(bad(cmd, "`position` must be [x, y, z] (or give `sketch` and `points`)")),
};
let direction = match (p.get("direction").and_then(vec3), &points) {
(None, Some((_, _, d))) => *d,
(d, _) => match d {
Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?,
None => {
// Into the planar face the position lies on.
let st = s.model.state();
let mut found = None;
for b in &st.bodies {
let tol = (b.body.size() * 1e-3).max(1e-3);
for f in b.body.faces(tol).unwrap_or_default() {
if let Some(n) = f.plane_normal
&& (position - f.centroid).dot(n).abs() < tol * 10.0
{
found = Some(-n);
}
}
}
found.ok_or_else(|| bad(cmd, "no planar face at `position`; give `direction`"))?
}
},
};
let diameter = req_expr(cmd, p, "diameter")?;
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
@@ -638,7 +683,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
},
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind })
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread })
}
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
@@ -716,3 +761,25 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
}
add_feature(s, p, FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets") })
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionThreadCommand";
let face = p
.get("face")
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
.ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on a cylindrical face"))?;
let designation = str_(p, "designation").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
if let Some(t) = &designation
&& solvecraft_doc::parse_metric_thread(t).is_none()
{
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M8 or M8x1)")));
}
let length = expr(p, "length");
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
}
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
}
+6
View File
@@ -18,6 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -206,3 +207,8 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
}
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.model.state();
Ok(json!({ "threads": st.threads }))
}
+2
View File
@@ -2,6 +2,7 @@
//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles
//! as the parity metric (`cargo xtask parity`).
mod component;
mod edit;
mod face;
mod features;
@@ -112,6 +113,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
+4
View File
@@ -355,6 +355,10 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
s.doc.resolve_plane(&vals, &plane, 0)?;
let name = str_(p, "name");
let id = s.doc_mut().add_feature(FeatureKind::Sketch { plane, sketch: Sketch::new() }, name)?;
let comp = s.active_component;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.component = comp;
}
s.active_sketch = Some(id);
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
// Sketching on a face projects the face's edges (unless turned off).