feat: preserve adaptive sketch profiles and curved modeling workflows

This commit is contained in:
bherbruck committed 2026-10-11 09:06:25 -04:00
1 parent 4c4a0449a4
commit 4e595deff2
62 files changed
+7114 -362

No files matched your search

+138 -15
View File
@@ -4,6 +4,7 @@
use std::f64::consts::TAU;
use serde_json::{Value, json};
use solvecraft_doc::ProfileSel;
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
@@ -95,7 +96,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
if sk.is_linked_curve(c) {
return Err(bad(cmd, format!("`{id}` is projected geometry: break its link first")));
}
if sk.freeform_offsets.iter().any(|offset| offset.output == id) {
if sk.freeform_offsets.iter().any(|offset| offset.output == id)
|| sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.roles.iter().any(|role| role.output == id))
{
return Err(bad(cmd, "break the offset association before editing its output"));
}
Ok(c)
@@ -184,7 +187,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec<usize>, Vec<u
}
let mut pieces = vec![ci];
let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> {
let id = sk.fresh(prefix);
let id = sk.fresh(prefix)?;
sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false });
pieces.push(sk.curves.len() - 1);
Ok(())
@@ -549,7 +552,7 @@ fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize);
let virtual_corner = if used || c == 0 {
None
} else {
let id = sk.fresh("corner");
let id = sk.fresh("corner")?;
if let Some(pt) = sk.points.get_mut(c) {
pt.id = id.clone();
}
@@ -934,6 +937,14 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
sk.refresh_offsets()?;
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
let first = *sel.first().ok_or_else(|| bad(cmd, "no curves"))?;
if sk.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| {
topology.roles.iter().any(|role| role.retired) && topology.roles.iter().any(|role| !role.retired && ids.contains(&role.output))
}) {
return Err(bad(
cmd,
"offsets of a boundary with retired corner arcs are not supported yet; break its offset association to use the current fixed topology",
));
}
let mut made = Vec::new();
let mut cons = Vec::new();
// Circles offset on their own.
@@ -954,8 +965,9 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
outputs: ids_of(sk, &made),
distance: r - nr,
closed: true,
topology: None,
});
return Ok((ids_of(sk, &made), cons));
return Ok((ids_of(sk, &made), cons, None));
}
let pool: Vec<usize> = if use_chain {
(0..sk.curves.len())
@@ -986,7 +998,31 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
let (source, forward) = order[0];
let output = sk.add_freeform_offset(source, forward, d)?;
return Ok((ids_of(sk, &[output]), cons));
return Ok((ids_of(sk, &[output]), cons, None));
}
let sources = order
.iter()
.map(|(index, forward)| {
sk.curves.get(*index).map(|curve| (curve.id.clone(), *forward)).ok_or_else(|| bad(cmd, "offset chain source vanished"))
})
.collect::<Result<Vec<_>>>()?;
if closed && sk.convex_offset_supported(&sources)? {
let created = sk.add_convex_offset(sources.clone(), d)?;
let roles = sk
.offsets
.iter()
.filter_map(|offset| offset.topology.as_ref())
.find(|topology| topology.id == created.association)
.map(|topology| topology.roles.clone())
.ok_or_else(|| bad(cmd, "new offset association is missing"))?;
for (source, role) in sources.iter().zip(&roles) {
if role.kind == solvecraft_sketch::OffsetRoleKind::Line {
let a = sk.curve_index(&source.0).ok_or_else(|| bad(cmd, "offset source line vanished"))?;
let b = sk.curve_index(&role.output).ok_or_else(|| bad(cmd, "offset output line vanished"))?;
cons.push(add_c(sk, ConstraintKind::Parallel { a, b })?);
}
}
return Ok((created.curves, cons, Some(created.association)));
}
let pieces: Vec<Piece> =
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
@@ -1072,23 +1108,32 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let outputs = ids_of(sk, &made);
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed, topology: None });
sk.refresh_offsets()?;
}
Ok((outputs, cons))
Ok((outputs, cons, None))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
let mut response = json!({"curves":out.0,"constraints":out.1,"sketch":info});
if let Some(association) = out.2 {
response["association"] = json!(association);
}
Ok(response)
}
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.offset.break";
let sketch = super::sketch::target_sketch(s, p, cmd)?;
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
let record = sk
.freeform_offsets
let record = sk.freeform_offsets.iter().position(|record| record.output == output);
let topology = sk
.offsets
.iter()
.position(|record| record.output == output)
.ok_or_else(|| bad(cmd, "that curve has no free-form offset association"))?;
.filter_map(|record| record.topology.as_ref())
.any(|record| record.id == output || record.roles.iter().any(|role| role.output == output));
if record.is_none() && !topology {
return Err(bad(cmd, "curve is not an offset output"));
}
// A parameter edit may have changed the evaluated source without
// rewriting the stored fit points. Break must freeze the displayed,
// current dimensioned geometry, rather than its stale stored predecessor.
@@ -1102,6 +1147,19 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
}
sk.validate_periodic_splines()?;
if let Some(association) = sk
.offsets
.iter()
.filter_map(|record| record.topology.as_ref())
.find(|record| record.id == output || record.roles.iter().any(|role| role.output == output))
.map(|record| record.id.clone())
{
freeze_offset_profiles(doc, sketch, sk, &association)?;
}
if let Some(curves) = sk.break_offset_topology(output)? {
return Ok((output.to_string(), Some(curves)));
}
let record = record.ok_or_else(|| bad(cmd, "offset association changed while materializing its current geometry"))?;
let ci = sk.curve_index(output).ok_or_else(|| bad(cmd, "missing offset output"))?;
let indices = sk.curves[ci].kind.point_ids();
sk.freeform_offsets.remove(record);
@@ -1110,9 +1168,74 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
point.fixed = false;
}
}
Ok(output.to_string())
Ok((output.to_string(), None))
})?;
Ok(json!({"curve":out,"sketch":info}))
let mut response = json!({"curve":out.0,"sketch":info});
if let Some(curves) = out.1 {
response["curves"] = json!(curves);
}
Ok(response)
}
/// A broken logical boundary becomes ordinary geometry. Keep downstream features
/// on that exact current boundary while other associations retain their full roles.
fn freeze_offset_profiles(doc: &mut solvecraft_doc::Document, sketch: u64, boundary: &Sketch, association: &str) -> Result<()> {
let cmd = "sketch.offset.break";
let mut groups = std::collections::BTreeMap::new();
let mut work = 0usize;
for offset in &boundary.offsets {
let Some(topology) = &offset.topology else { continue };
work = work.saturating_add(topology.roles.len());
if groups.len() >= 128 || topology.roles.len() > 512 || work > 1_000_000 {
return Err(bad(cmd, "too many offset roles to freeze safely"));
}
let reserved = topology.roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
if groups.insert(topology.id.clone(), (offset.outputs.clone(), reserved)).is_some() {
return Err(bad(cmd, "ambiguous offset association identity"));
}
}
let (active, reserved) = groups.get(association).ok_or_else(|| bad(cmd, "offset association disappeared before freezing"))?;
let mut reserved_sorted = reserved.clone();
reserved_sorted.sort();
for feature in &mut doc.features {
for (source, selection) in feature.kind.profile_sels_mut() {
if source != sketch {
continue;
}
match selection {
ProfileSel::OffsetBoundaries { associations } if associations.iter().any(|id| id == association) => {
let mut loops = Vec::with_capacity(associations.len());
for id in associations.iter() {
let (current, all_roles) = groups.get(id).ok_or_else(|| bad(cmd, format!("cannot freeze missing offset boundary `{id}`")))?;
let roles = if id == association { current } else { all_roles };
work = work.saturating_add(roles.len());
if work > 1_000_000 {
return Err(bad(cmd, "too many downstream offset references to freeze safely"));
}
loops.push(roles.clone());
}
*selection = ProfileSel::Curves { loops };
}
ProfileSel::Curves { loops } => {
for ids in loops {
work = work.saturating_add(ids.len());
if work > 1_000_000 {
return Err(bad(cmd, "too many downstream curve references to freeze safely"));
}
if ids.len() == reserved_sorted.len() {
let mut sorted = ids.clone();
sorted.sort();
if sorted == reserved_sorted {
*ids = active.clone();
}
}
}
}
_ => {}
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------------------------
@@ -1199,7 +1322,7 @@ fn copy_through(
}
}
};
let id = sk.fresh(prefix);
let id = sk.fresh(prefix)?;
sk.curves.push(Curve {
id,
kind,