diff --git a/ROADMAP.md b/ROADMAP.md index 6b09c647..001165a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 37c6ba5b..5dba61bb 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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) { diff --git a/crates/doc/src/naming.rs b/crates/doc/src/naming.rs index 430c81fa..a60c1478 100644 --- a/crates/doc/src/naming.rs +++ b/crates/doc/src/naming.rs @@ -173,6 +173,13 @@ pub fn face_names(b: &ModelBody) -> Arc> { /// 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 { + // 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); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index eca08287..300f3521 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -43,7 +43,7 @@ pub struct ViewportState { pub mouse: Option, /// Box selection being dragged. pub boxsel: Option, - /// 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, /// Bodies whose browser row is under the cursor: highlighted the same way. pub hover_bodies: Vec, @@ -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 { + 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 { + 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 { 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 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() { + let c = body_rgb(app, &b.name); + let m = b.mesh(); + 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) { let selected = app.highlighted(); let _ = ctx.run_ui(