feat: strengthen sketches, feature editing, and independent assemblies
This commit is contained in:
1 parent
07932d0f42
commit
0398a813c8
65 files changed
+7082
-268
No files matched your search
+137
-18
@@ -23,8 +23,26 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
let (at, normal) = match first {
|
||||
Sel::Profile { sketch, index } => {
|
||||
let ss = st.sketch(*sketch)?;
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
let p = ss.profiles.get(*index)?;
|
||||
(ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal()))
|
||||
let centre = if matches!(d.kind, Kind::Extrude { .. }) {
|
||||
let regions: Vec<_> = d
|
||||
.inputs
|
||||
.first()?
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
if let Sel::Profile { sketch: id, index } = item { (*id == *sketch).then(|| ss.profiles.get(*index)).flatten() } else { None }
|
||||
})
|
||||
.map(|p| p.region.clone())
|
||||
.collect();
|
||||
let regions = solvecraft_engine::sketch::merge_regions(®ions);
|
||||
let points: Vec<_> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||||
points.iter().fold(solvecraft_engine::geom::Vec2::default(), |sum, p| sum + *p) * (1.0 / points.len().max(1) as f64)
|
||||
} else {
|
||||
p.region.interior_point()
|
||||
};
|
||||
(plane.to_world(centre), Some(plane.normal()))
|
||||
}
|
||||
Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)),
|
||||
Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))),
|
||||
@@ -43,13 +61,15 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(match d.kind {
|
||||
Kind::Extrude { .. }
|
||||
| Kind::Fillet { .. }
|
||||
| Kind::Section { .. }
|
||||
| Kind::Hole { .. }
|
||||
| Kind::OffsetPlane { .. }
|
||||
| Kind::OffsetFaces { .. } => (at, normal),
|
||||
Some(match &d.kind {
|
||||
Kind::Extrude { start, .. } => {
|
||||
let n = normal?;
|
||||
let plane = solvecraft_engine::geom::Plane::from_normal(at, n)?;
|
||||
let from = reference_height(app, d, "From Object", &plane, at).unwrap_or(0.0);
|
||||
let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? };
|
||||
(at + n * (from + offset), Some(n))
|
||||
}
|
||||
Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
|
||||
// Shell thickness grows into the body.
|
||||
Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
||||
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
|
||||
@@ -59,6 +79,25 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_plane(app: &SolveApp, sketch: u64) -> Option<solvecraft_engine::geom::Plane> {
|
||||
let component = app.session.doc.feature(sketch)?.component;
|
||||
let state = app.session.model.state();
|
||||
let plane = state.sketch(sketch)?.plane;
|
||||
let frame = crate::dialogs::feature_frame(&app.session, component);
|
||||
solvecraft_engine::geom::Plane::new(
|
||||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.y),
|
||||
)
|
||||
}
|
||||
|
||||
fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_engine::geom::Plane, centre: Vec3) -> Option<f64> {
|
||||
let point = crate::dialogs::object_point(&d.inputs, label)?;
|
||||
let p = point.as_array()?;
|
||||
let p = Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?);
|
||||
Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
|
||||
}
|
||||
|
||||
/// The outward direction between the two faces at an edge (where a fillet's radius arrow
|
||||
/// points).
|
||||
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
@@ -155,11 +194,40 @@ fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option<Vec<Pos2>>
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
|
||||
let t = Tokens::get();
|
||||
let sign = match d.kind {
|
||||
Kind::Extrude { direction: 1, .. } => -1.0,
|
||||
let to_span = dir.and_then(|n| {
|
||||
let plane = solvecraft_engine::geom::Plane::from_normal(base, n)?;
|
||||
reference_height(app, d, "To Object", &plane, base)
|
||||
});
|
||||
let all_span = if matches!(d.kind, Kind::Extrude { all: true, .. }) {
|
||||
dir.and_then(|n| {
|
||||
let state = app.session.world_state();
|
||||
let plane = match d.inputs.first()?.items.first()? {
|
||||
Sel::Profile { sketch, .. } => profile_plane(app, *sketch)?,
|
||||
_ => solvecraft_engine::geom::Plane::from_normal(base, n)?,
|
||||
};
|
||||
let targets: Vec<String> = d
|
||||
.extra
|
||||
.get("targets")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
|
||||
.unwrap_or_default();
|
||||
solvecraft_engine::doc::through_all_distance(&state, &plane, &targets).ok()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sign = match (to_span, &d.kind) {
|
||||
(Some(span), _) => {
|
||||
if span < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
(_, Kind::Extrude { direction: 1, .. }) => -1.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
// Extrudes and moves go either way; radii and thicknesses stay positive.
|
||||
let signed =
|
||||
matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. });
|
||||
@@ -170,7 +238,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir)
|
||||
&& let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite())
|
||||
{
|
||||
drag_arrow(ui, painter, proj, base, bs, -n, l2, false, egui::Id::new("sc_manipulator2"), half_height, distance2);
|
||||
drag_arrow(ui, painter, proj, base, bs, -n, l2, true, egui::Id::new("sc_manipulator2"), half_height, distance2);
|
||||
}
|
||||
// An offset plane shows where it will be.
|
||||
if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir)
|
||||
@@ -184,16 +252,21 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
let mut dir = dir;
|
||||
// Extrude taper: a handle beside the arrow tip; dragging it sideways tilts the walls.
|
||||
let cam_right = app.cam.basis().0;
|
||||
if let (Kind::Extrude { direction: 0 | 1, distance, taper, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
if let (Kind::Extrude { direction: 0 | 1, distance, taper, taper_present, to_offset, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
let n = n * sign;
|
||||
if let Ok(l) = app.session.doc.eval(distance, ValueKind::Length)
|
||||
let span = to_span.map(|span| span.abs() + app.session.doc.eval(to_offset, ValueKind::Length).unwrap_or(0.0)).or(all_span);
|
||||
if let Ok(l) = span.map(Ok).unwrap_or_else(|| app.session.doc.eval(distance, ValueKind::Length))
|
||||
&& l.is_finite()
|
||||
&& l.abs() > 1e-9
|
||||
&& let Some(e) = (cam_right - n * cam_right.dot(n)).normalized()
|
||||
{
|
||||
let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0));
|
||||
let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let before = taper.clone();
|
||||
taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper);
|
||||
if *taper != before {
|
||||
*taper_present = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Kind::Hole { depth, opts, .. }, Some(n)) = (&mut d.kind, dir) {
|
||||
@@ -207,7 +280,10 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
dir = None;
|
||||
}
|
||||
let Some((label, kind, value)) = d.primary() else { return };
|
||||
let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite());
|
||||
let len = all_span.or_else(|| {
|
||||
if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) }
|
||||
});
|
||||
let before = value.clone();
|
||||
let mut box_at = bs + vec2(16.0, -30.0);
|
||||
if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle))
|
||||
&& let Some(at) = rotator(ui, painter, proj, base, axis, angle, value)
|
||||
@@ -216,7 +292,17 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
}
|
||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||
let n = n * sign;
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l, signed, egui::Id::new("sc_manipulator"), half_height, value) {
|
||||
let baseline = to_span.map(f64::abs).unwrap_or(0.0);
|
||||
let mut shown_value = format_len(l + baseline);
|
||||
let original_shown = shown_value.clone();
|
||||
let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value };
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) {
|
||||
if to_span.is_some()
|
||||
&& shown_value != original_shown
|
||||
&& let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length)
|
||||
{
|
||||
*value = format_len(v - baseline);
|
||||
}
|
||||
if symmetric && let Some(bt) = proj.to_screen(base - n * l) {
|
||||
arrow(painter, bs, bt, t.manipulator);
|
||||
}
|
||||
@@ -241,6 +327,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
});
|
||||
});
|
||||
});
|
||||
if *value != before
|
||||
&& let Kind::Extrude { all, .. } = &mut d.kind
|
||||
{
|
||||
*all = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes
|
||||
@@ -338,11 +429,39 @@ fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: V
|
||||
}
|
||||
|
||||
/// The revolve axis (a point on it and its unit direction).
|
||||
fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
if !matches!(d.kind, Kind::Revolve { .. }) {
|
||||
return None;
|
||||
}
|
||||
crate::dialogs::axis_of(app, d.inputs.get(1)?.items.first()?)
|
||||
if let Some(selected) = d.inputs.get(1)?.items.first() {
|
||||
if let Sel::Axis { name } = selected
|
||||
&& matches!(name.as_str(), "X" | "Y" | "Z")
|
||||
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
|
||||
{
|
||||
let component = app.session.doc.feature(*sketch)?.component;
|
||||
let frame = crate::dialogs::feature_frame(&app.session, component);
|
||||
let dir = match name.as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
_ => Vec3::Z,
|
||||
};
|
||||
return Some((solvecraft_engine::doc::apply_point(&frame, Vec3::ZERO), solvecraft_engine::doc::apply_vector(&frame, dir)));
|
||||
}
|
||||
if let Sel::Axis { name } = selected
|
||||
&& matches!(name.as_str(), "x" | "y")
|
||||
&& let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()?
|
||||
{
|
||||
let plane = profile_plane(app, *sketch)?;
|
||||
return Some((plane.origin, if name == "x" { plane.x } else { plane.y }));
|
||||
}
|
||||
return crate::dialogs::axis_of(app, selected);
|
||||
}
|
||||
let Kind::Revolve { axis: Some(axis), .. } = &d.kind else { return None };
|
||||
let vector = |key: &str| -> Option<Vec3> {
|
||||
let p = axis.get(key)?.as_array()?;
|
||||
Some(Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?))
|
||||
};
|
||||
Some((vector("origin")?, vector("dir")?.normalized()?))
|
||||
}
|
||||
|
||||
/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end
|
||||
|
||||
@@ -210,6 +210,18 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
|
||||
if let Some(preview) = &editor.preview {
|
||||
ui.collapsing(format!("Preview: {}", preview["active"].as_str().unwrap_or("row")), |ui| {
|
||||
ui.label("Preview leaves the current design unchanged.");
|
||||
if let Some(styles) = preview["styles"].as_array() {
|
||||
for style in styles {
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}: {} (shared definition)",
|
||||
style["target"].as_str().unwrap_or("Appearance"),
|
||||
if style["dormant"].as_bool() == Some(true) { "dormant" } else { "resolved" }
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(trace) = preview["trace"].as_array() {
|
||||
for entry in trace {
|
||||
ui.label(format!(
|
||||
|
||||
@@ -652,6 +652,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
|
||||
if !curves.is_empty() {
|
||||
v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves })));
|
||||
}
|
||||
if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) {
|
||||
v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]})));
|
||||
}
|
||||
v.push(act("ui.fix", "Fix/Unfix", "c_fix").with(json!({ "entities": ents })));
|
||||
v.push(act("ui.delete", "Delete", "delete").key("Del"));
|
||||
v.push(cmd(app, "sketch.dimension", "Sketch Dimension").with(json!({ "entities": ents })).on(ents.len() <= 2));
|
||||
|
||||
+245
-43
@@ -40,6 +40,10 @@ pub enum Kind {
|
||||
distance2: String,
|
||||
/// Taper angle (empty or zero: none).
|
||||
taper: String,
|
||||
/// An explicitly authored zero taper is still a model parameter.
|
||||
taper_present: bool,
|
||||
/// Optional distance beyond the selected end object.
|
||||
to_offset: String,
|
||||
/// Start offset from the profile plane (empty: start on the profile plane).
|
||||
start: String,
|
||||
/// Extent type All (through everything) instead of a distance.
|
||||
@@ -48,6 +52,8 @@ pub enum Kind {
|
||||
Revolve {
|
||||
angle: String,
|
||||
operation: usize,
|
||||
/// An arbitrary stored line until the user deliberately picks a replacement.
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Fillet {
|
||||
radius: String,
|
||||
@@ -262,6 +268,8 @@ pub struct Dialog {
|
||||
pub error: Option<String>,
|
||||
/// Editing an existing feature (its id, and the timeline marker to restore afterwards).
|
||||
pub editing: Option<(u64, Option<usize>)>,
|
||||
/// A user pick or clear makes the profile selector explicit, even if all are reselected.
|
||||
pub profile_selection_changed: bool,
|
||||
/// Give the on-canvas value box the keyboard (and select its text) on the next frame.
|
||||
pub focus: bool,
|
||||
/// Folded to its header (the "−" glyph).
|
||||
@@ -288,7 +296,17 @@ fn fits(a: Accept, s: &Sel) -> bool {
|
||||
|
||||
impl Dialog {
|
||||
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
|
||||
Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, collapsed: false, extra: serde_json::Map::new() }
|
||||
Dialog {
|
||||
kind,
|
||||
inputs,
|
||||
active: 0,
|
||||
error: None,
|
||||
editing: None,
|
||||
profile_selection_changed: false,
|
||||
focus: true,
|
||||
collapsed: false,
|
||||
extra: serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
|
||||
@@ -304,13 +322,15 @@ impl Dialog {
|
||||
auto_op: true,
|
||||
distance2: "10 mm".into(),
|
||||
taper: "0 deg".into(),
|
||||
taper_present: false,
|
||||
to_offset: String::new(),
|
||||
start: String::new(),
|
||||
all: false,
|
||||
},
|
||||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||||
),
|
||||
"solid.revolve" => Dialog::new(
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0 },
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Axis", AXES, false)],
|
||||
),
|
||||
"solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]),
|
||||
@@ -332,7 +352,10 @@ impl Dialog {
|
||||
Kind::Rib { web: id == "solid.web", thickness: "2 mm".into(), depth: String::new(), flip: false },
|
||||
vec![SelInput::new("Curves", CURVES, true)],
|
||||
),
|
||||
"solid.emboss" => Dialog::new(Kind::Emboss { depth: "1 mm".into(), deboss: false }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||||
"solid.emboss" => Dialog::new(
|
||||
Kind::Emboss { depth: "1 mm".into(), deboss: false },
|
||||
vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Target Face (optional)", FACES, false)],
|
||||
),
|
||||
"solid.replace_face" => Dialog::new(
|
||||
Kind::ReplaceFace,
|
||||
vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)],
|
||||
@@ -567,6 +590,7 @@ impl Dialog {
|
||||
/// The main value (shown on the canvas too): label, kind and the expression.
|
||||
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
||||
Some(match &mut self.kind {
|
||||
Kind::Extrude { to_offset, .. } if to_object_on(&self.inputs) => ("To Offset", ValueKind::Length, to_offset),
|
||||
Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance),
|
||||
Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
||||
Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius),
|
||||
@@ -633,6 +657,9 @@ impl Dialog {
|
||||
|
||||
/// A pick in the viewport (already accepted by the active input).
|
||||
pub fn pick(&mut self, s: &Session, sel: Sel) {
|
||||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||||
self.profile_selection_changed = true;
|
||||
}
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
|
||||
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
||||
@@ -673,6 +700,9 @@ impl Dialog {
|
||||
|
||||
/// Box selection result: replace (or add to) the active input.
|
||||
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
|
||||
if self.active == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||||
self.profile_selection_changed = true;
|
||||
}
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||||
if !add {
|
||||
@@ -1055,6 +1085,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
&& let Some(inp) = d.inputs.get_mut(i)
|
||||
{
|
||||
inp.items.clear();
|
||||
if i == 0 {
|
||||
d.profile_selection_changed = true;
|
||||
}
|
||||
d.active = i;
|
||||
}
|
||||
}
|
||||
@@ -1284,17 +1317,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
input_rows(&mut d, ui);
|
||||
match &mut d.kind {
|
||||
Kind::Sketch => {}
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => {
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
|
||||
row_label(ui, "Start");
|
||||
let mut st = usize::from(!start.is_empty());
|
||||
combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st);
|
||||
if st == 1 && start.is_empty() {
|
||||
*start = "5 mm".into();
|
||||
} else if st == 0 {
|
||||
start.clear();
|
||||
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
|
||||
let mut st = old;
|
||||
combo(ui, "ex_start", &["Profile Plane", "Offset", "From Object"], &mut st);
|
||||
if st != old {
|
||||
set_object(&mut d.inputs, &mut d.active, FROM_OBJECT, st == 2);
|
||||
if st == 1 && start.is_empty() {
|
||||
*start = "5 mm".into();
|
||||
}
|
||||
if st == 0 {
|
||||
start.clear();
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
if !start.is_empty() {
|
||||
if st != 0 {
|
||||
row_label(ui, "Offset");
|
||||
enter |= field(ui, start);
|
||||
ui.end_row();
|
||||
@@ -1304,8 +1342,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let order = [0usize, TWO_SIDES, 2];
|
||||
let mut pos = order.iter().position(|o| o == direction).unwrap_or(0);
|
||||
combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos);
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
if pos != order.iter().position(|o| o == direction).unwrap_or(0) {
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
}
|
||||
ui.end_row();
|
||||
if *direction < 2 {
|
||||
row_label(ui, "Reverse");
|
||||
let mut reverse = *direction == 1;
|
||||
if ui.checkbox(&mut reverse, "Reverse Direction").changed() {
|
||||
*direction = usize::from(reverse);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Extent Type");
|
||||
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 };
|
||||
let mut ext = cur;
|
||||
@@ -1323,9 +1371,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
if ext == 1 {
|
||||
row_label(ui, "To Offset");
|
||||
enter |= field(ui, to_offset);
|
||||
ui.end_row();
|
||||
}
|
||||
if *direction < 2 {
|
||||
row_label(ui, "Taper Angle");
|
||||
let before = taper.clone();
|
||||
enter |= field(ui, taper);
|
||||
if *taper != before {
|
||||
*taper_present = !taper.trim().is_empty();
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Operation");
|
||||
@@ -1337,7 +1394,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
Kind::Revolve { angle, operation, .. } => {
|
||||
row_label(ui, "Type");
|
||||
let cur = usize::from(to_object_on(&d.inputs));
|
||||
let mut ty = cur;
|
||||
@@ -1541,6 +1598,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i);
|
||||
*deboss = i == 1;
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.add(
|
||||
egui::Label::new("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Align { flip } => {
|
||||
// Where on the picked face or edge to snap: as picked (a face turns to meet
|
||||
@@ -1873,7 +1935,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.dialog = Some(d);
|
||||
} else if let Some((_, marker)) = d.editing {
|
||||
// Editing done: put the timeline marker back where it was.
|
||||
let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = roll_marker(app, marker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1886,12 +1948,30 @@ pub fn cancel(app: &mut SolveApp) {
|
||||
if let Some(d) = app.dialog.take()
|
||||
&& let Some((_, marker)) = d.editing
|
||||
{
|
||||
let _ = app.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = roll_marker(app, marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary rollback used by an editor is view state. Preserve both history stacks,
|
||||
/// including redo and a history already at the undo limit.
|
||||
pub(crate) fn roll_marker(app: &mut SolveApp, marker: Option<usize>) -> Result<(), String> {
|
||||
let undo = std::mem::take(&mut app.session.undo);
|
||||
let redo = std::mem::take(&mut app.session.redo);
|
||||
let selection = std::mem::take(&mut app.session.selection);
|
||||
let result = app.run("timeline.roll_to", marker.map(|m| json!({"position":m})).unwrap_or_else(|| json!({})));
|
||||
app.session.undo = undo;
|
||||
app.session.redo = redo;
|
||||
app.session.selection = selection;
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/// The pick input an extent of To Object adds (a face, vertex or point to go to).
|
||||
const TO_OBJECT: &str = "To Object";
|
||||
const FROM_OBJECT: &str = "From Object";
|
||||
|
||||
fn object_on(inputs: &[SelInput], label: &str) -> bool {
|
||||
inputs.iter().any(|i| i.label == label)
|
||||
}
|
||||
|
||||
fn to_object_on(inputs: &[SelInput]) -> bool {
|
||||
inputs.iter().any(|i| i.label == TO_OBJECT)
|
||||
@@ -1899,18 +1979,26 @@ fn to_object_on(inputs: &[SelInput]) -> bool {
|
||||
|
||||
/// Add or remove the To Object input; the active input moves to it when it is added.
|
||||
fn set_to_object(inputs: &mut Vec<SelInput>, active: &mut usize, on: bool) {
|
||||
if on && !to_object_on(inputs) {
|
||||
inputs.push(SelInput::new(TO_OBJECT, FACES | selection::VERTICES, false));
|
||||
set_object(inputs, active, TO_OBJECT, on);
|
||||
}
|
||||
|
||||
fn set_object(inputs: &mut Vec<SelInput>, active: &mut usize, label: &'static str, on: bool) {
|
||||
if on && !object_on(inputs, label) {
|
||||
inputs.push(SelInput::new(label, FACES | selection::VERTICES, false));
|
||||
*active = inputs.len() - 1;
|
||||
} else if !on && to_object_on(inputs) {
|
||||
inputs.retain(|i| i.label != TO_OBJECT);
|
||||
} else if !on && object_on(inputs, label) {
|
||||
inputs.retain(|i| i.label != label);
|
||||
*active = (*active).min(inputs.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The picked To Object place.
|
||||
fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == TO_OBJECT)?.items.first().and_then(|x| match x {
|
||||
object_point(inputs, TO_OBJECT)
|
||||
}
|
||||
|
||||
pub(crate) fn object_point(inputs: &[SelInput], label: &str) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == label)?.items.first().and_then(|x| match x {
|
||||
Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)),
|
||||
_ => None,
|
||||
})
|
||||
@@ -1918,9 +2006,13 @@ fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
|
||||
/// A To Object input filled from a stored place (editing).
|
||||
fn with_to_object(d: &mut Dialog, s: &Session, to: Option<Vec3>) {
|
||||
with_object(d, s, TO_OBJECT, to);
|
||||
}
|
||||
|
||||
fn with_object(d: &mut Dialog, s: &Session, label: &'static str, to: Option<Vec3>) {
|
||||
if let Some(p) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||||
inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })];
|
||||
}
|
||||
}
|
||||
@@ -2086,17 +2178,58 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] {
|
||||
}
|
||||
|
||||
fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
for (cmd, params) in apply_commands(app, d)? {
|
||||
app.run(&cmd, params)?;
|
||||
let commands = apply_commands(app, d)?;
|
||||
if let Some((_, marker)) = d.editing {
|
||||
// Record the actual pre-edit timeline state in the command's undo snapshot.
|
||||
roll_marker(app, marker)?;
|
||||
}
|
||||
for (cmd, params) in commands {
|
||||
if let Err(error) = app.run(&cmd, params) {
|
||||
if let Some((id, _)) = d.editing {
|
||||
let position = app.session.doc.feature_index(id);
|
||||
let _ = roll_marker(app, position);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Placement used to edit a definition in the selected authoring occurrence.
|
||||
pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::doc::Mat {
|
||||
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
|
||||
}
|
||||
|
||||
/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same.
|
||||
pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||||
let cmds = dialog_commands(app, d)?;
|
||||
if let Some((id, _)) = d.editing {
|
||||
let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
if let Some(feature) = app.session.doc.feature(id) {
|
||||
let original_all = match &feature.kind {
|
||||
FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(sketch) = original_all
|
||||
&& !d.profile_selection_changed
|
||||
&& let Some(ss) = app.session.world_state().sketch(sketch)
|
||||
{
|
||||
let mut selected: Vec<usize> = sels(d, 0)
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Sel::Profile { sketch: id, index } if *id == sketch => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
selected.sort_unstable();
|
||||
selected.dedup();
|
||||
if selected == (0..ss.profiles.len()).collect::<Vec<_>>() && sels(d, 0).len() == selected.len() {
|
||||
params["profiles"] = json!("all");
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]);
|
||||
}
|
||||
Ok(cmds)
|
||||
@@ -2111,6 +2244,31 @@ pub fn preview_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value
|
||||
Ok(cmds)
|
||||
}
|
||||
|
||||
/// A wrapped Emboss edit keeps its profile guide in the saved author occurrence.
|
||||
/// An invalid saved context must not draw the guide at a different occurrence.
|
||||
pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result<Option<solvecraft_engine::geom::Plane>, String> {
|
||||
let Some(dialog) = app.dialog.as_ref().filter(|d| matches!(d.kind, Kind::Emboss { .. })) else { return Ok(None) };
|
||||
let Some((feature, _)) = dialog.editing else { return Ok(None) };
|
||||
let Some(feature) = app.session.doc.feature(feature) else { return Err("the edited feature is missing".into()) };
|
||||
let FeatureKind::Emboss { sketch: source, face: Some(face), .. } = &feature.kind else { return Ok(None) };
|
||||
if *source != sketch {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = app.session.model.state();
|
||||
let sketch = model.sketch(sketch).ok_or("the Emboss sketch is missing")?;
|
||||
let body = model.body(&face.body).ok_or("the Emboss target body is missing")?;
|
||||
let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||||
let plane = sketch.plane;
|
||||
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body.feature).map_err(|error| error.to_string())?;
|
||||
solvecraft_engine::geom::Plane::new(
|
||||
solvecraft_engine::doc::apply_point(&frame, plane.origin),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.x),
|
||||
solvecraft_engine::doc::apply_vector(&frame, plane.y),
|
||||
)
|
||||
.map(Some)
|
||||
.ok_or_else(|| "the Emboss author frame is degenerate".into())
|
||||
}
|
||||
|
||||
/// The commands (id, parameters) a dialog's OK runs.
|
||||
fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||||
let s = &app.session;
|
||||
@@ -2126,13 +2284,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
||||
};
|
||||
let (cmd, params): (&str, Value) = match &d.kind {
|
||||
Kind::Extrude { distance, direction, operation, distance2, taper, start, all, .. } => {
|
||||
Kind::Extrude { distance, direction, operation, distance2, taper, taper_present, to_offset, start, all, .. } => {
|
||||
need(0, "profiles or a planar face")?;
|
||||
let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty());
|
||||
if *direction == TWO_SIDES {
|
||||
common["distance2"] = json!(distance2);
|
||||
} else if *direction < 2 && tapered {
|
||||
} else if *direction < 2 && (*taper_present || tapered) {
|
||||
common["taper"] = json!(taper);
|
||||
}
|
||||
if !start.trim().is_empty() {
|
||||
@@ -2143,6 +2301,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
if to_object_on(&d.inputs) {
|
||||
common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?;
|
||||
if !to_offset.trim().is_empty() {
|
||||
common["to_offset"] = json!(to_offset);
|
||||
}
|
||||
}
|
||||
if object_on(&d.inputs, FROM_OBJECT) {
|
||||
common["from"] = object_point(&d.inputs, FROM_OBJECT).ok_or("select the object to extrude from")?;
|
||||
}
|
||||
let with = |extra: Value| -> Value {
|
||||
let mut p = common.clone();
|
||||
@@ -2165,21 +2329,18 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
Kind::Revolve { angle, operation, axis: stored_axis } => {
|
||||
need(0, "profiles")?;
|
||||
need(1, "an axis")?;
|
||||
let axis = match sels(d, 1).first() {
|
||||
Some(Sel::Axis { name }) => json!(name),
|
||||
Some(Sel::SketchCurve { id }) => json!(id),
|
||||
_ => return Err("select an axis".into()),
|
||||
_ => stored_axis.clone().ok_or("select an axis")?,
|
||||
};
|
||||
let (sketch, idx) = profiles();
|
||||
let mut p =
|
||||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
if to_object_on(&d.inputs) {
|
||||
p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?;
|
||||
// A To Object turn has no second side.
|
||||
return Ok(vec![("solid.revolve".into(), p)]);
|
||||
}
|
||||
("solid.revolve", p)
|
||||
}
|
||||
@@ -2361,7 +2522,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
Kind::Emboss { depth, deboss } => {
|
||||
need(0, "profiles")?;
|
||||
let (sketch, idx) = profiles();
|
||||
("solid.emboss", json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }}))
|
||||
let mut p = json!({"sketch": sketch, "profiles": idx, "depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }});
|
||||
if let Some(Sel::Face { body, point, .. }) = sels(d, 1).first() {
|
||||
p["face"] = pt(*point);
|
||||
p["targets"] = json!([body]);
|
||||
}
|
||||
("solid.emboss", p)
|
||||
}
|
||||
Kind::ReplaceFace => {
|
||||
need(0, "faces to move")?;
|
||||
@@ -2783,6 +2949,18 @@ pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
|
||||
}
|
||||
|
||||
fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::EmbossFace, persistent: &str) -> Option<Sel> {
|
||||
let source = s.model.state();
|
||||
let body = source.body(&face.body)?;
|
||||
let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?;
|
||||
let owner = s.doc.body_component(&face.body, body.feature);
|
||||
let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path);
|
||||
let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?;
|
||||
let point = solvecraft_engine::doc::apply_point(&author, face.at);
|
||||
s.world_state().body(&world_name)?;
|
||||
Some(Sel::Face { body: world_name, index, point })
|
||||
}
|
||||
|
||||
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||
match pl {
|
||||
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
|
||||
@@ -2840,16 +3018,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
auto_op: false,
|
||||
distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()),
|
||||
taper: extent.taper.clone().unwrap_or_else(|| "0 deg".into()),
|
||||
taper_present: extent.taper.is_some(),
|
||||
to_offset: extent.to_offset.clone().unwrap_or_default(),
|
||||
start: extent.start_offset.clone().unwrap_or_default(),
|
||||
all: extent.through_all,
|
||||
};
|
||||
with_to_object(&mut d, s, extent.to);
|
||||
// The dialog has no "flip": a flipped extrude is a negative distance.
|
||||
if let Kind::Extrude { direction, distance, .. } = &mut d.kind
|
||||
&& *direction == 1
|
||||
{
|
||||
*direction = 0;
|
||||
*distance = format!("-({distance})");
|
||||
// Extent references belong to the extrude's authoring frame, including free
|
||||
// points. They are not body-local picks (a reference may be on another body).
|
||||
let frame = feature_frame(s, f.component);
|
||||
for (label, point) in [(TO_OBJECT, extent.to), (FROM_OBJECT, extent.from)] {
|
||||
if let Some(point) = point {
|
||||
set_object(&mut d.inputs, &mut d.active, label, true);
|
||||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == label) {
|
||||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, point) }];
|
||||
}
|
||||
}
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
@@ -2862,20 +3045,34 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, to } => {
|
||||
let mut d = start("solid.revolve")?;
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) };
|
||||
let frame = feature_frame(s, f.component);
|
||||
let stored_axis = match axis {
|
||||
AxisRef::Line { origin, dir } => Some(json!({
|
||||
"origin": pt(solvecraft_engine::doc::apply_point(&frame, *origin)),
|
||||
"dir": pt(solvecraft_engine::doc::apply_vector(&frame, *dir))
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis };
|
||||
if let Some(a2) = angle2 {
|
||||
d.extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
if *symmetric {
|
||||
d.extra.insert("direction".into(), json!("symmetric"));
|
||||
}
|
||||
with_to_object(&mut d, s, *to);
|
||||
if let Some(point) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(input) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
input.items = vec![Sel::Vertex { body: String::new(), point: solvecraft_engine::doc::apply_point(&frame, *point) }];
|
||||
}
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.get_mut(0) {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
}
|
||||
let ax = match axis {
|
||||
AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }),
|
||||
AxisRef::SketchAxis { axis } => Some(Sel::Axis { name: axis.to_ascii_lowercase() }),
|
||||
AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }),
|
||||
_ => None,
|
||||
};
|
||||
@@ -3289,7 +3486,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||||
let mut d = start("solid.emboss")?;
|
||||
d.kind = Kind::Emboss { depth: depth.clone(), deboss: *deboss };
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
@@ -3299,6 +3496,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
if !targets.is_empty() {
|
||||
d.extra.insert("targets".into(), json!(targets));
|
||||
}
|
||||
if let Some(face) = face
|
||||
&& let Some(input) = d.inputs.get_mut(1)
|
||||
{
|
||||
input.items = vec![emboss_face_sel(s, f.component, face, f.face_names.first()?)?];
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Rib { curves, thickness, depth, flip, web, .. } => {
|
||||
|
||||
@@ -118,17 +118,20 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. }))
|
||||
&& !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. }));
|
||||
let state = app.session.world_state();
|
||||
let body_look = |body: &solvecraft_engine::doc::ModelBody| {
|
||||
app.session.doc.model_body_look(body).or_else(|| {
|
||||
(body.body.paint().is_some() || body.imported.is_some() || body.body.color().is_some()).then(|| {
|
||||
let color =
|
||||
app.session.doc.body_color(body).map(|rgb| rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([196, 199, 204]);
|
||||
solvecraft_engine::doc::appearance::Look::custom(color, app.session.doc.model_body_opacity(body))
|
||||
})
|
||||
})
|
||||
};
|
||||
let existing = app.session.selection.iter().find_map(|sel| match sel {
|
||||
Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.model_body_look(b)),
|
||||
Sel::Face { body, index, .. } => state.body(body).and_then(|b| {
|
||||
app.session
|
||||
.doc
|
||||
.face_colors(b)
|
||||
.into_iter()
|
||||
.find(|(i, _)| i == index)
|
||||
.map(|(_, look)| look)
|
||||
.or_else(|| app.session.doc.model_body_look(b))
|
||||
}),
|
||||
Sel::Body { name } => state.body(name).and_then(body_look),
|
||||
Sel::Face { body, index, .. } => state
|
||||
.body(body)
|
||||
.and_then(|b| app.session.doc.face_colors(b).into_iter().find(|(i, _)| i == index).map(|(_, look)| look).or_else(|| body_look(b))),
|
||||
_ => None,
|
||||
});
|
||||
(
|
||||
@@ -341,7 +344,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
ui.end_row();
|
||||
row_label(ui, "Opacity");
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() {
|
||||
// Decimal formatting must not round a saved look merely by drawing its editor.
|
||||
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() {
|
||||
k.remove_override = false;
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -590,6 +594,23 @@ mod tests {
|
||||
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_frames_and_unchanged_apply_keep_precise_opacity() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#abcdef","opacity":0.123456})).unwrap();
|
||||
for cancel in [true, false] {
|
||||
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
h.app.start("appearance.assign");
|
||||
h.frames(3);
|
||||
let Kind::Appearance(appearance) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert_eq!(appearance.opacity, 0.123456);
|
||||
h.step(&json!({"key":if cancel {"Escape"} else {"Enter"}})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].opacity, 0.123456);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() {
|
||||
let mut a = app();
|
||||
|
||||
@@ -819,13 +819,11 @@ pub fn world_preview(s: &Session, cmds: &[(String, Value)], colors: Colors) -> O
|
||||
if cmds.is_empty() || !(moves || solvecraft_engine::frames::placed(s)) {
|
||||
return None;
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
for (id, p) in cmds {
|
||||
if let Err(e) = scratch.execute(id, p) {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
}
|
||||
let (before, after) = (s.world_state(), scratch.world_state());
|
||||
let preview = match s.preview_visible_world(cmds) {
|
||||
Ok(preview) => preview,
|
||||
Err(error) => return Some(Err(error.to_string())),
|
||||
};
|
||||
let (before, after) = (preview.before, preview.after);
|
||||
// A moved component looks like itself (no new surface), with its old place as the ghost.
|
||||
let colors = Colors { added: colors.body, ..colors };
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::preview::build(&before, &after, colors)));
|
||||
|
||||
@@ -631,10 +631,22 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
"suppress" => format!("Suppress: {n}"),
|
||||
_ => n.to_string(),
|
||||
};
|
||||
let label = v["targets"]
|
||||
.as_array()
|
||||
.and_then(|targets| targets.iter().find(|target| target["key"].as_str() == Some(c)))
|
||||
.and_then(|target| target["label"].as_str())
|
||||
.filter(|_| matches!(kind, "component_appearance" | "face_appearance"))
|
||||
.map(str::to_string)
|
||||
.unwrap_or(label);
|
||||
ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c);
|
||||
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
|
||||
let key = if kind == "suppress" { "feature" } else { kind };
|
||||
run(app, error, "config.column", json!({ key: n, "delete": true }));
|
||||
run(
|
||||
app,
|
||||
error,
|
||||
"config.column",
|
||||
json!({ key: if kind == "face_appearance" {c.as_str()} else {n}, "delete": true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -661,7 +673,8 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
for (c, val) in tb.columns.iter().zip(vals) {
|
||||
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let (kind, _) = column_parts(c);
|
||||
let shown = if kind == "appearance" {
|
||||
let appearance = matches!(kind, "appearance" | "component_appearance" | "face_appearance");
|
||||
let shown = if appearance {
|
||||
solvecraft_engine::doc::config::appearance_cell(val)
|
||||
.ok()
|
||||
.flatten()
|
||||
@@ -671,11 +684,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
} else {
|
||||
val.clone()
|
||||
};
|
||||
let style = matches!(kind, "material" | "appearance");
|
||||
let style = kind == "material" || appearance;
|
||||
if style {
|
||||
let mut choice = None;
|
||||
ui.menu_button("Library", |ui| {
|
||||
if ui.button(if kind == "appearance" { "Follow material" } else { "Default material" }).clicked() {
|
||||
if ui
|
||||
.button(if kind == "appearance" {
|
||||
"Follow material"
|
||||
} else if appearance {
|
||||
"Follow inherited appearance"
|
||||
} else {
|
||||
"Default material"
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
choice = Some("Default".to_string());
|
||||
ui.close();
|
||||
}
|
||||
@@ -757,6 +779,17 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
}
|
||||
}
|
||||
}
|
||||
for component in &app.session.doc.components {
|
||||
if component.id != 0 && !tb.columns.contains(&format!("component_appearance:{}", component.id)) {
|
||||
options.push((format!("{} (component appearance)", component.name), json!({"component_appearance":component.id})));
|
||||
}
|
||||
}
|
||||
for selected in &app.session.selection {
|
||||
if let solvecraft_engine::Sel::Face { body, point, .. } = selected {
|
||||
options
|
||||
.push((format!("Selected face on {body} (shared appearance)"), json!({"face_appearance":{"body":body,"point":point}})));
|
||||
}
|
||||
}
|
||||
let mut pick: Option<Value> = None;
|
||||
ui.menu_button("Add Column", |ui| {
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
@@ -766,6 +799,43 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.menu_button("Face Appearance", |ui| {
|
||||
ui.label("Semantic faces share their appearance across instances.");
|
||||
let state = app.session.model.state();
|
||||
for body in &state.bodies {
|
||||
if body.body.is_mesh() {
|
||||
continue;
|
||||
}
|
||||
ui.menu_button(format!("Faces on {}", body.name), |ui| {
|
||||
let names = solvecraft_engine::doc::naming::face_names(body);
|
||||
if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for face in faces {
|
||||
let Some(name) = names.get(face.index) else { continue };
|
||||
let name = solvecraft_engine::doc::naming::strip_piece(name);
|
||||
if !seen.insert(name.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let Some((id, role)) = name.strip_prefix('F').and_then(|name| name.split_once(':')) else { continue };
|
||||
let Ok(origin_feature) = id.parse() else { continue };
|
||||
let target = solvecraft_engine::doc::config::ConfigFaceTarget {
|
||||
body: body.name.clone(),
|
||||
origin_feature,
|
||||
name: name.into(),
|
||||
point: face.centroid,
|
||||
};
|
||||
if app.session.doc.certify_config_face(&target).is_err() {
|
||||
continue;
|
||||
}
|
||||
if ui.button(role).clicked() {
|
||||
pick = Some(json!({"face_appearance":{"body":body.name,"point":face.centroid}}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(p) = pick {
|
||||
@@ -779,6 +849,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
json!({ "features": [n] })
|
||||
} else if let Some(n) = p.get("material") {
|
||||
json!({ "materials": [n] })
|
||||
} else if let Some(n) = p.get("component_appearance") {
|
||||
json!({ "component_appearances": [n] })
|
||||
} else if let Some(n) = p.get("face_appearance") {
|
||||
json!({ "face_appearances": [n] })
|
||||
} else {
|
||||
json!({ "appearances": [p.get("appearance")] })
|
||||
},
|
||||
@@ -791,6 +865,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
});
|
||||
ui.end_row();
|
||||
configuration_rules_ui::show(ui, app, error, rule_editor);
|
||||
if let Some(styles) = v["style_status"].as_array() {
|
||||
for style in styles.iter().filter(|style| style["dormant"].as_bool() == Some(true)) {
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}: appearance dormant while its feature is suppressed",
|
||||
style["target"].as_str().unwrap_or("Face")
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(message) = v["style_error"].as_str() {
|
||||
ui.add(egui::Label::new(RichText::new(message).color(t.error)).wrap());
|
||||
}
|
||||
if let Some(e) = error {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(e.as_str()).color(t.error));
|
||||
@@ -1004,6 +1092,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_component_and_face_pickers_start_fresh_and_clear_inherited() {
|
||||
for (component, width) in [(true, 1400), (false, 900)] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.resize", json!({"width":width,"height":720}));
|
||||
if component {
|
||||
h.call("engine.execute", json!({"command":"component.create","params":{"name":"Part"}}));
|
||||
}
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":10,"width":10,"height":10,"body_name":"Block"}}},
|
||||
{"start":"config.table"}, {"widget":"Add Column"}
|
||||
]))
|
||||
.unwrap();
|
||||
if component {
|
||||
h.step(&json!({"widget":"Part (component appearance)"})).unwrap();
|
||||
assert!(h.widget("Component: Part").is_some());
|
||||
} else {
|
||||
h.run(&json!([{"widget":"Face Appearance"},{"widget":"Faces on Block"},{"widget":"+z"}])).unwrap();
|
||||
assert!(h.widget("Face: Block / +z").is_some());
|
||||
}
|
||||
assert_eq!(h.app.session.doc.configs.rows.len(), 1);
|
||||
h.run(&json!([{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap();
|
||||
if component {
|
||||
assert_eq!(h.app.session.doc.appearances.components.values().next().unwrap().name, "ABS - Red");
|
||||
} else {
|
||||
assert_eq!(h.app.session.doc.appearances.faces[0].look.name, "ABS - Red");
|
||||
assert!(h.app.session.doc.appearances.faces[0].strict_name);
|
||||
}
|
||||
h.run(&json!([{"widget":"Library"},{"widget":"Follow inherited appearance"},{"widget":"Apply"}])).unwrap();
|
||||
assert!(h.app.session.doc.appearances.components.is_empty());
|
||||
assert!(h.app.session.doc.appearances.faces.is_empty());
|
||||
crate::scenario::check(&mut h, &json!({"windows_fit":{"max_width":560}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_style_picker_applies_then_inherits_material() {
|
||||
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Exercise the actual feature editor, including frames rendered before OK.
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{Extent, FeatureKind};
|
||||
|
||||
fn make(params: Value) -> Harness {
|
||||
make_in(Harness::new(), params)
|
||||
}
|
||||
fn make_in(mut h: Harness, params: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut p = json!({"sketch":"Base","distance":"10 mm","operation":"new","name":"Depth","body_name":"Main"});
|
||||
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
|
||||
h.app.run("solid.extrude", p).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
fn extent(h: &Harness) -> Extent {
|
||||
let FeatureKind::Extrude { extent, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
extent.clone()
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Depth"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_all_profiles_includes_profiles_added_after_edit() {
|
||||
let mut h = make(json!({}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Extrude { profiles, .. } = &h.app.session.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
assert!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All));
|
||||
h.app.run("sketch.edit", json!({"sketch":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum();
|
||||
assert!((volume - 1250.0).abs() < 1e-7, "{volume}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_actual_editor_preserves_all_extents_and_parameter_identities() {
|
||||
for p in [
|
||||
json!({"direction":"negative","distance":"10"}),
|
||||
json!({"direction":"negative","distance":"-10 mm"}),
|
||||
json!({"direction":"symmetric","distance":"5 mm"}),
|
||||
json!({"distance":"10 mm","distance2":"3 mm"}),
|
||||
json!({"from":[0,0,5],"start_offset":"0 mm"}),
|
||||
json!({"to":[0,0,10],"to_offset":"3 mm"}),
|
||||
json!({"to":[0,0,-10],"to_offset":"-2 mm"}),
|
||||
json!({"taper":"0 deg"}),
|
||||
] {
|
||||
let mut h = make(p.clone());
|
||||
let original = extent(&h);
|
||||
let names = h.app.session.doc.find_feature("Depth").unwrap().param_names.clone();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{p}: {:?}", h.app.session.log);
|
||||
assert_eq!(extent(&h), original, "{p}");
|
||||
assert_eq!(h.app.session.doc.find_feature("Depth").unwrap().param_names, names, "{p}");
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_edit_keeps_downstream_distance_reference_positive() {
|
||||
let mut h = make(json!({"direction":"negative","distance":"10"}));
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":"d1","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_after_canvas_edit_preserves_document_and_undo() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let original = extent(&h);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
assert_eq!(crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0.z, 5.0);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"4 mm"})).unwrap();
|
||||
h.frames(8);
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_offset_canvas_edit_changes_offset_and_one_undo_restores_extent() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let original = extent(&h);
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"4 mm"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("4 mm"));
|
||||
assert_eq!(extent(&h).distance, original.distance);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 19.0);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_owner_references_survive_actual_edit_with_another_component_active() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20]})).unwrap();
|
||||
let other = h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":other["occurrence"],"translate":[200,0,40]})).unwrap();
|
||||
let original = extent(&h);
|
||||
let active = h.app.session.active_component;
|
||||
open(&mut h);
|
||||
let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0;
|
||||
assert_eq!(base.z, 25.0);
|
||||
assert!(base.x >= 100.0 && base.x <= 110.0);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.active_component, active);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 37.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_to_object_handle_edits_offset_not_dormant_distance() {
|
||||
let mut h = make(json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
open(&mut h);
|
||||
let original = extent(&h);
|
||||
h.step(&json!({"drag":[{"handle":"arrow"},{"world":[5,5,21]}],"ctrl":true})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
assert_eq!(extent(&h).distance, original.distance);
|
||||
let state = h.app.session.world_state();
|
||||
let z = state.body("Main").unwrap().mesh().bounds().max.z;
|
||||
assert!((z - 21.0).abs() < 0.1, "dragged end {z}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn referenced_offset_recomputes_after_edit_and_save_reopen() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("parameters.add", json!({"name":"clearance","expression":"2 mm"})).unwrap();
|
||||
let mut h = make_in(h, json!({"to":[0,0,15],"to_offset":"clearance"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance"));
|
||||
h.app.run("parameters.change", json!({"name":"clearance","expression":"6 mm"})).unwrap();
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0);
|
||||
let path = h.dir.join("extent.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(extent(&h).to_offset.as_deref(), Some("clearance"));
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 21.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_object_start_can_be_explicitly_changed_to_profile_plane() {
|
||||
let mut h = make(json!({"from":[0,0,5]}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"click":{"handle":"dialog:Start"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Start:Profile Plane"}})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(extent(&h).from.is_none());
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_reverse_keeps_original_expression_and_parameter_sign() {
|
||||
let mut h = make(json!({"distance":"10"}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"Reverse Direction"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h).distance, "10");
|
||||
assert_eq!(extent(&h).direction, solvecraft_engine::doc::Direction::Negative);
|
||||
assert_eq!(h.app.session.doc.eval("d1", solvecraft_engine::doc::expr::Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z, -10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_edit_and_cancel_keep_full_history_and_existing_redo() {
|
||||
let mut h = make(json!({}));
|
||||
for i in 0..205 {
|
||||
h.app.run("timeline.rename", json!({"feature":1,"name":format!("Base{i}")})).unwrap();
|
||||
}
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":2,"corner":[30,0,0],"body_name":"RedoBody"})).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
assert_eq!(undo, 199);
|
||||
assert_eq!(redo, 1);
|
||||
h.app.session.selection = vec![solvecraft_engine::Sel::Body { name: "Main".into() }];
|
||||
let selection = h.app.session.selection.clone();
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
open(&mut h);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"missing_extent"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_some(), "failed edit must remain editable");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert!(h.app.session.world_state().body("RedoBody").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_from_and_signed_to_have_correct_start_volume_and_preview_parity() {
|
||||
for (params, lo, hi) in [
|
||||
(json!({"from":[0,0,5],"distance":"10 mm","taper":"atan(0.1)"}), 5.0, 15.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,15],"direction":"negative","taper":"atan(0.1)"}), 5.0, 15.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,-5],"direction":"negative","taper":"atan(0.1)"}), -5.0, 5.0),
|
||||
(json!({"from":[0,0,5],"to":[0,0,-5],"direction":"positive","taper":"atan(0.1)"}), -5.0, 5.0),
|
||||
] {
|
||||
let mut h = make(params.clone());
|
||||
let state = h.app.session.world_state();
|
||||
let body = state.body("Main").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
assert!((bounds.min.z - lo).abs() < 1e-8 && (bounds.max.z - hi).abs() < 1e-8, "{params}: {bounds:?}");
|
||||
let expected = 10.0 * (100.0 + 120.0 + 144.0) / 3.0;
|
||||
let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 1e-5, "{params}: volume{volume} expected{expected}");
|
||||
let original = extent(&h);
|
||||
open(&mut h);
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_profiles_from_slanted_face_use_same_merged_centre_as_geometry() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":30,"width":30,"height":2,"corner":[-10,-10,10],"body_name":"Reference"})).unwrap();
|
||||
h.app.run("solid.move", json!({"bodies":["Reference"],"axis":[0,1,0],"angle":"atan(0.1)","origin":[0,0,10]})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Adjacent"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let angle = 0.1f64.atan();
|
||||
let point = [2.0 * angle.sin(), 0.0, 10.0 + 2.0 * angle.cos()];
|
||||
h.app.run("solid.extrude", json!({"sketch":"Adjacent","distance":10,"from":point,"name":"Depth","body_name":"Main","operation":"new"})).unwrap();
|
||||
let min_z = h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z;
|
||||
open(&mut h);
|
||||
let base = crate::canvas::anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().0;
|
||||
assert!((base.z - min_z).abs() < 1e-7, "displayed start{} geometry start{min_z}", base.z);
|
||||
// Averaging the two unmerged rectangle loops gives x=3.5; their internal
|
||||
// seam must not bias the reference ray used by the extent handle.
|
||||
let unmerged_height = 10.0 + 2.0 / angle.cos() - 3.5 * angle.tan();
|
||||
assert!((base.z - unmerged_height).abs() > 0.01, "fixture must distinguish the raw-profile centre");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!((h.app.session.world_state().body("Main").unwrap().mesh().bounds().min.z - min_z).abs() < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_second_occurrence_keeps_saved_from_and_to_points_exact() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[1,0,0],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let original = extent(&h);
|
||||
let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(extent(&h), original);
|
||||
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
@@ -54,6 +54,8 @@ mod status_tests {
|
||||
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod extrude_edit_tests;
|
||||
mod file_load;
|
||||
pub mod palette;
|
||||
pub mod params_dialog;
|
||||
@@ -63,6 +65,8 @@ mod preselect_tests;
|
||||
pub mod preview;
|
||||
pub mod recovery_ui;
|
||||
pub mod ref_images;
|
||||
#[cfg(test)]
|
||||
mod revolve_edit_tests;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
pub mod shortcut_box;
|
||||
@@ -548,13 +552,13 @@ impl SolveApp {
|
||||
let Some(idx) = self.session.doc.feature_index(id) else { return };
|
||||
let marker = self.session.doc.marker;
|
||||
self.tool = None;
|
||||
if self.run("timeline.roll_to", json!({ "position": idx })).is_err() {
|
||||
if dialogs::roll_marker(self, Some(idx)).is_err() {
|
||||
return;
|
||||
}
|
||||
match dialogs::for_feature(self, id, marker) {
|
||||
Some(d) => self.dialog = Some(d),
|
||||
None => {
|
||||
let _ = self.run("timeline.roll_to", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({})));
|
||||
let _ = dialogs::roll_marker(self, marker);
|
||||
self.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}");
|
||||
self.ui.palette_open = true;
|
||||
}
|
||||
|
||||
@@ -8,12 +8,187 @@ use solvecraft_engine::{Sel, Session};
|
||||
use crate::dialogs::{Dialog, Kind, apply_commands};
|
||||
use crate::{Services, SolveApp};
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }];
|
||||
h.app.start("solid.emboss");
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 1);
|
||||
let planar = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(planar[0].1.get("face").is_none());
|
||||
h.call("ui.dialogInput", json!({"index":1}));
|
||||
h.call("ui.view", json!({"view":"back"}));
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[0,10,7.5]}})).unwrap();
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
assert!(matches!(dialog.inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve"));
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert_eq!(commands[0].1["targets"], json!(["Sleeve"]));
|
||||
assert!(commands[0].1.get("face").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let feature = h.app.session.doc.features.last().unwrap().id;
|
||||
let before = serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
let edited = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(edited[0].0, "timeline.redefine");
|
||||
assert_eq!(edited[0].1["params"]["targets"], json!(["Sleeve"]));
|
||||
assert!(edited[0].1["params"].get("face").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(feature).unwrap().kind).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_edit_retains_second_source_and_author_paths_and_all_profiles() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let source = h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap();
|
||||
let source_second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
let author = h.app.run("component.create", json!({"name":"Author"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]})).unwrap();
|
||||
let author_second = h.app.run("occurrence.copy", json!({"component":author,"translate":[0,100,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":author,"occurrence":author_second})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":{"origin":[120,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let target = h.app.session.occurrence_bodies(&[source_second], false).unwrap().into_iter().next().unwrap();
|
||||
let feature = h.app.run("solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[120,0,7.5],"depth":"1mm","targets":[target]})).unwrap()
|
||||
["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let before = h.app.session.doc.feature(feature).unwrap().clone();
|
||||
let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), .. } = &before.kind else { panic!("selected face") };
|
||||
assert_eq!(face.path, vec![source_second]);
|
||||
assert_eq!(face.author_path, vec![author_second]);
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
let Sel::Face { body, point, .. } = &dialog.inputs[1].items[0] else { panic!("refilled face") };
|
||||
assert_eq!(body, &target);
|
||||
assert!(point.dist(Vec3::new(120.0, 0.0, 7.5)) < 1e-8);
|
||||
let commands = apply_commands(&h.app, dialog).unwrap();
|
||||
assert_eq!(commands[0].1["params"]["profiles"], json!("all"));
|
||||
let Sel::Profile { sketch, index } = dialog.inputs[0].items[0] else { panic!("refilled profile") };
|
||||
let plane = crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap();
|
||||
assert!(plane.origin.dist(Vec3::new(120.0, 0.0, 0.0)) < 1e-8, "{plane:?}");
|
||||
let mut guide = crate::gpu::GpuScene::default();
|
||||
crate::viewport::profile_fill(&mut guide, &h.app, sketch, index, egui::Color32::BLUE, egui::Color32::BLUE);
|
||||
let (vertices, remainder) = guide.trans.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty() && !vertices.is_empty());
|
||||
let lift = h.app.cam.back() * (h.app.cam.half_height() * 0.002);
|
||||
for vertex in vertices {
|
||||
let coordinate = |offset| f32::from_le_bytes(vertex[offset..offset + 4].try_into().unwrap()) as f64;
|
||||
let point = Vec3::new(coordinate(0), coordinate(4), coordinate(8)) - lift;
|
||||
assert!((point.x - 120.0).abs() < 1e-4 && point.y.abs() < 2.01 && (4.99..=10.01).contains(&point.z), "{point:?}");
|
||||
}
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let volume = preview.after.body(&target).unwrap().mesh().measure().volume;
|
||||
assert!(volume > preview.before.body(&target).unwrap().mesh().measure().volume + 10.0);
|
||||
let colors = crate::preview::Colors::from_tokens(&crate::theme::Tokens::get());
|
||||
let built = crate::preview::compute(&h.app.session, &commands, colors).unwrap();
|
||||
assert!(built.replaced.contains(&target) && !built.scene.tris.is_empty(), "{:?}", built.replaced);
|
||||
let source_first = h.app.session.doc.occurrence_of(source).unwrap().id;
|
||||
h.app.run("browser.visibility", json!({"paths":[[source_first]],"visible":false})).unwrap();
|
||||
let hidden = crate::preview::compute(&h.app.session, &commands, colors).unwrap();
|
||||
assert_eq!(hidden.replaced, vec![target.clone()]);
|
||||
let visible = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
assert!(visible.before.body("Sleeve").is_none() && visible.after.body("Sleeve").is_none());
|
||||
assert!(visible.after.body(&target).is_some() && !hidden.scene.tris.is_empty());
|
||||
let mut shared = h.app.session.scratch();
|
||||
shared.execute("component.activate", &json!({"component":source,"occurrence":source_first})).unwrap();
|
||||
let new_body = shared
|
||||
.preview_visible_world(&[("solid.box".into(), json!({"body_name":"NewPreview","length":2,"width":2,"height":2,"operation":"new"}))])
|
||||
.unwrap();
|
||||
let added = new_body.after.bodies.iter().filter(|body| new_body.before.body(&body.name).is_none()).collect::<Vec<_>>();
|
||||
assert_eq!(added.len(), 1, "{:?}", added.iter().map(|body| &body.name).collect::<Vec<_>>());
|
||||
assert!(added[0].name.contains("Source:2"), "{}", added[0].name);
|
||||
let doc = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
let selection = h.app.session.selection.clone();
|
||||
let Kind::Emboss { depth, .. } = &mut h.app.dialog.as_mut().unwrap().kind else { panic!("Emboss editor") };
|
||||
*depth = "2 mm".into();
|
||||
let changed = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let changed_preview = h.app.session.preview_world(&changed).unwrap();
|
||||
assert!(changed_preview.after.body(&target).unwrap().mesh().measure().volume > volume + 10.0);
|
||||
let mut invalid = changed.clone();
|
||||
invalid[0].1["params"]["depth"] = json!("unknown_depth");
|
||||
assert!(h.app.session.preview_world(&invalid).is_err());
|
||||
assert_eq!(h.app.session.doc.to_json(), doc);
|
||||
assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert_eq!(h.app.session.doc.feature(feature).unwrap().kind, before.kind);
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
|
||||
let after = h.app.session.doc.feature(feature).unwrap();
|
||||
assert_eq!(after.kind, before.kind);
|
||||
assert_eq!(after.face_names, before.face_names);
|
||||
assert_eq!(h.app.session.active_component, 0);
|
||||
}
|
||||
|
||||
fn app_with(script: serde_json::Value) -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script).unwrap();
|
||||
SolveApp::new(s, Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_profile_guide_uses_target_or_unique_foreign_sketch_frame() {
|
||||
for target_owned in [true, false] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let source = if target_owned { h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap() } else { 0 };
|
||||
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
|
||||
let (profile_owner, expected, target, face) = if target_owned {
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
let target = h.app.session.occurrence_bodies(&[second], false).unwrap().remove(0);
|
||||
(source, Vec3::new(120.0, 0.0, 0.0), target, json!([120, 0, 7.5]))
|
||||
} else {
|
||||
let profile = h.app.run("component.create", json!({"name":"Profile"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":"Profile:1","axis":[0,0,1],"angle":"90 deg","translate":[50,20,0]})).unwrap();
|
||||
(profile, Vec3::new(0.0, 10.0, 0.0), "Sleeve".to_string(), json!([0, 10, 7.5]))
|
||||
};
|
||||
let plane = if target_owned {
|
||||
json!({"origin":[40,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]})
|
||||
} else {
|
||||
json!({"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]})
|
||||
};
|
||||
let sketch = h.app.run("sketch.create", json!({"plane":plane,"name":"Badge"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":"root"})).unwrap();
|
||||
let feature =
|
||||
h.app.run("solid.emboss", json!({"sketch":sketch,"profiles":"all","face":face,"depth":"1mm","targets":[target]})).unwrap()["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.edit_feature(feature);
|
||||
h.frames(2);
|
||||
assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap().unwrap().origin.dist(expected) < 1e-8);
|
||||
if !target_owned {
|
||||
h.app.run("occurrence.copy", json!({"component":profile_owner,"translate":[100,0,0]})).unwrap();
|
||||
assert!(crate::dialogs::emboss_profile_plane(&h.app, sketch).unwrap_err().contains("ambiguous"));
|
||||
let mut guide = crate::gpu::GpuScene::default();
|
||||
crate::viewport::profile_fill(&mut guide, &h.app, sketch, 0, egui::Color32::BLUE, egui::Color32::BLUE);
|
||||
assert!(guide.trans.is_empty() && guide.lines.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed() -> SolveApp {
|
||||
app_with(json!([{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}]))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{AxisRef, FeatureKind};
|
||||
|
||||
fn make(h: Harness, axis: Value) -> Harness {
|
||||
make_params(h, axis, json!({}))
|
||||
}
|
||||
fn make_params(mut h: Harness, axis: Value, extra: Value) -> Harness {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let mut params = json!({"sketch":"Profile","axis":axis,"angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
h.app.run("solid.revolve", params).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_all_profiles_retains_future_geometry_but_reselection_is_explicit() {
|
||||
for reselect in [false, true] {
|
||||
let mut h = make(Harness::new(), json!("Y"));
|
||||
open(&mut h);
|
||||
if reselect {
|
||||
let selection = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone();
|
||||
let mut dialog = h.app.dialog.take().unwrap();
|
||||
dialog.active = 0;
|
||||
dialog.pick(&h.app.session, selection.clone());
|
||||
dialog.pick(&h.app.session, selection);
|
||||
h.app.dialog = Some(dialog);
|
||||
h.frames(4);
|
||||
}
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let FeatureKind::Revolve { profiles, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(matches!(profiles, solvecraft_engine::doc::ProfileSel::All), !reselect);
|
||||
h.app.run("sketch.edit", json!({"sketch":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[25,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let volume: f64 = state.bodies.iter().map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum();
|
||||
let expected = if reselect { 490.873852 } else { 1374.446786 };
|
||||
assert!((volume - expected).abs() < 0.3, "{volume} != {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_noop_preserves_cut_targets_and_untargeted_body() {
|
||||
let mut h = Harness::new();
|
||||
for name in ["A", "B"] {
|
||||
h.app.run("solid.box", json!({"length":20,"width":5,"height":20,"corner":[0,0,0],"body_name":name})).unwrap();
|
||||
}
|
||||
let mut h = make_params(h, json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","operation":"cut","targets":["A"]}));
|
||||
let state = h.app.session.world_state();
|
||||
let before_a = solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume;
|
||||
assert!((before_a - (2000.0 - 490.873852)).abs() < 0.2);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let state = h.app.session.world_state();
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("B").unwrap().body).unwrap().volume - 2000.0).abs() < 1e-7);
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume - before_a).abs() < 1e-7);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let FeatureKind::Revolve { targets, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(targets, &["A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_object_noop_retains_dormant_secondary_angle_and_downstream_dimension() {
|
||||
let mut h = make_params(Harness::new(), json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","angle2":"30 deg","direction":"symmetric"}));
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI / 6.0).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_owner_to_point_remains_exact_on_noop_edit() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"}));
|
||||
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let frame = h.app.session.active_component_frame();
|
||||
let expected_origin = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::ZERO);
|
||||
let before: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
open(&mut h);
|
||||
let (origin, _) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!((origin - expected_origin).len() < 1e-8);
|
||||
assert!(origin.len() > 50.0, "fixture must distinguish first and second placements");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&vec![second.as_u64().unwrap()]));
|
||||
}
|
||||
fn axis(h: &Harness) -> Value {
|
||||
let FeatureKind::Revolve { axis, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
serde_json::to_value(axis).unwrap()
|
||||
}
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap();
|
||||
h.frames(8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_noop_editor_retains_all_revolve_axis_kinds_and_analytic_volume() {
|
||||
for axis_value in [json!("Y"), json!("y"), json!("l4"), json!({"origin":[0,0,0],"dir":[0,2,0]})] {
|
||||
let mut h = make(Harness::new(), axis_value.clone());
|
||||
let original = axis(&h);
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
let state = h.app.session.world_state();
|
||||
let body = state.body("Main").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
let radii = if axis_value == json!("l4") { 25.0 } else { 225.0 - 100.0 };
|
||||
let expected = 0.5 * radii * 5.0 * std::f64::consts::FRAC_PI_2;
|
||||
let volume = solvecraft_engine::doc::kernel::measure(&body.body).unwrap().volume;
|
||||
assert!((volume - expected).abs() < 0.03, "{axis_value}: {volume}");
|
||||
open(&mut h);
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
assert_eq!(preview.after.body("Main").unwrap().mesh().bounds(), bounds);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{axis_value}: {:?}", h.app.session.log);
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_angle_edit_preserves_axis_downstream_binding_and_one_undo() {
|
||||
let mut h = make(Harness::new(), json!({"origin":[0,0,0],"dir":[0,2,0]}));
|
||||
h.app.run("solid.box", json!({"length":2,"width":2,"height":"(d1 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
|
||||
let original = axis(&h);
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::PI).abs() < 1e-8);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_2).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_owner_explicit_axis_is_lossless_with_another_component_active() {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make(h, json!({"origin":[0,0,0],"dir":[0,2,0]}));
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap();
|
||||
let active = h.app.session.active_component;
|
||||
let original = axis(&h);
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
open(&mut h);
|
||||
let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8);
|
||||
let angle = 17.0f64.to_radians();
|
||||
assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
assert_eq!(h.app.session.active_component, active);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_and_sketch_axis_handles_follow_authoring_occurrence() {
|
||||
for selected in [json!("Y"), json!("y")] {
|
||||
let mut h = Harness::new();
|
||||
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
|
||||
let mut h = make(h, selected);
|
||||
h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap();
|
||||
let original = axis(&h);
|
||||
open(&mut h);
|
||||
let (origin, direction) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let angle = 17.0f64.to_radians();
|
||||
assert!((origin.x - 100.0).abs() < 1e-8 && (origin.z - 20.0).abs() < 1e-8);
|
||||
assert!((direction.x + angle.sin()).abs() < 1e-8 && (direction.y - angle.cos()).abs() < 1e-8);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(axis(&h), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_axis_cancel_restores_original_document_and_history() {
|
||||
let mut h = make(Harness::new(), json!("y"));
|
||||
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"180 deg"})).unwrap();
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert!(matches!(&h.app.session.doc.find_feature("Turn").unwrap().kind,FeatureKind::Revolve{axis:AxisRef::SketchAxis{axis},..} if axis=="y"));
|
||||
}
|
||||
@@ -1,6 +1,42 @@
|
||||
use super::*;
|
||||
use solvecraft_engine::sketch::CurveKind;
|
||||
|
||||
#[test]
|
||||
fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_origin = true;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[20,0],"major":[30,0],"minor_radius":5})).unwrap();
|
||||
h.app.run("sketch.constraint.fix", json!({"entity":"e1"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,3],[5,3]],"connect":false,"infer":false})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"start":"sketch.extend"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,3,0]}})).unwrap();
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
let end = ss.sketch.point(ss.sketch.resolve_point("l1.end").unwrap()).unwrap();
|
||||
assert!(end.dist(Vec2::new(12.0, 3.0)) < 1e-7, "clicked endpoint must extend: {end:?}, {:?}", h.app.status);
|
||||
assert_eq!(ss.sketch.point(ss.sketch.resolve_point("l1.start").unwrap()), Some(Vec2::new(0.0, 3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_offset_context_action_releases_geometry_and_undo_restores() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
|
||||
let out = h.app.run("sketch.offset", json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap();
|
||||
let id = out["curves"][0].as_str().unwrap().to_string();
|
||||
h.app.session.selection = vec![solvecraft_engine::Sel::SketchCurve { id: id.clone() }];
|
||||
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport);
|
||||
let item = items.iter().find(|item| item.id == "sketch.offset.break").unwrap();
|
||||
assert_eq!(item.params, Some(json!({"curve":id})));
|
||||
crate::context_menu::run_item(&mut h.app, item, egui::Pos2::ZERO);
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty());
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
|
||||
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
|
||||
|
||||
@@ -647,7 +647,13 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
}
|
||||
Mode::CurveAt => {
|
||||
if let (Some((id, false)), Some((p, _))) = (sketch_entity(app, &hits), at) {
|
||||
let curve = active_sketch(app).and_then(|active| {
|
||||
hits.iter().find_map(|hit| match hit {
|
||||
Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let (Some(id), Some((p, _))) = (curve, at) {
|
||||
let _ = app.run(cmd, json!({"curve": id, "at": xy(p)}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewport_appearance_tests.rs"]
|
||||
mod appearance_tests;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NavMode {
|
||||
Orbit,
|
||||
@@ -290,7 +294,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
let hidden_edges = app.ui.visual_style == 3;
|
||||
let hidden_col = c4(tk.text.gamma_multiply(0.35));
|
||||
// Appearances: the body's opacity, and faces with looks of their own.
|
||||
let opacity = s.doc.model_body_look(b).map_or(1.0, |l| l.opacity);
|
||||
let opacity = s.doc.model_body_opacity(b);
|
||||
let face_looks: std::collections::HashMap<usize, solvecraft_engine::doc::appearance::Look> =
|
||||
if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() };
|
||||
for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) {
|
||||
@@ -1086,13 +1090,18 @@ fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, fac
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = st.sketch(sketch) else { return };
|
||||
let Some(p) = ss.profiles.get(index) else { return };
|
||||
let plane = match crate::dialogs::emboss_profile_plane(app, sketch) {
|
||||
Ok(Some(plane)) => plane,
|
||||
Ok(None) => ss.plane,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Lift toward the eye so a profile on a face shows over it.
|
||||
let lift = app.cam.back() * (app.cam.half_height() * 0.002);
|
||||
let w = |q: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32();
|
||||
let w = |q: solvecraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32();
|
||||
let col = c4(fill);
|
||||
// In the translucent pass (after the grid and edges) so it covers them.
|
||||
for [a, b, c] in p.region.triangulate(0.02) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::build_scene;
|
||||
use crate::{Services, SolveApp};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Session, doc, geom::Vec3, kernel};
|
||||
|
||||
#[test]
|
||||
fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() {
|
||||
let body = kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0))
|
||||
.unwrap()
|
||||
.with_color(Some([0.2, 0.6, 0.8]))
|
||||
.with_paint(Some(kernel::Paint { opacity: 0.4, faces: Vec::new() }));
|
||||
let step =
|
||||
kernel::step_export_bodies(&[kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }], &Default::default())
|
||||
.unwrap();
|
||||
let mut document = doc::Document::default();
|
||||
let component = document.add_component(Some("Imported part"), 0).unwrap();
|
||||
let feature = document.add_feature(doc::FeatureKind::Import { file: "glass.step".into(), step, components: Vec::new() }, None).unwrap();
|
||||
document.features.iter_mut().find(|f| f.id == feature).unwrap().component = component;
|
||||
let mut session = Session::new(document);
|
||||
let source = session.model.state().bodies[0].name.clone();
|
||||
assert!((session.doc.model_body_opacity(&session.model.state().bodies[0]) - 0.4).abs() < 1e-6);
|
||||
session.execute("occurrence.copy", &json!({"component":component,"translate":[30,0,0]})).unwrap();
|
||||
let mut app = SolveApp::new(session, Services::default());
|
||||
app.ui.ground_shadow = false;
|
||||
app.ui.show_origin = false;
|
||||
let placed = app.session.world_state().bodies.iter().find(|b| b.name != source).unwrap().name.clone();
|
||||
app.session.selection = vec![solvecraft_engine::Sel::Body { name: placed }];
|
||||
app.start("appearance.assign");
|
||||
let crate::dialogs::Kind::Appearance(appearance) = &app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert!((appearance.opacity - 0.4).abs() < 1e-6);
|
||||
assert_eq!(appearance.color, [51, 153, 204]);
|
||||
crate::dialogs::cancel(&mut app);
|
||||
let scene = build_scene(&app);
|
||||
assert!(!scene.glass.is_empty());
|
||||
assert!(
|
||||
scene.tris.is_empty(),
|
||||
"opaque triangles {}, bodies {:?}",
|
||||
scene.tris.len(),
|
||||
app.session.world_state().bodies.iter().map(|b| (&b.name, b.body.paint(), &b.imported)).collect::<Vec<_>>()
|
||||
);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 102));
|
||||
app.run("appearance.assign", json!({"bodies":[source],"color":"#336699","opacity":0.7})).unwrap();
|
||||
let scene = build_scene(&app);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 179));
|
||||
app.run("appearance.assign", json!({"bodies":[source]})).unwrap();
|
||||
let scene = build_scene(&app);
|
||||
let (vertices, remainder) = scene.glass.as_chunks::<{ crate::gpu::TRI_SIZE }>();
|
||||
assert!(remainder.is_empty());
|
||||
assert!(vertices.iter().all(|v| v[27] == 102));
|
||||
}
|
||||
Reference in new issue
Block a user