Merge pull request #109 from CypherNaught-0x/fix/timeline-modifier-highlighting

Highlight fillets and other modifier faces from the timeline
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-10 20:56:59 -04:00
commit 17e8e3eff2
4 files changed
+161 -20

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
+5 -1
View File
@@ -2948,7 +2948,11 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
out.bodies.push(ModelBody::new(name, body, b.feature));
let mut placed = ModelBody::new(name, body, b.feature);
// Placement preserves topology: face ownership remains that of the
// source model, including modifiers on component instances.
placed.names = crate::naming::NameCell::known(crate::naming::face_names(b));
out.bodies.push(placed);
}
if primary {
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
+7
View File
@@ -173,6 +173,13 @@ pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
/// Work out a body's face names from the feature that made it and the model before it.
fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
// A rigid Move changes coordinates, not topology or face ownership. Geometric
// overlap with the unmoved input cannot recover the names at the new position.
if matches!(o.feature.kind, FeatureKind::Move { .. })
&& let Some(input) = o.before.body(&b.name)
{
return face_names(input).as_ref().clone();
}
let m = b.mesh();
let size = b.body.size().max(1e-6);
let tol = (size * 1e-4).max(1e-6);
+148 -18
View File
@@ -43,7 +43,7 @@ pub struct ViewportState {
pub mouse: Option<Pos2>,
/// Box selection being dragged.
pub boxsel: Option<BoxSel>,
/// Timeline item under the cursor: its bodies are highlighted.
/// Timeline item under the cursor: its bodies or resulting faces are highlighted.
pub hover_feature: Option<u64>,
/// Bodies whose browser row is under the cursor: highlighted the same way.
pub hover_bodies: Vec<String>,
@@ -862,13 +862,55 @@ pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> {
app.session.section
}
/// Visible geometry belonging to a timeline feature. Body creators retain their whole-body
/// feedback; modifiers use persistent face names, which survive later edits and placement.
/// Do not expand these faces into surface patches: neighbouring faces can belong to another
/// feature even when they share a surface.
fn feature_geometry(app: &SolveApp, id: u64) -> Vec<Sel> {
if !app.session.model.result(id).is_some_and(|r| !r.skipped && r.error.is_none()) {
return Vec::new();
}
let st = app.session.world_state();
let prefix = format!("F{id}:");
let mut out = Vec::new();
for b in &st.bodies {
if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
continue;
}
if b.feature == id {
out.push(Sel::Body { name: b.name.clone() });
continue;
}
let names = solvecraft_engine::doc::naming::face_names(b);
let m = b.mesh();
for (index, _) in names.iter().enumerate().filter(|(_, n)| n.starts_with(&prefix)) {
if let Some(point) = m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
if *f as usize != index {
return None;
}
m.tri(t).map(|[a, b, c]| (a + b + c) / 3.0)
}) {
out.push(Sel::Face { body: b.name.clone(), index, point });
}
}
}
out
}
fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec<Sel> {
match item {
Sel::Feature { id } => feature_geometry(app, *id),
_ => grouped_selection(&app.session, item),
}
}
/// The box around what is selected (bodies, faces, edges, vertices, sketch curves and the
/// bodies of selected features), where it is shown.
pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3> {
let st = app.session.world_state();
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
for s in &app.session.selection {
match s {
for s in app.session.selection.iter().flat_map(|item| displayed_selection(app, item)) {
match &s {
Sel::Body { name } => {
if let Some(x) = st.body(name) {
b = b.union(&x.mesh().bounds());
@@ -894,11 +936,6 @@ pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3
}
}
Sel::Vertex { point, .. } => b.add(*point),
Sel::Feature { id } => {
for x in st.bodies.iter().filter(|x| x.feature == *id) {
b = b.union(&x.mesh().bounds());
}
}
Sel::SketchCurve { id } => {
for ss in &st.sketches {
if let Some(i) = ss.sketch.curve_index(id) {
@@ -1095,7 +1132,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
let st = app.session.world_state();
let mut sel = Vec::new();
for item in app.highlighted() {
for piece in grouped_selection(&app.session, &item) {
for piece in displayed_selection(app, &item) {
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(piece);
}
@@ -1213,21 +1250,40 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
_ => {}
}
}
// Bodies made by the timeline item under the cursor, or whose browser row is.
if app.viewport.hover_feature.is_some() || !app.viewport.hover_bodies.is_empty() {
// Timeline modifiers light only their resulting faces; browser rows light whole bodies.
let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default();
hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() }));
for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name) && !app.preview.replaced.contains(&b.name)) {
let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
let faces: Vec<_> = if whole {
(0..b.body.face_count()).collect()
} else {
hovered
.iter()
.filter_map(|x| match x {
Sel::Face { body, index, .. } if body == &b.name => Some(*index),
_ => None,
})
.collect()
};
if faces.is_empty() {
continue;
}
let k = t.hover_face_lift;
let lit =
|b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name);
for b in st.bodies.iter().filter(|b| lit(b) && !app.session.visibility.hidden_bodies.contains(&b.name)) {
let c = body_rgb(app, &b.name);
let m = b.mesh();
for f in 0..b.body.face_count() {
for &f in &faces {
if !sel.iter().any(|x| {
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
}) {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false) {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
}
for (ei, e) in m.edges.iter().enumerate() {
if !m.seams.get(ei).copied().unwrap_or(false)
&& (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize)))))
{
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
}
}
}
@@ -2480,6 +2536,80 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests {
use super::*;
#[test]
fn timeline_modifiers_highlight_on_hover_and_click() {
for command in ["solid.fillet", "solid.chamfer"] {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_origin = false;
let base = h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap()["feature"].as_u64().unwrap();
let id = h.app.run(command, json!({"edges":[[0,0,10]],"radius":2,"distance":2,"name":"Blend"})).unwrap()["feature"].as_u64().unwrap();
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0]})).unwrap();
h.frames(2);
let geometry = feature_geometry(&h.app, id);
assert_eq!(geometry.len(), 1, "{command}: {geometry:?}");
let Sel::Face { body, index, .. } = &geometry[0] else { panic!("a modifier must highlight its face") };
let st = h.app.session.world_state();
let m = st.body(body).unwrap().mesh();
let triangles = m.tri_face.iter().filter(|f| **f as usize == *index).count();
assert!(triangles > 0 && triangles < m.triangles.len());
let p = crate::scenario::handle_at("timeline:Blend").unwrap();
h.step(&json!({"move":[p.x,p.y]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, Some(id));
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
assert!(!build_highlight(&h.app).lines.is_empty());
h.step(&json!({"click":[p.x,p.y]})).unwrap();
assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]);
// Moving away removes hover but leaves the selected blend highlighted.
h.step(&json!({"move":[5,5]})).unwrap();
assert_eq!(h.app.viewport.hover_feature, None);
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
let bounds = selection_bounds(&h.app).unwrap();
assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}");
// Creator features retain whole-body feedback, while hidden bodies stay hidden.
assert_eq!(feature_geometry(&h.app, base), vec![Sel::Body { name: body.clone() }]);
h.app.session.visibility.hidden_bodies.push(body.clone());
assert!(feature_geometry(&h.app, id).is_empty());
assert!(selection_bounds(&h.app).is_none());
assert!(build_highlight(&h.app).tris.is_empty());
}
}
#[test]
fn timeline_modifier_highlight_follows_edits_and_rollback() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
// A later chamfer changes the same body without consuming the fillet.
app.run("solid.chamfer", json!({"edges":[[20,20,10]],"distance":1})).unwrap();
app.run("solid.move", json!({"bodies":["Body1"],"translate":[30,0,0]})).unwrap();
app.run("body.rename", json!({"body":"Body1","name":"Moved"})).unwrap();
app.session.selection = vec![Sel::Feature { id }];
let bounds = selection_bounds(&app).unwrap();
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01, "{bounds:?}");
assert!(feature_geometry(&app, id).iter().all(|x| matches!(x, Sel::Face { body, .. } if body == "Moved")));
app.run("timeline.roll_to", json!({"position":1})).unwrap();
assert!(feature_geometry(&app, id).is_empty());
app.run("timeline.roll_to", json!({})).unwrap();
assert!(!feature_geometry(&app, id).is_empty());
app.run("timeline.suppress", json!({"feature":id})).unwrap();
assert!(feature_geometry(&app, id).is_empty());
}
#[test]
fn timeline_modifier_highlight_follows_component_instances() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.run("component.create", json!({"name":"Part"})).unwrap();
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
app.run("occurrence.move", json!({"occurrence":"Part:1","translate":[30,0,0]})).unwrap();
app.run("occurrence.copy", json!({"component":"Part","translate":[0,40,0]})).unwrap();
let geometry = feature_geometry(&app, id);
assert_eq!(geometry.len(), 2, "both occurrences retain the fillet's face: {geometry:?}");
app.session.selection = vec![Sel::Feature { id }];
let bounds = selection_bounds(&app).unwrap();
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01 && bounds.max.y >= 41.99, "{bounds:?}");
}
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let selected = app.highlighted();
let _ = ctx.run_ui(